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