1477 lines
41 KiB
JavaScript
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()
|
|
})
|
|
}
|
|
}
|