Files
bare-operating-system/packages/bare-os-booter/lib/fish-readline.js
T
2026-04-03 21:39:36 -04:00

859 lines
23 KiB
JavaScript

/**
* Fish / Zsh-inspired raw TTY line editor (hyper-os style).
* One integrated loop — not layered on node:readline.
*
* Session kill-switch: `BARE_OS_FISH=0` (see createKernelReplSession in repl-session.js).
* Non-TTY booters keep the lazy bare-readline / stream fallback from index.js.
*/
/** @type {readonly string[]} */
export const SHELL_BUILTINS = [
'barerc',
'cd',
'export',
'exit',
'login',
'logout'
]
/** @param {Record<string, unknown>} ctx */
function replHistoryDrivePath(ctx) {
const u = String(
ctx.vfs?.env?.USER ?? (ctx.env && ctx.env.USER) ?? 'guest'
).replace(/[^a-zA-Z0-9._-]/g, '_')
return `/.bare/repl_history_${u}`
}
const HISTORY_CAP = 1000
const FLAG_MAP = {
ls: ['-a', '-l', '-la', '-h'],
grep: ['-i', '-v', '-n', '-c'],
find: ['-name', '-type', '-size'],
sort: ['-r', '-n', '-u'],
rm: ['-r', '-f', '-rf']
}
/** @param {string} str */
export function stripAnsi(str) {
return str.replace(/\x1b\[[0-9;]*m/g, '')
}
/**
* @param {string} str
* @param {string} pattern
*/
export function fuzzyMatch(str, pattern) {
let patternIdx = 0
for (let i = 0; i < str.length && patternIdx < pattern.length; i++) {
if (str[i].toLowerCase() === pattern[patternIdx].toLowerCase()) {
patternIdx++
}
}
return patternIdx === pattern.length
}
/**
* @param {Array<string | { command?: string }>} history
* @param {string} query
* @returns {string[]}
*/
export function searchHistoryEntries(history, query) {
if (!query) return []
const results = []
for (const entry of history) {
const cmd = typeof entry === 'string' ? entry : (entry.command ?? '')
if (cmd.includes(query)) results.push(cmd)
}
return results.reverse()
}
/**
* @param {string} data
* @returns {Array<{ timestamp: number, command: string }>}
*/
export function parseHistoryFile(data) {
const lines = data.split('\n').filter((l) => l.length > 0)
return lines.map((line) => {
if (line.startsWith('#')) {
const match = line.match(/^#(\d+):(.+)$/)
if (match) {
return { timestamp: parseInt(match[1], 10), command: match[2] }
}
}
return { timestamp: Date.now(), command: line }
})
}
/**
* @param {Array<{ timestamp: number, command: string }>} entries
*/
export function formatHistoryFile(entries) {
return entries.map((e) => `#${e.timestamp}:${e.command}`).join('\n')
}
/**
* @param {Array<{ timestamp: number, command: string } | string>} history
*/
export function dedupeConsecutiveHistory(history) {
const deduped = []
let lastCommand = null
for (const entry of history) {
const cmd = typeof entry === 'string' ? entry : entry.command
if (cmd !== lastCommand) {
deduped.push(
typeof entry === 'string'
? { timestamp: Date.now(), command: entry }
: entry
)
lastCommand = cmd
}
}
return deduped
}
/**
* @param {import('stream').Readable} stdin
* @param {import('stream').Writable} stdout
*/
function canUseRichTty(stdin, stdout) {
if (!stdin || !stdout) return false
if (!stdin.isTTY) return false
if (typeof stdin.setRawMode !== 'function') return false
if (typeof stdout.write !== 'function') return false
return true
}
/**
* @param {import('stream').Writable} stdout
*/
function createScreen(stdout) {
const hasCursor = typeof stdout.cursorTo === 'function'
const hasClear = typeof stdout.clearLine === 'function'
return {
clearLineAndCarriage() {
if (hasClear) {
stdout.cursorTo(0)
stdout.clearLine(0)
} else {
stdout.write('\r\x1b[2K')
}
},
/** Move up n lines (from current row). */
moveUp(n) {
if (n <= 0) return
if (hasCursor) {
stdout.write(`\x1b[${n}A`)
} else {
stdout.write(`\x1b[${n}A`)
}
},
cursorToColumn(col) {
if (hasCursor) {
stdout.cursorTo(col)
} else {
stdout.write(`\x1b[${col + 1}G`)
}
}
}
}
/**
* @param {Record<string, unknown>} ctx
*/
export function disableFishRawMode(stdin) {
if (stdin && typeof stdin.setRawMode === 'function') {
try {
stdin.setRawMode(false)
} catch {
/* ignore */
}
}
}
/** @param {import('stream').Readable} stdin */
export function releaseFishStdin(stdin) {
if (stdin && fishStdinDataHandler.has(stdin)) {
stdin.removeListener('data', fishStdinDataHandler.get(stdin))
fishStdinDataHandler.delete(stdin)
}
}
/**
* Temporarily release the TTY from fish (e.g. before spawning a fullscreen subprocess).
* Keeps the data handler registered in {@link fishStdinDataHandler} so
* {@link resumeFishStdinAfterSubprocess} can re-attach it.
* @param {import('stream').Readable | null | undefined} stdin
* @returns {boolean} true if fish had this stdin attached
*/
export function suspendFishStdinForSubprocess(stdin) {
if (!stdin || !fishStdinDataHandler.has(stdin)) return false
disableFishRawMode(stdin)
const h = fishStdinDataHandler.get(stdin)
stdin.removeListener('data', h)
return true
}
/**
* Restore fish raw mode and stdin listener after a subprocess exits.
* @param {import('stream').Readable | null | undefined} stdin
*/
export function resumeFishStdinAfterSubprocess(stdin) {
if (!stdin || !fishStdinDataHandler.has(stdin)) return false
const h = fishStdinDataHandler.get(stdin)
stdin.on('data', h)
try {
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
} catch {
/* ignore */
}
try {
if (typeof stdin.resume === 'function') stdin.resume()
} catch {
/* ignore */
}
return true
}
/** @type {WeakMap<import('stream').Readable, (chunk: Buffer | string) => void>} */
const fishStdinDataHandler = new WeakMap()
/**
* @param {Record<string, unknown>} ctx
* @param {{
* stdin: import('stream').Readable,
* stdout: import('stream').Writable,
* writeScreen?: (chunk: string) => void
* }} opts
* @returns {Promise<((prompt: string) => Promise<string | null>) | null>}
*/
export async function createFishReadLine(ctx, { stdin, stdout, writeScreen }) {
if (!canUseRichTty(stdin, stdout)) return null
const vfs = ctx.vfs
const env = ctx.env && typeof ctx.env === 'object' ? ctx.env : {}
const b4a = ctx.b4a
const personalDrive = ctx.personalDrive
if (
!vfs ||
typeof vfs.readdir !== 'function' ||
typeof vfs.getcwd !== 'function'
) {
return null
}
const historyPath = replHistoryDrivePath(ctx)
/** @type {Array<{ timestamp: number, command: string }>} */
let history = []
try {
const buf =
personalDrive && typeof personalDrive.get === 'function'
? await personalDrive.get(historyPath)
: null
if (buf && b4a) {
const text = b4a.toString(buf)
history = dedupeConsecutiveHistory(parseHistoryFile(text))
}
} catch {
history = []
}
const screen = createScreen(stdout)
async function saveHistory(line) {
const trimmed = line.trim()
if (!trimmed) return
history = history.filter((e) => e.command !== trimmed)
history.push({ timestamp: Date.now(), command: trimmed })
while (history.length > HISTORY_CAP) history.shift()
try {
if (personalDrive && typeof personalDrive.put === 'function' && b4a) {
await personalDrive.put(
replHistoryDrivePath(ctx),
b4a.from(formatHistoryFile(history))
)
}
} catch {
/* ignore */
}
}
let historyIndex = -1
/** @type {string | null} */
let currentPromptText = '> '
let line = ''
let lines = ['']
let currentLineIndex = 0
let cursor = 0
let ghost = ''
let tabMatches = []
let tabIndex = -1
let originalLine = ''
let historySearchActive = false
let historySearchQuery = ''
/** @type {string[]} */
let historySearchResults = []
let historySearchIndex = -1
let savedLine = ''
/** @type {((value: string | null) => void) | null} */
let pendingResolve = null
let inputBuffer = ''
let processing = false
function colorFromEnv(key, fallback) {
const v = env[key]
return v != null && String(v) !== '' ? String(v) : fallback
}
function getPromptBase(isContinuation = false) {
let displayPath = vfs.getcwd()
const home = env.HOME || '/home/guest'
if (displayPath === home) {
displayPath = '~'
} else if (displayPath.startsWith(home + '/')) {
displayPath = '~' + displayPath.slice(home.length)
}
const user = env.USER || 'user'
const host = env.HOSTNAME || 'bare-os'
const open = colorFromEnv('BARE_OS_COLOR_PROMPT', '\x1b[32m')
const base = `${open}[${user}@${host}:${displayPath}]\x1b[0m`
return isContinuation ? `${base} ` : `${base} `
}
function visiblePrompt(isContinuation) {
return (
getPromptBase(isContinuation) +
(isContinuation ? '> ' : currentPromptText)
)
}
/** Cached /bin names (no .js) */
let cachedBinNames = null
async function loadBinNames() {
if (cachedBinNames) return cachedBinNames
try {
const entries = await vfs.readdir('/bin')
cachedBinNames = entries.map((e) => e.replace(/\.js$/i, ''))
} catch {
cachedBinNames = []
}
return cachedBinNames
}
/** @param {string} firstLine */
function colorFirstLine(firstLine) {
const parts = firstLine.split(/\s+/)
const cmd = parts[0]
const knownCmd = new Set([...SHELL_BUILTINS, ...(cachedBinNames || [])])
let coloredLine = firstLine
const cmdOpen = colorFromEnv('BARE_OS_COLOR_COMMAND', '\x1b[36m')
const pathOpen = colorFromEnv('BARE_OS_COLOR_PATH', '\x1b[33m')
const envSetOpen = colorFromEnv('BARE_OS_COLOR_ENVSET', '\x1b[35m')
const envUnsetOpen = colorFromEnv('BARE_OS_COLOR_ENVUNSET', '\x1b[31m')
if (cmd && knownCmd.has(cmd)) {
coloredLine = `${cmdOpen}${cmd}\x1b[0m${firstLine.slice(cmd.length)}`
}
const pathRegex = /(\/[^\s]+|\.\/[^\s]+|\.\.\/[^\s]+)/g
coloredLine = coloredLine.replace(
pathRegex,
(match) => `${pathOpen}${match}\x1b[0m`
)
const envRegex = /\$(\w+)/g
coloredLine = coloredLine.replace(envRegex, (match, varName) => {
const value = env[varName]
return value
? `${envSetOpen}${match}\x1b[0m`
: `${envUnsetOpen}${match}\x1b[0m`
})
return coloredLine
}
function updateGhost() {
const currentLine = lines[currentLineIndex] || ''
if (currentLine.length === 0 || historySearchActive) {
ghost = ''
return
}
const matches = history.filter((e) => e.command.startsWith(currentLine))
if (matches.length > 0) {
ghost = matches[matches.length - 1].command
} else {
ghost = ''
}
}
function render(showHistorySearch = false) {
const n = lines.length
if (n > 1) {
for (let i = 0; i < n; i++) {
screen.clearLineAndCarriage()
if (i < n - 1) stdout.write('\n')
}
screen.clearLineAndCarriage()
screen.moveUp(n - 1)
} else {
screen.clearLineAndCarriage()
}
for (let i = 0; i < lines.length; i++) {
const isLastLine = i === lines.length - 1
const prompt = visiblePrompt(i > 0)
const currentLine = lines[i]
let coloredLine = currentLine
if (i === 0) {
coloredLine = colorFirstLine(currentLine)
}
stdout.write(prompt + coloredLine)
if (
isLastLine &&
ghost &&
currentLine.length > 0 &&
ghost.startsWith(currentLine) &&
!historySearchActive
) {
stdout.write(
`${colorFromEnv('BARE_OS_COLOR_GHOST', '\x1b[90m')}${ghost.slice(currentLine.length)}\x1b[0m`
)
}
if (!isLastLine) stdout.write('\n')
}
if (showHistorySearch || historySearchActive) {
stdout.write(
`\n${colorFromEnv('BARE_OS_COLOR_SEARCH', '\x1b[36m')}(reverse-i-search)'${historySearchQuery}':\x1b[0m ${historySearchResults[historySearchIndex] || ''}`
)
}
const lastPrompt = visiblePrompt(lines.length > 1)
const visualPromptLength = stripAnsi(lastPrompt).length
screen.cursorToColumn(visualPromptLength + cursor)
}
async function handleTab() {
if (historySearchActive) return
const currentLine = lines[currentLineIndex]
if (tabMatches.length === 0) {
const parts = currentLine.slice(0, cursor).split(/\s+/)
const currentWord = parts[parts.length - 1] ?? ''
const isCommand = parts.length <= 1
originalLine = currentLine
if (isCommand) {
const bins = await loadBinNames()
const all = Array.from(new Set([...bins, ...SHELL_BUILTINS]))
const exactMatches = all.filter((b) => b.startsWith(currentWord))
if (exactMatches.length > 0) {
tabMatches = exactMatches
} else if (currentWord.length > 0) {
tabMatches = all.filter((b) => fuzzyMatch(b, currentWord))
} else {
tabMatches = all
}
} else {
if (currentWord.startsWith('$')) {
const varName = currentWord.slice(1)
const envVars = Object.keys(env)
tabMatches = envVars
.filter((v) => v.startsWith(varName))
.map((v) => `$${v}`)
} else if (currentWord.startsWith('-') && parts.length === 2) {
const cmd = parts[0]
const flags = FLAG_MAP[cmd]
if (flags) {
tabMatches = flags.filter((f) => f.startsWith(currentWord))
}
} else {
let pathPart = currentWord
let dir = '.'
let filePrefix = ''
if (pathPart.includes('/')) {
const lastSlash = pathPart.lastIndexOf('/')
dir =
pathPart.slice(0, lastSlash) ||
(pathPart.startsWith('/') ? '/' : '.')
filePrefix = pathPart.slice(lastSlash + 1)
} else {
filePrefix = pathPart
}
try {
const entries = await vfs.readdir(dir)
const exactMatches = entries.filter((e) => e.startsWith(filePrefix))
if (exactMatches.length > 0) {
tabMatches = exactMatches.map((e) => {
const prefix =
dir === '.' ? '' : dir.endsWith('/') ? dir : dir + '/'
return prefix + e
})
} else if (filePrefix.length > 0) {
tabMatches = entries
.filter((e) => fuzzyMatch(e, filePrefix))
.map((e) => {
const prefix =
dir === '.' ? '' : dir.endsWith('/') ? dir : dir + '/'
return prefix + e
})
}
} catch {
tabMatches = []
}
}
}
}
if (tabMatches.length > 0) {
tabIndex = (tabIndex + 1) % tabMatches.length
const match = tabMatches[tabIndex]
const parts = originalLine.slice(0, cursor).split(/\s+/)
parts[parts.length - 1] = match
const newLine = parts.join(' ') + originalLine.slice(cursor)
lines[currentLineIndex] = newLine
line = lines.join('\n')
cursor = parts.join(' ').length
render()
}
}
async function processOneKey(key) {
if (pendingResolve === null) return
if (key === '\u0003') {
if (historySearchActive) {
historySearchActive = false
line = savedLine
lines = line ? [line] : ['']
currentLineIndex = 0
cursor = line.length
render()
return
}
const emptyInput =
lines.length === 1 &&
lines[0].length === 0 &&
currentLineIndex === 0 &&
cursor === 0
if (emptyInput) {
const resolve = pendingResolve
pendingResolve = null
stdout.write('^C\n')
resolve(null)
return
}
stdout.write('^C\n')
line = ''
lines = ['']
currentLineIndex = 0
cursor = 0
ghost = ''
tabMatches = []
tabIndex = -1
render()
return
}
if (key === '\u0004') {
const resolve = pendingResolve
pendingResolve = null
resolve(null)
return
}
if (key === '\u0012' || (key.length === 1 && key.charCodeAt(0) === 18)) {
if (!historySearchActive) {
historySearchActive = true
savedLine = line
historySearchQuery = ''
historySearchResults = []
historySearchIndex = -1
} else if (historySearchResults.length > 0) {
historySearchIndex =
(historySearchIndex + 1) % historySearchResults.length
}
render(true)
return
}
if (key === '\u000c') {
stdout.write('\x1b[2J\x1b[H\x1b[0m')
if (typeof writeScreen === 'function') {
try {
writeScreen('')
} catch {
/* ignore */
}
}
render()
return
}
if (key === '\r' || key === '\n') {
if (historySearchActive) {
if (historySearchResults.length > 0 && historySearchIndex >= 0) {
line = historySearchResults[historySearchIndex]
lines = [line]
currentLineIndex = 0
cursor = line.length
}
historySearchActive = false
historySearchQuery = ''
historySearchResults = []
historySearchIndex = -1
render()
return
}
const currentLine = lines[currentLineIndex]
const trimmedEnd = currentLine.trimEnd()
if (trimmedEnd.endsWith('\\') && trimmedEnd.length > 1) {
lines[currentLineIndex] = currentLine.slice(
0,
currentLine.lastIndexOf('\\')
)
lines.push('')
currentLineIndex++
cursor = 0
line = lines.join('\n')
stdout.write('\n')
render()
return
}
stdout.write('\n')
const cmdToExec = lines.join('\n').trim()
const resolve = pendingResolve
pendingResolve = null
historyIndex = -1
line = ''
lines = ['']
currentLineIndex = 0
cursor = 0
ghost = ''
tabMatches = []
tabIndex = -1
if (cmdToExec) await saveHistory(cmdToExec)
resolve(cmdToExec)
return
}
if (historySearchActive) {
if (key === '\u007f') {
historySearchQuery = historySearchQuery.slice(0, -1)
} else if (key.length === 1 && key >= ' ') {
historySearchQuery += key
}
historySearchResults = searchHistoryEntries(
history.map((h) => h.command),
historySearchQuery
)
historySearchIndex =
historySearchResults.length > 0 ? historySearchResults.length - 1 : -1
render(true)
return
}
if (key === '\u007f') {
const currentLine = lines[currentLineIndex]
if (cursor > 0) {
lines[currentLineIndex] =
currentLine.slice(0, cursor - 1) + currentLine.slice(cursor)
cursor--
line = lines.join('\n')
tabMatches = []
tabIndex = -1
updateGhost()
render()
} else if (currentLineIndex > 0) {
const prevLine = lines[currentLineIndex - 1]
lines[currentLineIndex - 1] = prevLine + lines[currentLineIndex]
lines.splice(currentLineIndex, 1)
currentLineIndex--
cursor = prevLine.length
line = lines.join('\n')
render()
}
return
}
if (key === '\t') {
await handleTab()
return
}
if (key.startsWith('\u001b[')) {
const code = key.slice(2)
const currentLine = lines[currentLineIndex]
if (code === 'C') {
if (ghost && ghost.startsWith(currentLine) && !historySearchActive) {
lines[currentLineIndex] = ghost
line = lines.join('\n')
cursor = ghost.length
ghost = ''
} else if (cursor < currentLine.length) {
cursor++
} else if (currentLineIndex < lines.length - 1) {
currentLineIndex++
cursor = 0
}
render()
} else if (code === 'D') {
if (cursor > 0) {
cursor--
} else if (currentLineIndex > 0) {
currentLineIndex--
cursor = lines[currentLineIndex].length
}
render()
} else if (code === 'A') {
if (historyIndex < history.length - 1) {
historyIndex++
const entry = history[history.length - 1 - historyIndex]
line = entry.command
lines = [line]
currentLineIndex = 0
cursor = line.length
render()
}
} else if (code === 'B') {
if (historyIndex > 0) {
historyIndex--
const entry = history[history.length - 1 - historyIndex]
line = entry.command
lines = [line]
currentLineIndex = 0
cursor = line.length
render()
} else if (historyIndex === 0) {
historyIndex = -1
line = ''
lines = ['']
currentLineIndex = 0
cursor = 0
render()
}
}
return
}
if (key.length === 1 && key >= ' ') {
const currentLine = lines[currentLineIndex]
lines[currentLineIndex] =
currentLine.slice(0, cursor) + key + currentLine.slice(cursor)
cursor++
line = lines.join('\n')
tabMatches = []
tabIndex = -1
updateGhost()
render()
}
}
async function drainInputBuffer() {
if (processing) return
processing = true
try {
while (inputBuffer.length && pendingResolve !== null) {
let consumed = false
if (inputBuffer[0] === '\x1b') {
const arrow = inputBuffer.match(/^\x1b\[([A-D])/)
if (arrow) {
await processOneKey('\x1b[' + arrow[1])
inputBuffer = inputBuffer.slice(arrow[0].length)
consumed = true
} else if (inputBuffer.length >= 1 && inputBuffer[1] !== '[') {
await processOneKey(inputBuffer[0])
inputBuffer = inputBuffer.slice(1)
consumed = true
} else if (inputBuffer.length < 3) {
break
} else {
const m = inputBuffer.match(/^\x1b\[[\d;]*[A-Za-z]/)
if (m) {
await processOneKey(m[0])
inputBuffer = inputBuffer.slice(m[0].length)
consumed = true
} else {
await processOneKey(inputBuffer[0])
inputBuffer = inputBuffer.slice(1)
consumed = true
}
}
} else {
await processOneKey(inputBuffer[0])
inputBuffer = inputBuffer.slice(1)
consumed = true
}
if (!consumed) break
}
} finally {
processing = false
}
}
function onStdinData(chunk) {
const s =
typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
inputBuffer += s
void drainInputBuffer()
}
if (fishStdinDataHandler.has(stdin)) {
releaseFishStdin(stdin)
}
fishStdinDataHandler.set(stdin, onStdinData)
stdin.setEncoding('utf8')
stdin.setRawMode(true)
stdin.resume()
stdin.on('data', onStdinData)
void loadBinNames()
stdin.once('end', () => {
if (pendingResolve) {
const r = pendingResolve
pendingResolve = null
r(null)
}
})
/**
* @param {string} prompt
* @returns {Promise<string | null>}
*/
return function readLine(prompt) {
void prompt
/* Always same suffix; ignore kernel prompt so older /boot/init.js cannot show e.g. bare-os> */
currentPromptText = '> '
historyIndex = -1
line = ''
lines = ['']
currentLineIndex = 0
cursor = 0
ghost = ''
tabMatches = []
tabIndex = -1
historySearchActive = false
historySearchQuery = ''
historySearchResults = []
historySearchIndex = -1
inputBuffer = ''
return new Promise((resolve) => {
pendingResolve = resolve
updateGhost()
render()
})
}
}