/** * 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} 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} 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} 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 void>} */ const fishStdinDataHandler = new WeakMap() /** * @param {Record} ctx * @param {{ * stdin: import('stream').Readable, * stdout: import('stream').Writable, * writeScreen?: (chunk: string) => void * }} opts * @returns {Promise<((prompt: string) => Promise) | 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} */ 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() }) } }