Files
bare-operating-system/packages/bare-os-booter/lib/cli/fish-readline.js
T
2026-08-18 18:11:28 -04:00

1477 lines
41 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.
*/
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<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 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<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 {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<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 {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<string, unknown>} ctx
* @param {Record<string, string>} 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<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
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<import('./completion-engine.js').parseCompletionContext> | null} */
let lastCompletionCx = null
/** @type {Record<string, unknown> | 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<typeof setTimeout> | 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<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 = ''
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()
})
}
}