/** * 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. */ import { listBareOsShellBuiltins } from './shell.js' import { XTERM_CLEAR_SCROLLBACK_AND_VIEWPORT } from './terminal-escapes.js' import { completeLine, loadBareOsManDb, longestCommonCompletionPrefix, menuVisibleCap, registerBareOsCompleter, suggestGhostFromFs, suggestGhostFromHistory, unregisterBareOsCompleter } from './completion-engine.js' /** Frozen builtin names (read omitted unless enabled in env — see {@link listBareOsShellBuiltins}). */ export const SHELL_BUILTINS = Object.freeze(listBareOsShellBuiltins({})) /** @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 LOGIN_HISTORY_COMMAND_RE = /(?:^|[;&|]\s*)login(?:\s|$)/ /** @param {string} str */ export function stripAnsi(str) { return String(str).replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/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 {string} line */ export function shouldPersistShellHistoryCommand(line) { const trimmed = String(line).trim() if (!trimmed) return false return !LOGIN_HISTORY_COMMAND_RE.test(trimmed) } /** * @param {Array<{ timestamp: number, command: string }>} entries */ export function scrubShellHistoryEntries(entries) { const kept = [] let removed = 0 for (const entry of entries) { if (shouldPersistShellHistoryCommand(entry.command)) kept.push(entry) else removed++ } return { entries: kept, removed } } /** * @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 {string} line * @param {number} cursor * @param {string} value * @param {{ endsWithWhitespace: boolean }} cx */ function applyCompletionValue(line, cursor, value, cx) { if (cx.endsWithWhitespace) { const before = line.slice(0, cursor) const nl = before + value + line.slice(cursor) const nc = before.length + value.length return { line: nl, cursor: nc } } let start = cursor while (start > 0 && !/\s/.test(line[start - 1])) start-- const before = line.slice(0, start) const after = line.slice(cursor) const nl = before + value + after const nc = start + value.length return { line: nl, cursor: nc } } /** @param {{ kind?: string, description?: string }} it */ function iconForCompletionItem(it) { const d = (it.description || '').toLowerCase() if ( it.kind === 'builtin' || it.kind === 'bin' || it.kind === 'pathcmd' || it.kind === 'alias' ) return '⚡' if (it.kind === 'flag' || it.kind === 'manpage') return '⚙️' if (it.kind === 'process') return '🧩' if (it.kind === 'initd') return '🔧' if (d.startsWith('directory')) return '📁' if (d.startsWith('symlink')) return '🔗' return '📄' } /** * Resolve an optional prompt hook segment with timeout protection. * Returns empty string on timeout/error/non-string outputs. * @param {Record} ctx * @param {Record} env */ export async function resolveShellPromptHookSegment(ctx, env) { const hook = ctx && typeof ctx.shellPromptHook === 'function' ? ctx.shellPromptHook : null if (!hook) return '' const raw = Number.parseInt(String(env.BARE_OS_SHELL_PROMPT_HOOK_TIMEOUT_MS || '25'), 10) const timeoutMs = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 5000) : 25 try { const v = await Promise.race([ Promise.resolve().then(() => hook(ctx)), new Promise((resolve) => setTimeout(() => resolve(''), timeoutMs)) ]) return typeof v === 'string' ? v : '' } catch { return '' } } /** * @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 let promptHookSegment = '' if ( !vfs || typeof vfs.readdir !== 'function' || typeof vfs.getcwd !== 'function' ) { return null } const historyPath = replHistoryDrivePath(ctx) async function writeHistoryFile(path, entries) { if (personalDrive && typeof personalDrive.put === 'function' && b4a) { await personalDrive.put(path, b4a.from(formatHistoryFile(entries))) } } /** Set `BARE_OS_SHELL_NO_EXIT_CONFIRM=1` to restore instant ^C/^D exit (automation / power users). */ const exitConfirmOptOut = env.BARE_OS_SHELL_NO_EXIT_CONFIRM === '1' || env.BARE_OS_SHELL_NO_EXIT_CONFIRM === 'true' /** * After ^D, ^X, or ^C on an empty line, next key (Y) ends the shell; anything else cancels. * @type {boolean} */ let shellExitConfirmPending = false /** @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) const scrubbed = scrubShellHistoryEntries( dedupeConsecutiveHistory(parseHistoryFile(text)) ) history = scrubbed.entries if (scrubbed.removed > 0) await writeHistoryFile(historyPath, history) } } catch { history = [] } const screen = createScreen(stdout) async function saveHistory(line) { const trimmed = line.trim() if (!trimmed) return if (!shouldPersistShellHistoryCommand(trimmed)) return history = history.filter((e) => e.command !== trimmed) history.push({ timestamp: Date.now(), command: trimmed }) while (history.length > HISTORY_CAP) history.shift() try { await writeHistoryFile(replHistoryDrivePath(ctx), 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 completionMenuOpen = false /** @type {import('./completion-engine.js').CompletionItem[]} */ let completionItems = [] let completionIndex = 0 let completionScroll = 0 let completionTotal = 0 /** @type {ReturnType | null} */ let lastCompletionCx = null /** @type {Record | null} */ let lastManPage = null let manPreviewText = '' /** @type {string | null} */ let lastExecutedFirst = null let inlineTabKeySig = '' let historySearchActive = false let historySearchQuery = '' /** @type {string[]} */ let historySearchResults = [] let historySearchIndex = -1 let savedLine = '' const TAB_BURST_MS = 420 let lastTabAt = 0 let lastTabSig = '' let ghostRefreshSeq = 0 /** @type {ReturnType | null} */ let ghostDebounceTimer = null /** @type {((value: string | null) => void) | null} */ let pendingResolve = null let inputBuffer = '' /** xterm.js bracketed paste: consume between `\x1b[200~` … `\x1b[201~` without CSI parsing */ let bracketedPasteActive = false 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) + (promptHookSegment ? String(promptHookSegment) : '') ) } async function refreshPromptHookSegment() { promptHookSegment = await resolveShellPromptHookSegment(ctx, env) } /** 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([ ...listBareOsShellBuiltins(env), ...(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 clearGhostDebounce() { if (ghostDebounceTimer) { clearTimeout(ghostDebounceTimer) ghostDebounceTimer = null } } /** Sync fallback while VFS ghost is loading (history only). */ function updateGhostSyncHistoryOnly() { const currentLine = lines[currentLineIndex] || '' if ( currentLine.length === 0 || historySearchActive || completionMenuOpen ) { ghost = '' return } ghost = suggestGhostFromHistory( history, currentLine, lastExecutedFirst ) } async function refreshGhostFull() { const seq = ++ghostRefreshSeq const currentLine = lines[currentLineIndex] || '' if ( currentLine.length === 0 || historySearchActive || completionMenuOpen ) { if (seq === ghostRefreshSeq) ghost = '' return } let pathG = '' try { pathG = await suggestGhostFromFs(ctx, env, currentLine, cursor) } catch { pathG = '' } if (seq !== ghostRefreshSeq) return const histG = suggestGhostFromHistory( history, currentLine, lastExecutedFirst ) const b = currentLine.trimEnd() if (pathG && pathG.startsWith(b) && pathG.length > b.length) { ghost = pathG } else { ghost = histG } render() } function scheduleUpdateGhost() { const currentLine = lines[currentLineIndex] || '' if ( currentLine.length === 0 || historySearchActive || completionMenuOpen ) { clearGhostDebounce() ghost = '' return } updateGhostSyncHistoryOnly() clearGhostDebounce() ghostDebounceTimer = setTimeout(() => { ghostDebounceTimer = null void refreshGhostFull() }, 45) } /** @returns {boolean} */ function acceptGhostLine() { const currentLine = lines[currentLineIndex] || '' const b = currentLine.trimEnd() if ( !ghost || !ghost.startsWith(b) || ghost.length <= b.length || historySearchActive ) { return false } lines[currentLineIndex] = ghost line = lines.join('\n') cursor = ghost.length ghost = '' scheduleUpdateGhost() return true } 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 && !historySearchActive) { const b = currentLine.trimEnd() if (ghost.startsWith(b) && ghost.length > b.length) { stdout.write( `${colorFromEnv('BARE_OS_COLOR_GHOST', '\x1b[90m')}${ghost.slice(b.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 lastLine = lines[lines.length - 1] const lastRowPrompt = visiblePrompt(lines.length > 1) const visualLastPromptLen = stripAnsi(lastRowPrompt).length let ghostVisLen = 0 if (ghost && !historySearchActive) { const b = lastLine.trimEnd() if (ghost.startsWith(b) && ghost.length > b.length) { ghostVisLen = ghost.length - b.length } } /** Column index after the last visible char on the final input row (before 0J). */ const safeEraseCol = visualLastPromptLen + lastLine.length + ghostVisLen const editRowPrompt = visiblePrompt(currentLineIndex > 0) const visualEditPromptLen = stripAnsi(editRowPrompt).length const editCol = visualEditPromptLen + cursor const rowsBelowEdit = lines.length - 1 - currentLineIndex /** * CSI 0J erases from the **cursor** to the end of the screen. Running it while the * cursor sits in the middle of the line (edit position) wipes the rest of the line * and the ghost — left/right looked like delete/undelete. Move to the end of the * drawn input first so 0J only clears rows below (old menu / junk). */ if (showHistorySearch || historySearchActive) { screen.moveUp(1) } screen.cursorToColumn(safeEraseCol) stdout.write('\x1b[s') stdout.write('\x1b[0J') const compactMenu = env.BARE_OS_COMPACT_MENU === '1' || env.BARE_OS_COMPACT_MENU === 'true' || (typeof stdout.columns === 'number' && stdout.columns > 0 && stdout.columns < 44) if ( completionMenuOpen && completionItems.length > 0 && !historySearchActive && !compactMenu ) { const dim = '\x1b[2m' const rst = '\x1b[0m' const cols = stdout.columns || 80 const termRows = stdout.rows || Number(env.LINES) || 24 const maxVis = Math.min( menuVisibleCap(), Math.max(4, Math.min(12, termRows - lines.length - 4)) ) while (completionIndex < completionScroll) completionScroll = completionIndex while (completionIndex >= completionScroll + maxVis) { completionScroll = completionIndex - maxVis + 1 } const slice = completionItems.slice( completionScroll, completionScroll + maxVis ) stdout.write('\n') for (let i = 0; i < slice.length; i++) { const it = slice[i] const globalI = completionScroll + i const sel = globalI === completionIndex ? '\x1b[7m' : '' const lab = stripAnsi(String(it.ansiLabel || it.value)).slice(0, cols - 28) const desc = stripAnsi(String(it.description || '')).slice(0, cols - 36) const ic = iconForCompletionItem(it) stdout.write(` ${sel}${ic} ${lab}${rst} ${dim}${desc}${rst}\n`) } const clipped = completionTotal - completionItems.length const foot = clipped > 0 ? ` … +${clipped} ranked beyond cap` : '' stdout.write( `${dim} — Tab twice · menu · Tab/→ accept ghost — ↑↓ · ^N/^P · Enter · Esc · ^Space preview —${foot}${rst}\n` ) if (manPreviewText) { stdout.write(`${dim}${manPreviewText.slice(0, cols - 1)}${rst}\n`) } } stdout.write('\x1b[u') if (rowsBelowEdit > 0) { screen.moveUp(rowsBelowEdit) } screen.cursorToColumn(editCol) } function closeCompletionUi() { completionMenuOpen = false completionItems = [] completionIndex = 0 completionScroll = 0 completionTotal = 0 lastCompletionCx = null lastManPage = null manPreviewText = '' tabMatches = [] tabIndex = -1 inlineTabKeySig = '' } function clearGhostBeforeSubmit() { clearGhostDebounce() ghostRefreshSeq++ if (!ghost) return ghost = '' render() } function applyCurrentCompletionPick() { const currentLine = lines[currentLineIndex] || '' const cx = lastCompletionCx const it = completionItems[completionIndex] if (!cx || !it) return const ap = applyCompletionValue(currentLine, cursor, it.value, cx) lines[currentLineIndex] = ap.line line = lines.join('\n') cursor = ap.cursor closeCompletionUi() scheduleUpdateGhost() } /** @param {boolean} shift */ async function handleTab(shift = false) { if (historySearchActive) return const currentLine = lines[currentLineIndex] || '' const compactMenu = env.BARE_OS_COMPACT_MENU === '1' || env.BARE_OS_COMPACT_MENU === 'true' || (typeof stdout.columns === 'number' && stdout.columns > 0 && stdout.columns < 44) if ( completionMenuOpen && !compactMenu && completionItems.length > 0 ) { completionIndex = shift ? (completionIndex - 1 + completionItems.length) % completionItems.length : (completionIndex + 1) % completionItems.length render() return } const sig = `${currentLine}\0${cursor}` const now = Date.now() const isDoubleTab = !shift && sig === lastTabSig && now - lastTabAt < TAB_BURST_MS lastTabAt = now lastTabSig = sig if (compactMenu) { if (acceptGhostLine()) { render() return } const r = await completeLine(ctx, env, currentLine, cursor, history, { lastExecutedFirst }) lastCompletionCx = r.context lastManPage = r.manPage if (!r.items.length) return const ckey = `${currentLine}\0${cursor}` if (inlineTabKeySig !== ckey) { inlineTabKeySig = ckey completionItems = r.items completionTotal = r.total tabIndex = shift ? r.items.length - 1 : 0 } else { tabIndex = shift ? (tabIndex - 1 + r.items.length) % r.items.length : (tabIndex + 1) % r.items.length } const pick = r.items[Math.min(tabIndex, r.items.length - 1)] if (!pick || !r.context) return const ap = applyCompletionValue(currentLine, cursor, pick.value, r.context) lines[currentLineIndex] = ap.line line = lines.join('\n') cursor = ap.cursor scheduleUpdateGhost() render() return } if (acceptGhostLine()) { render() return } const r = await completeLine(ctx, env, currentLine, cursor, history, { lastExecutedFirst }) lastCompletionCx = r.context lastManPage = r.manPage if (!r.items.length) return if (isDoubleTab) { completionItems = r.items completionTotal = r.total completionIndex = shift ? r.items.length - 1 : 0 completionScroll = 0 completionMenuOpen = true manPreviewText = '' render() return } if (r.items.length === 1) { const pick = r.items[0] if (!pick || !r.context) return const ap = applyCompletionValue(currentLine, cursor, pick.value, r.context) lines[currentLineIndex] = ap.line line = lines.join('\n') cursor = ap.cursor closeCompletionUi() scheduleUpdateGhost() render() return } const lcp = longestCommonCompletionPrefix(r.items, r.context) if (lcp) { const ap = applyCompletionValue(currentLine, cursor, lcp, r.context) lines[currentLineIndex] = ap.line line = lines.join('\n') cursor = ap.cursor closeCompletionUi() scheduleUpdateGhost() render() return } } function manPreviewForItem(it) { const page = lastManPage if (!page || !it) return '' if (it.manFlag && Array.isArray(page.options)) { for (const o of page.options) { if ( o && typeof o === 'object' && /** @type {{ flag?: string, meaning?: string }} */ (o).flag === it.manFlag ) { const m = /** @type {{ meaning?: string }} */ (o).meaning if (m) return `${page.name || page.title}: ${m}` } } } const t = String(page.title || page.name || '') const d = String(page.description || '') .replace(/\s+/g, ' ') .trim() .slice(0, 140) return d ? `${t} — ${d}` : t } function resolveBareOsShellExit() { const resolve = pendingResolve pendingResolve = null stdout.write('\n') resolve(null) } function promptBareOsShellExitConfirm() { if (exitConfirmOptOut) { resolveBareOsShellExit() return } shellExitConfirmPending = true stdout.write('\r\nExit Bare OS shell? [y/N] ') } async function processOneKey(key) { if (pendingResolve === null) return if (key === '\x1b[I' || key === '\x1b[O') { return } if (key === '\x1b[1~' || key === '\x1b[7~' || key === '\x1b[H') { cursor = 0 render() return } if (key === '\x1b[4~' || key === '\x1b[8~' || key === '\x1b[F') { cursor = lines[currentLineIndex].length render() return } if (key === '\x1b[3~') { await processOneKey('\u007f') return } if ( key === '\x1b[5~' || key === '\x1b[6~' || key === '\x1b[2~' || key === '\x1b[15~' || key === '\x1b[17~' || key === '\x1b[18~' || key === '\x1b[19~' || key === '\x1b[20~' || key === '\x1b[21~' || key === '\x1b[23~' || key === '\x1b[24~' ) { return } if (shellExitConfirmPending) { shellExitConfirmPending = false if (key === 'y' || key === 'Y') { resolveBareOsShellExit() return } render() return } if (key === '\x0e') { if (completionMenuOpen && completionItems.length) { completionIndex = (completionIndex + 1) % completionItems.length render() } return } if (key === '\x10') { if (completionMenuOpen && completionItems.length) { completionIndex = (completionIndex - 1 + completionItems.length) % completionItems.length render() } return } if (key === '\u0000') { if (completionMenuOpen && completionItems[completionIndex]) { manPreviewText = manPreviewForItem(completionItems[completionIndex]) render() } return } if (key === '\x1b') { if (completionMenuOpen) { closeCompletionUi() render() } 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) { stdout.write('^C\n') promptBareOsShellExitConfirm() return } stdout.write('^C\n') line = '' lines = [''] currentLineIndex = 0 cursor = 0 ghost = '' closeCompletionUi() render() return } if (key === '\u0004') { promptBareOsShellExitConfirm() return } /** ^X — same confirm path as EOF so the shell is not closed by mistake. */ if (key === '\u0018') { promptBareOsShellExitConfirm() 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(XTERM_CLEAR_SCROLLBACK_AND_VIEWPORT) if (typeof writeScreen === 'function') { try { writeScreen('') } catch { /* ignore */ } } render() return } if (key === '\r' || key === '\n') { if (completionMenuOpen && completionItems.length) { applyCurrentCompletionPick() render() return } 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') clearGhostBeforeSubmit() stdout.write('\n') render() return } const cmdToExec = lines.join('\n').trim() clearGhostBeforeSubmit() stdout.write('\n') const resolve = pendingResolve pendingResolve = null historyIndex = -1 line = '' lines = [''] currentLineIndex = 0 cursor = 0 ghost = '' closeCompletionUi() const ft = cmdToExec.trim().split(/\s+/)[0] lastExecutedFirst = ft || lastExecutedFirst 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') closeCompletionUi() scheduleUpdateGhost() 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(false) return } if (key.startsWith('\u001b[')) { const code = key.slice(2) const currentLine = lines[currentLineIndex] if (code === 'Z') { await handleTab(true) return } if ( completionMenuOpen && completionItems.length && (code === 'A' || code === 'B') ) { if (code === 'A') { completionIndex = (completionIndex - 1 + completionItems.length) % completionItems.length } else { completionIndex = (completionIndex + 1) % completionItems.length } render() return } if (code === 'C') { if (!historySearchActive && acceptGhostLine()) { /* handled */ } 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') closeCompletionUi() scheduleUpdateGhost() render() } } /** * Bracketed-paste payload: insert text without interpreting embedded ESC sequences as keys. * @param {string} payload */ async function insertPastedText(payload) { for (const ch of payload) { if (ch === '\r' || ch === '\n') { await processOneKey('\n') continue } if (ch === '\t') { await processOneKey('\t') continue } if (ch === '\u007f') { await processOneKey('\u007f') continue } const code = ch.charCodeAt(0) if (code === 3) { await processOneKey('\x03') continue } if (code < 32) { const currentLine = lines[currentLineIndex] lines[currentLineIndex] = currentLine.slice(0, cursor) + ch + currentLine.slice(cursor) cursor++ line = lines.join('\n') closeCompletionUi() scheduleUpdateGhost() render() continue } await processOneKey(ch) } } async function drainInputBuffer() { if (processing) return processing = true try { while (inputBuffer.length && pendingResolve !== null) { if (bracketedPasteActive) { const endMarker = '\x1b[201~' const end = inputBuffer.indexOf(endMarker) if (end === -1) break const inner = inputBuffer.slice(0, end) inputBuffer = inputBuffer.slice(end + endMarker.length) bracketedPasteActive = false await insertPastedText(inner) continue } if (inputBuffer[0] === '\x1b') { const startPaste = '\x1b[200~' if (inputBuffer.startsWith(startPaste)) { inputBuffer = inputBuffer.slice(startPaste.length) bracketedPasteActive = true continue } const ss3 = inputBuffer.match(/^\x1bO([A-Za-z0-9=<>])/) if (ss3) { const k = ss3[1] if (k === 'A' || k === 'B' || k === 'C' || k === 'D') { await processOneKey('\x1b[' + k) } inputBuffer = inputBuffer.slice(ss3[0].length) continue } const legacyCsiArrow = inputBuffer.match(/^\x1b\[O([ABCD])/) if (legacyCsiArrow) { await processOneKey('\x1b[' + legacyCsiArrow[1]) inputBuffer = inputBuffer.slice(legacyCsiArrow[0].length) continue } if (inputBuffer.startsWith('\x1b[I')) { inputBuffer = inputBuffer.slice(3) continue } if ( inputBuffer.startsWith('\x1b[O') && inputBuffer.length === 3 ) { inputBuffer = inputBuffer.slice(3) continue } const arrow = inputBuffer.match(/^\x1b\[([A-D])/) if (arrow) { await processOneKey('\x1b[' + arrow[1]) inputBuffer = inputBuffer.slice(arrow[0].length) continue } if (inputBuffer.length >= 2 && inputBuffer[1] !== '[') { await processOneKey(inputBuffer[0]) inputBuffer = inputBuffer.slice(1) continue } if (inputBuffer.length < 3) break const m = inputBuffer.match(/^\x1b\[[\d;]*[~A-Za-z]/) if (m) { await processOneKey(m[0]) inputBuffer = inputBuffer.slice(m[0].length) continue } await processOneKey(inputBuffer[0]) inputBuffer = inputBuffer.slice(1) continue } await processOneKey(inputBuffer[0]) inputBuffer = inputBuffer.slice(1) } } 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() ctx.bareOsRegisterCompleter = (name, fn) => { registerBareOsCompleter(ctx, String(name), fn) } ctx.bareOsUnregisterCompleter = (name) => { unregisterBareOsCompleter(ctx, String(name)) } const sched = globalThis.setImmediate || ((fn) => setTimeout(fn, 0)) sched(() => { void loadBareOsManDb(ctx) }) stdin.once('end', () => { if (pendingResolve) { const r = pendingResolve pendingResolve = null r(null) } }) ctx.bareOsReloadFishHistoryForIdentity = async () => { const hp = replHistoryDrivePath(ctx) history = [] try { const buf = personalDrive && typeof personalDrive.get === 'function' ? await personalDrive.get(hp) : null if (buf && b4a) { const text = b4a.toString(buf) const scrubbed = scrubShellHistoryEntries( dedupeConsecutiveHistory(parseHistoryFile(text)) ) history = scrubbed.entries if (scrubbed.removed > 0) await writeHistoryFile(hp, history) } } catch { history = [] } historyIndex = -1 historySearchActive = false historySearchQuery = '' historySearchResults = [] historySearchIndex = -1 } /** * @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 = '' closeCompletionUi() historySearchActive = false historySearchQuery = '' historySearchResults = [] historySearchIndex = -1 void refreshPromptHookSegment().then(() => render()) inputBuffer = '' bracketedPasteActive = false shellExitConfirmPending = false clearGhostDebounce() ghostRefreshSeq++ return new Promise((resolve) => { pendingResolve = resolve scheduleUpdateGhost() render() }) } }