FISH style
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
looksLikeInteractiveStdin
|
||||
} from './lib/cli-readline.js'
|
||||
import { resolveStdio } from './lib/resolve-stdio.js'
|
||||
import { createFishReadLine, disableFishRawMode } from './lib/fish-readline.js'
|
||||
|
||||
const _pkg = packageRootDir(import.meta.url)
|
||||
|
||||
@@ -27,18 +28,21 @@ function bootStorePath() {
|
||||
return defaultBootCorestorePath(_pkg, import.meta.url)
|
||||
}
|
||||
|
||||
/** @returns {Promise<{ readLine: (p: string) => Promise<string | null>, interactiveAvailable: boolean, skipInteractive: boolean }>} */
|
||||
/** @returns {Promise<{ readLine: (p: string) => Promise<string | null>, interactiveAvailable: boolean, skipInteractive: boolean, stdout: import('stream').Writable | null, stdin: import('stream').Readable | null }>} */
|
||||
async function createReadLine() {
|
||||
const { stdin, stdout } = await resolveStdio()
|
||||
|
||||
const skipInteractive = globalThis.process?.env?.BARE_OS_SKIP_REPL === '1'
|
||||
if (skipInteractive) {
|
||||
return {
|
||||
readLine: async () => null,
|
||||
interactiveAvailable: false,
|
||||
skipInteractive: true
|
||||
skipInteractive: true,
|
||||
stdout,
|
||||
stdin
|
||||
}
|
||||
}
|
||||
|
||||
const { stdin, stdout } = await resolveStdio()
|
||||
if (!stdin || !stdout) {
|
||||
console.warn(
|
||||
'[bare-os-booter] No stdin/stdout (neither process nor bare-stdio). Cannot run interactive shell.'
|
||||
@@ -46,7 +50,9 @@ async function createReadLine() {
|
||||
return {
|
||||
readLine: async () => null,
|
||||
interactiveAvailable: false,
|
||||
skipInteractive: false
|
||||
skipInteractive: false,
|
||||
stdout,
|
||||
stdin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +78,9 @@ async function createReadLine() {
|
||||
})
|
||||
}),
|
||||
interactiveAvailable: true,
|
||||
skipInteractive: false
|
||||
skipInteractive: false,
|
||||
stdout,
|
||||
stdin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +89,9 @@ async function createReadLine() {
|
||||
return {
|
||||
readLine,
|
||||
interactiveAvailable: true,
|
||||
skipInteractive: false
|
||||
skipInteractive: false,
|
||||
stdout,
|
||||
stdin
|
||||
}
|
||||
} catch {
|
||||
/* bare-readline failed to load */
|
||||
@@ -91,7 +101,9 @@ async function createReadLine() {
|
||||
return {
|
||||
readLine: createStreamLineReader(stdin, stdout),
|
||||
interactiveAvailable: true,
|
||||
skipInteractive: false
|
||||
skipInteractive: false,
|
||||
stdout,
|
||||
stdin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +113,9 @@ async function createReadLine() {
|
||||
return {
|
||||
readLine: async () => null,
|
||||
interactiveAvailable: false,
|
||||
skipInteractive: false
|
||||
skipInteractive: false,
|
||||
stdout,
|
||||
stdin
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,19 +129,23 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
const {
|
||||
readLine: rawReadLine,
|
||||
interactiveAvailable,
|
||||
skipInteractive
|
||||
skipInteractive,
|
||||
stdout: sessionStdout,
|
||||
stdin: sessionStdin
|
||||
} = await createReadLine()
|
||||
|
||||
/** Under Pear there is often no readline; never return null or the kernel exits and tears down the swarm. */
|
||||
const readLine = async (prompt) => {
|
||||
const line = await rawReadLine(prompt)
|
||||
if (line == null && !skipInteractive && !interactiveAvailable) {
|
||||
console.log(
|
||||
'(No interactive stdin — leaving booter and replication running. Close the app to quit.)'
|
||||
)
|
||||
await new Promise(() => {})
|
||||
/** Same Writable as the REPL (bare-stdio or process.stdout) for ANSI / clear. */
|
||||
function writeScreen(chunk) {
|
||||
const s = typeof chunk === 'string' ? chunk : String(chunk)
|
||||
if (sessionStdout && typeof sessionStdout.write === 'function') {
|
||||
sessionStdout.write(s)
|
||||
} else if (
|
||||
globalThis.process?.stdout &&
|
||||
typeof globalThis.process.stdout.write === 'function'
|
||||
) {
|
||||
globalThis.process.stdout.write(s)
|
||||
}
|
||||
return line
|
||||
globalThis.console.clear?.()
|
||||
}
|
||||
|
||||
const shellEnv = {
|
||||
@@ -144,6 +162,7 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
}
|
||||
const vfs = createVfs(disk.drive, disk.personalDrive, shellEnv)
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const ctx = {
|
||||
disk,
|
||||
drive: disk.drive,
|
||||
@@ -153,12 +172,47 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
console,
|
||||
b4a,
|
||||
topic: topicKey(),
|
||||
readLine,
|
||||
writeScreen,
|
||||
async execLine(line) {
|
||||
return await execShellLine(ctx, line)
|
||||
}
|
||||
}
|
||||
|
||||
const fishOff = globalThis.process?.env?.BARE_OS_FISH === '0'
|
||||
let fishRead = null
|
||||
if (
|
||||
!skipInteractive &&
|
||||
interactiveAvailable &&
|
||||
!fishOff &&
|
||||
sessionStdin &&
|
||||
sessionStdout
|
||||
) {
|
||||
fishRead = await createFishReadLine(ctx, {
|
||||
stdin: sessionStdin,
|
||||
stdout: sessionStdout,
|
||||
writeScreen
|
||||
})
|
||||
if (fishRead) {
|
||||
console.log('[bare-os-booter] Fish-style TTY line editor active')
|
||||
}
|
||||
}
|
||||
|
||||
const readLineImpl = fishRead || rawReadLine
|
||||
|
||||
/** Under Pear there is often no readline; never return null or the kernel exits and tears down the swarm. */
|
||||
const readLine = async (prompt) => {
|
||||
const line = await readLineImpl(prompt)
|
||||
if (line == null && !skipInteractive && !interactiveAvailable) {
|
||||
console.log(
|
||||
'(No interactive stdin — leaving booter and replication running. Close the app to quit.)'
|
||||
)
|
||||
await new Promise(() => {})
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
ctx.readLine = readLine
|
||||
|
||||
disk.os = {
|
||||
async searchLocal() {
|
||||
return []
|
||||
@@ -168,7 +222,11 @@ async function executeKernel(disk, store, swarm, initSource) {
|
||||
}
|
||||
}
|
||||
|
||||
await runKernelFromSource(b4a.toString(initSource), ctx)
|
||||
try {
|
||||
await runKernelFromSource(b4a.toString(initSource), ctx)
|
||||
} finally {
|
||||
if (fishRead) disableFishRawMode(sessionStdin)
|
||||
}
|
||||
}
|
||||
|
||||
async function bootFromPeers(disk, store, swarm) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Hyper-os compatible history lines: #timestamp:command per line.
|
||||
* @typedef {{ timestamp: number, command: string }} HistoryEntry
|
||||
*/
|
||||
|
||||
/** @param {string} data */
|
||||
export function parseHistoryFile(data) {
|
||||
const lines = data.split('\n').filter((l) => l.length > 0)
|
||||
/** @type {HistoryEntry[]} */
|
||||
const out = []
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#')) {
|
||||
const m = line.match(/^#(\d+):(.+)$/)
|
||||
if (m) {
|
||||
out.push({ timestamp: parseInt(m[1], 10), command: m[2] })
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.push({ timestamp: Date.now(), command: line })
|
||||
}
|
||||
dedupeHistory(out)
|
||||
return out
|
||||
}
|
||||
|
||||
/** @param {HistoryEntry[]} history in-place */
|
||||
export function dedupeHistory(history) {
|
||||
const deduped = []
|
||||
let last = null
|
||||
for (const e of history) {
|
||||
if (e.command !== last) {
|
||||
deduped.push(e)
|
||||
last = e.command
|
||||
}
|
||||
}
|
||||
history.length = 0
|
||||
history.push(...deduped)
|
||||
}
|
||||
|
||||
/** @param {HistoryEntry[]} history */
|
||||
export function serializeHistory(history) {
|
||||
return history.map((e) => `#${e.timestamp}:${e.command}`).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost: most recent history entry whose command starts with prefix.
|
||||
* @param {HistoryEntry[]} history chronological (oldest first)
|
||||
* @param {string} prefix
|
||||
*/
|
||||
export function ghostFromHistory(history, prefix) {
|
||||
if (!prefix.length) return ''
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const cmd = history[i].command
|
||||
if (cmd.startsWith(prefix)) return cmd
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse search: commands containing query, most recent first.
|
||||
* @param {HistoryEntry[]} history
|
||||
* @param {string} query
|
||||
*/
|
||||
export function searchHistorySubstring(history, query) {
|
||||
if (!query) return []
|
||||
const results = []
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const cmd = history[i].command
|
||||
if (cmd.includes(query)) results.push(cmd)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import {
|
||||
parseHistoryFile,
|
||||
serializeHistory,
|
||||
dedupeHistory,
|
||||
ghostFromHistory,
|
||||
searchHistorySubstring
|
||||
} from './fish-history.js'
|
||||
|
||||
export const SHELL_BUILTINS = ['cd', 'export', 'exit']
|
||||
|
||||
/** @type {Record<string, string[]>} */
|
||||
export const FLAG_COMPLETION = {
|
||||
ls: ['-a', '-l', '-la', '-1'],
|
||||
grep: ['-i', '-v', '-n', '-c'],
|
||||
sort: ['-r', '-n', '-u'],
|
||||
rm: ['-r', '-f', '-rf'],
|
||||
head: ['-n'],
|
||||
tail: ['-n'],
|
||||
wc: ['-l', '-w', '-c'],
|
||||
uname: ['-a', '-s', '-n', '-r', '-m', '-v']
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function stripAnsi(str) {
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ stdin: import('stream').Readable & { isTTY?: boolean, setRawMode?: (b: boolean) => void }, stdout: import('stream').Writable & { cursorTo?: (x: number, y?: number) => void, clearLine?: (dir: number) => void }, writeScreen: (s: string) => void, historyPath?: string, historyMax?: number }} opts
|
||||
* @returns {Promise<((prompt: string) => Promise<string | null>) | null>}
|
||||
*/
|
||||
export async function createFishReadLine(ctx, opts) {
|
||||
const {
|
||||
stdin,
|
||||
stdout,
|
||||
writeScreen,
|
||||
historyPath = '/.bare_history',
|
||||
historyMax = Number(
|
||||
globalThis.process?.env?.BARE_OS_FISH_HISTORY_MAX || 1000
|
||||
)
|
||||
} = opts
|
||||
|
||||
if (!stdin?.isTTY || typeof stdin.setRawMode !== 'function') {
|
||||
return null
|
||||
}
|
||||
|
||||
const vfs = ctx.vfs
|
||||
const drive = ctx.drive
|
||||
const personalDrive = ctx.personalDrive
|
||||
const b4a = ctx.b4a
|
||||
const env = ctx.env
|
||||
|
||||
/** @type {import('./fish-history.js').HistoryEntry[]} */
|
||||
let history = []
|
||||
try {
|
||||
const buf = await personalDrive.get(historyPath, { follow: true })
|
||||
if (buf) history = parseHistoryFile(b4a.toString(buf))
|
||||
} catch {
|
||||
history = []
|
||||
}
|
||||
|
||||
let historyIndex = -1
|
||||
/** @type {string[]} */
|
||||
let binCache = []
|
||||
try {
|
||||
const stream = drive.readdir('/bin')
|
||||
for await (const name of stream) {
|
||||
binCache.push(name)
|
||||
}
|
||||
binCache.sort()
|
||||
} catch {
|
||||
binCache = []
|
||||
}
|
||||
|
||||
function knownCommands() {
|
||||
return new Set([...SHELL_BUILTINS, ...binCache])
|
||||
}
|
||||
|
||||
async function saveHistoryLine(line) {
|
||||
if (!line.trim()) return
|
||||
history = history.filter((e) => e.command !== line)
|
||||
history.push({ timestamp: Date.now(), command: line })
|
||||
while (history.length > historyMax) history.shift()
|
||||
dedupeHistory(history)
|
||||
try {
|
||||
await personalDrive.put(historyPath, b4a.from(serializeHistory(history)))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function getPrompt(isContinuation = false) {
|
||||
let displayPath = vfs.getcwd()
|
||||
const home = env.HOME || '/home/user'
|
||||
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 base = `\x1b[32m[${user}@${host}:${displayPath}]\x1b[0m`
|
||||
return isContinuation ? `${base} \\> ` : `${base} > `
|
||||
}
|
||||
|
||||
function highlightLine(text, lineIndex) {
|
||||
if (lineIndex !== 0) return text
|
||||
const parts = text.split(/\s+/)
|
||||
const cmd = parts[0]
|
||||
const known = knownCommands()
|
||||
let colored = text
|
||||
if (cmd && known.has(cmd)) {
|
||||
colored = `\x1b[36m${cmd}\x1b[0m${text.slice(cmd.length)}`
|
||||
}
|
||||
const pathRegex = /(\/[^\s]+|\.\/[^\s]+|\.\.\/[^\s]+)/g
|
||||
colored = colored.replace(pathRegex, (m) => `\x1b[33m${m}\x1b[0m`)
|
||||
const envRegex = /\$(\w+)/g
|
||||
colored = colored.replace(envRegex, (match, varName) => {
|
||||
const value = env[varName]
|
||||
return value ? `\x1b[35m${match}\x1b[0m` : `\x1b[31m${match}\x1b[0m`
|
||||
})
|
||||
return colored
|
||||
}
|
||||
|
||||
let line = ''
|
||||
/** @type {string[]} */
|
||||
let lines = ['']
|
||||
let currentLineIndex = 0
|
||||
let cursor = 0
|
||||
let ghost = ''
|
||||
/** @type {string[]} */
|
||||
let tabMatches = []
|
||||
let tabIndex = -1
|
||||
let originalLine = ''
|
||||
let historySearchActive = false
|
||||
let historySearchQuery = ''
|
||||
/** @type {string[]} */
|
||||
let historySearchResults = []
|
||||
let historySearchIndex = -1
|
||||
let savedLine = ''
|
||||
|
||||
/** @type {((v: string | null) => void) | null} */
|
||||
let pendingResolve = null
|
||||
function updateGhost() {
|
||||
const currentLine = lines[currentLineIndex] || ''
|
||||
if (currentLine.length === 0 || historySearchActive) {
|
||||
ghost = ''
|
||||
return
|
||||
}
|
||||
ghost = ghostFromHistory(history, currentLine)
|
||||
}
|
||||
|
||||
function render(showHistorySearch = false) {
|
||||
if (lines.length > 1) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (typeof stdout.cursorTo === 'function') stdout.cursorTo(0)
|
||||
if (typeof stdout.clearLine === 'function') stdout.clearLine(0)
|
||||
if (i < lines.length - 1) stdout.write('\n')
|
||||
}
|
||||
if (typeof stdout.cursorTo === 'function') stdout.cursorTo(0)
|
||||
stdout.write('\x1b[' + (lines.length - 1) + 'A')
|
||||
} else {
|
||||
if (typeof stdout.clearLine === 'function') stdout.clearLine(0)
|
||||
if (typeof stdout.cursorTo === 'function') stdout.cursorTo(0)
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const isLastLine = i === lines.length - 1
|
||||
const prompt = getPrompt(i > 0)
|
||||
const currentLine = lines[i]
|
||||
const coloredLine = highlightLine(currentLine, i)
|
||||
stdout.write(prompt + coloredLine)
|
||||
if (
|
||||
isLastLine &&
|
||||
ghost &&
|
||||
currentLine.length > 0 &&
|
||||
ghost.startsWith(currentLine) &&
|
||||
!historySearchActive
|
||||
) {
|
||||
stdout.write(`\x1b[90m${ghost.slice(currentLine.length)}\x1b[0m`)
|
||||
}
|
||||
if (!isLastLine) stdout.write('\n')
|
||||
}
|
||||
|
||||
if (historySearchActive) {
|
||||
stdout.write(
|
||||
`\n\x1b[36m(reverse-i-search)'${historySearchQuery}':\x1b[0m ${historySearchResults[historySearchIndex] || ''}`
|
||||
)
|
||||
}
|
||||
|
||||
const lastPrompt = getPrompt(lines.length > 1)
|
||||
const visualPromptLength = stripAnsi(lastPrompt).length
|
||||
if (typeof stdout.cursorTo === 'function') {
|
||||
stdout.cursorTo(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 all = Array.from(new Set([...binCache, ...SHELL_BUILTINS])).sort()
|
||||
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_COMPLETION[cmd]
|
||||
if (flags) {
|
||||
tabMatches = flags.filter((f) => f.startsWith(currentWord))
|
||||
}
|
||||
} else {
|
||||
const pathPart = currentWord
|
||||
let dir = vfs.getcwd()
|
||||
let filePrefix = pathPart
|
||||
let pathPrefix = ''
|
||||
if (pathPart.includes('/')) {
|
||||
const lastSlash = pathPart.lastIndexOf('/')
|
||||
const dirPart = pathPart.slice(0, lastSlash)
|
||||
filePrefix = pathPart.slice(lastSlash + 1)
|
||||
pathPrefix = pathPart.slice(0, lastSlash + 1)
|
||||
dir = vfs.resolveLogical(dirPart === '' ? '/' : dirPart)
|
||||
}
|
||||
try {
|
||||
const entries = await vfs.readdir(dir)
|
||||
const exactMatches = entries.filter((e) => e.startsWith(filePrefix))
|
||||
if (exactMatches.length > 0) {
|
||||
tabMatches = exactMatches.map((e) => pathPrefix + e)
|
||||
} else if (filePrefix.length > 0) {
|
||||
tabMatches = entries
|
||||
.filter((e) => fuzzyMatch(e, filePrefix))
|
||||
.map((e) => pathPrefix + 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null} cmd empty string continues kernel loop; null EOF exit
|
||||
*/
|
||||
function finishLine(cmd) {
|
||||
const resolve = pendingResolve
|
||||
pendingResolve = null
|
||||
line = ''
|
||||
lines = ['']
|
||||
currentLineIndex = 0
|
||||
cursor = 0
|
||||
ghost = ''
|
||||
tabMatches = []
|
||||
tabIndex = -1
|
||||
historyIndex = -1
|
||||
historySearchActive = false
|
||||
historySearchQuery = ''
|
||||
historySearchResults = []
|
||||
historySearchIndex = -1
|
||||
if (resolve) {
|
||||
if (cmd === null) resolve(null)
|
||||
else resolve(typeof cmd === 'string' ? cmd : '')
|
||||
}
|
||||
}
|
||||
|
||||
async function onDataKey(key) {
|
||||
if (pendingResolve === null) return
|
||||
|
||||
if (key === '\u0003') {
|
||||
if (historySearchActive) {
|
||||
historySearchActive = false
|
||||
line = savedLine
|
||||
lines = [line]
|
||||
currentLineIndex = 0
|
||||
cursor = line.length
|
||||
render()
|
||||
return
|
||||
}
|
||||
stdout.write('^C\n')
|
||||
line = ''
|
||||
lines = ['']
|
||||
currentLineIndex = 0
|
||||
cursor = 0
|
||||
ghost = ''
|
||||
tabMatches = []
|
||||
tabIndex = -1
|
||||
render()
|
||||
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') {
|
||||
writeScreen('\x1b[H\x1b[2J\x1b[3J')
|
||||
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]
|
||||
if (currentLine.trim().endsWith('\\') && currentLine.trim().length > 1) {
|
||||
lines[currentLineIndex] = currentLine.slice(0, -1).trimEnd()
|
||||
lines.push('')
|
||||
currentLineIndex++
|
||||
cursor = 0
|
||||
stdout.write('\n')
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
stdout.write('\n')
|
||||
const cmdToExec = lines.join('\n').trim()
|
||||
if (cmdToExec) {
|
||||
await saveHistoryLine(cmdToExec)
|
||||
}
|
||||
finishLine(cmdToExec)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === '\u0004') {
|
||||
if ((lines[currentLineIndex] || '').length === 0 && lines.length === 1) {
|
||||
stdout.write('\n')
|
||||
finishLine(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (historySearchActive) {
|
||||
if (key === '\u007f') {
|
||||
historySearchQuery = historySearchQuery.slice(0, -1)
|
||||
} else if (key.length === 1 && key >= ' ') {
|
||||
historySearchQuery += key
|
||||
}
|
||||
historySearchResults = searchHistorySubstring(history, 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 === '\u001b[3~') {
|
||||
const currentLine = lines[currentLineIndex]
|
||||
lines[currentLineIndex] =
|
||||
currentLine.slice(0, cursor) + currentLine.slice(cursor + 1)
|
||||
line = lines.join('\n')
|
||||
tabMatches = []
|
||||
tabIndex = -1
|
||||
updateGhost()
|
||||
render()
|
||||
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()
|
||||
}
|
||||
} else if (code === 'H' || code === '1~' || code === '7~') {
|
||||
cursor = 0
|
||||
render()
|
||||
} else if (code === 'F' || code === '4~' || code === '8~') {
|
||||
cursor = currentLine.length
|
||||
render()
|
||||
} else if (code.endsWith('C') && code.includes(';')) {
|
||||
cursor = Math.min(currentLine.length, cursor + 1)
|
||||
render()
|
||||
} else if (code.endsWith('D') && code.includes(';')) {
|
||||
cursor = Math.max(0, cursor - 1)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
stdin.setEncoding('utf8')
|
||||
stdin.on('data', async (chunk) => {
|
||||
const s = typeof chunk === 'string' ? chunk : String(chunk)
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i]
|
||||
if (ch === '\u001b' && s[i + 1] === '[') {
|
||||
let j = i + 2
|
||||
while (
|
||||
j < s.length &&
|
||||
!'ABCDEFGHOPQRSTUVWXYZabcdefghopqrstuvwxyz~'.includes(s[j])
|
||||
) {
|
||||
j++
|
||||
}
|
||||
if (j < s.length) j++
|
||||
const seq = s.slice(i, j)
|
||||
i = j - 1
|
||||
await onDataKey(seq)
|
||||
continue
|
||||
}
|
||||
await onDataKey(ch === '\r' ? '\n' : ch)
|
||||
}
|
||||
})
|
||||
|
||||
stdin.setRawMode(true)
|
||||
if (typeof stdin.resume === 'function') stdin.resume()
|
||||
|
||||
return function fishReadLine(_prompt) {
|
||||
return new Promise((resolve) => {
|
||||
pendingResolve = resolve
|
||||
line = ''
|
||||
lines = ['']
|
||||
currentLineIndex = 0
|
||||
cursor = 0
|
||||
ghost = ''
|
||||
tabMatches = []
|
||||
tabIndex = -1
|
||||
historyIndex = -1
|
||||
updateGhost()
|
||||
render()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('stream').Readable & { setRawMode?: (b: boolean) => void }} stdin
|
||||
*/
|
||||
export function disableFishRawMode(stdin) {
|
||||
try {
|
||||
if (stdin && typeof stdin.setRawMode === 'function') {
|
||||
stdin.setRawMode(false)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import { runKernelFromSource, runBinCommand } from './lib/kernel-runner.js'
|
||||
import { createStreamLineReader } from './lib/cli-readline.js'
|
||||
import { createVfs } from './lib/vfs.js'
|
||||
import { tokenize, expandWord, execShellLine } from './lib/shell.js'
|
||||
import {
|
||||
parseHistoryFile,
|
||||
serializeHistory,
|
||||
ghostFromHistory,
|
||||
searchHistorySubstring
|
||||
} from './lib/fish-history.js'
|
||||
import { fuzzyMatch, SHELL_BUILTINS } from './lib/fish-readline.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -211,3 +218,36 @@ async function readBuiltBin(name) {
|
||||
const p = path.join(__dirname, '../../kernel/bin', name)
|
||||
return fs.readFile(p, 'utf8')
|
||||
}
|
||||
|
||||
test('fish history parse serialize roundtrip', async (t) => {
|
||||
const raw = '#100:ls\n#200:cd /\n'
|
||||
const h = parseHistoryFile(raw)
|
||||
t.is(h.length, 2)
|
||||
t.is(h[0].command, 'ls')
|
||||
const again = parseHistoryFile(serializeHistory(h))
|
||||
t.is(again.length, 2)
|
||||
})
|
||||
|
||||
test('ghostFromHistory uses most recent prefix match', async (t) => {
|
||||
const h = [
|
||||
{ timestamp: 1, command: 'ls /bin' },
|
||||
{ timestamp: 2, command: 'ls /etc' },
|
||||
{ timestamp: 3, command: 'pwd' }
|
||||
]
|
||||
t.is(ghostFromHistory(h, 'ls'), 'ls /etc')
|
||||
t.is(ghostFromHistory(h, 'p'), 'pwd')
|
||||
})
|
||||
|
||||
test('searchHistorySubstring reverse order', async (t) => {
|
||||
const h = [
|
||||
{ timestamp: 1, command: 'aa' },
|
||||
{ timestamp: 2, command: 'ab' }
|
||||
]
|
||||
const r = searchHistorySubstring(h, 'a')
|
||||
t.is(r[0], 'ab')
|
||||
})
|
||||
|
||||
test('fuzzyMatch and SHELL_BUILTINS', async (t) => {
|
||||
t.ok(fuzzyMatch('basename', 'bse'))
|
||||
t.ok(SHELL_BUILTINS.includes('cd'))
|
||||
})
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
async function run(ctx, argv) {
|
||||
globalThis.console.clear?.()
|
||||
const w = globalThis.process?.stdout?.write
|
||||
if (typeof w === 'function') {
|
||||
w.call(globalThis.process.stdout, '\x1b[2J\x1b[3J\x1b[H')
|
||||
const seq = '\x1b[H\x1b[2J\x1b[3J'
|
||||
if (typeof ctx.writeScreen === 'function') {
|
||||
ctx.writeScreen(seq)
|
||||
return
|
||||
}
|
||||
globalThis.console.clear?.()
|
||||
const ps = globalThis.process?.stdout
|
||||
if (ps && typeof ps.write === 'function') {
|
||||
ps.write(seq)
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < 48; i++) ctx.console.log('')
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@ function bareStdin(ctx) {
|
||||
}
|
||||
|
||||
async function run(ctx, argv) {
|
||||
globalThis.console.clear?.()
|
||||
const w = globalThis.process?.stdout?.write
|
||||
if (typeof w === 'function') {
|
||||
w.call(globalThis.process.stdout, '\x1b[2J\x1b[3J\x1b[H')
|
||||
const seq = '\x1b[H\x1b[2J\x1b[3J'
|
||||
if (typeof ctx.writeScreen === 'function') {
|
||||
ctx.writeScreen(seq)
|
||||
return
|
||||
}
|
||||
globalThis.console.clear?.()
|
||||
const ps = globalThis.process?.stdout
|
||||
if (ps && typeof ps.write === 'function') {
|
||||
ps.write(seq)
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < 48; i++) ctx.console.log('')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user