FISH style

This commit is contained in:
Raven Scott
2026-04-02 22:57:23 -04:00
parent c337a8aaa6
commit 5991f4f8e8
8 changed files with 823 additions and 37 deletions
+78 -20
View File
@@ -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 */
}
}
+40
View File
@@ -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'))
})
+11 -4
View File
@@ -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('')
}
+11 -4
View File
@@ -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('')
}