updates
CI / test (push) Failing after 4m58s

This commit is contained in:
Raven Scott
2026-04-02 23:25:35 -04:00
parent 5991f4f8e8
commit 45cf20ea3c
9 changed files with 293 additions and 34 deletions
+167 -28
View File
@@ -5,8 +5,15 @@ import {
ghostFromHistory,
searchHistorySubstring
} from './fish-history.js'
import {
clearEntireLine,
clearScreenDown,
clearCurrentLine,
cursorTo,
moveCursor
} from './tty-ansi.js'
export const SHELL_BUILTINS = ['cd', 'export', 'exit']
export const SHELL_BUILTINS = ['cd', 'export', 'exit', 'quit']
/** @type {Record<string, string[]>} */
export const FLAG_COMPLETION = {
@@ -30,8 +37,54 @@ export function fuzzyMatch(str, pattern) {
return patternIdx === pattern.length
}
/** Visible length for cursor placement (strip CSI sequences, not only SGR `m`). */
function stripAnsi(str) {
return str.replace(/\x1b\[[0-9;]*m/g, '')
return str.replace(/\x1b\[[\d;?]*[A-Za-z]/g, '')
}
/**
* TTY width for soft-wrap math. If the real terminal is narrower than we assume,
* we treat the line as one row and only clear one physical row — the rest of the
* prompt (e.g. `[user@…`) stays as garbage and the user sees a lone `>`.
* When width is unknown (common on Bare/Pear), use a conservative default.
* @param {import('stream').Writable & { columns?: number }} stdout
*/
function effectiveTermCols(stdout) {
const w =
Number(stdout.columns) ||
Number(globalThis.process?.stdout?.columns) ||
Number(globalThis.process?.env?.COLUMNS)
if (Number.isFinite(w) && w > 0) {
return Math.max(20, Math.min(Math.trunc(w), 512))
}
return 40
}
/**
* Visible column/row after `str` on a terminal (soft-wrap model; ASCII width 1).
* @returns {{ cols: number, rows: number }}
*/
function displayPos(str, termCols) {
const col = Math.max(1, termCols)
let offset = 0
let rows = 0
for (let i = 0; i < str.length; i++) {
const c = str[i]
if (c === '\n') {
rows += Math.ceil(offset / col) || 1
offset = 0
continue
}
if (c === '\t') {
const tabSize = 8
offset += tabSize - (offset % tabSize)
continue
}
offset += 1
}
const cols = offset % col
rows += (offset - cols) / col
return { cols, rows }
}
/**
@@ -148,6 +201,10 @@ export async function createFishReadLine(ctx, opts) {
let historySearchResults = []
let historySearchIndex = -1
let savedLine = ''
/** Previous cursor row (0-based) within the prompt+input block; used for wrap-aware redraw. */
let prevCursorRows = 0
/** Rows spanned by last painted prompt+input+ghost (>=1); forces full refresh when >1. */
let prevPaintRowCount = 0
/** @type {((v: string | null) => void) | null} */
let pendingResolve = null
@@ -161,47 +218,123 @@ export async function createFishReadLine(ctx, opts) {
}
function render(showHistorySearch = false) {
void showHistorySearch
if (lines.length > 1) {
prevCursorRows = 0
prevPaintRowCount = 0
for (let i = 0; i < lines.length; i++) {
if (typeof stdout.cursorTo === 'function') stdout.cursorTo(0)
if (typeof stdout.clearLine === 'function') stdout.clearLine(0)
clearCurrentLine(stdout)
if (i < lines.length - 1) stdout.write('\n')
}
if (typeof stdout.cursorTo === 'function') stdout.cursorTo(0)
clearCurrentLine(stdout)
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`)
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 (!isLastLine) stdout.write('\n')
const cursorRowPrompt = getPrompt(currentLineIndex > 0)
const visualPromptLength = stripAnsi(cursorRowPrompt).length
const col = visualPromptLength + cursor
cursorTo(stdout, col)
return
}
if (historySearchActive) {
prevCursorRows = 0
prevPaintRowCount = 0
clearEntireLine(stdout)
const prompt = getPrompt(false)
const currentLine = lines[0]
const coloredLine = highlightLine(currentLine, 0)
stdout.write(prompt + coloredLine)
if (
ghost &&
currentLine.length > 0 &&
ghost.startsWith(currentLine)
) {
stdout.write(`\x1b[90m${ghost.slice(currentLine.length)}\x1b[0m`)
}
stdout.write(
`\n\x1b[36m(reverse-i-search)'${historySearchQuery}':\x1b[0m ${historySearchResults[historySearchIndex] || ''}`
)
const vp = stripAnsi(prompt).length
cursorTo(stdout, vp + cursor)
return
}
const lastPrompt = getPrompt(lines.length > 1)
const visualPromptLength = stripAnsi(lastPrompt).length
if (typeof stdout.cursorTo === 'function') {
stdout.cursorTo(visualPromptLength + cursor)
const termCols = effectiveTermCols(stdout)
const prompt = getPrompt(false)
const currentLine = lines[0]
const coloredLine = highlightLine(currentLine, 0)
const showGhost = Boolean(
ghost &&
currentLine.length > 0 &&
ghost.startsWith(currentLine)
)
const ghostWrite = showGhost
? `\x1b[90m${ghost.slice(currentLine.length)}\x1b[0m`
: ''
const visPrompt = stripAnsi(prompt)
const ghostVis = showGhost ? ghost.slice(currentLine.length) : ''
const fullVis = visPrompt + currentLine + ghostVis
const strBefore = visPrompt + currentLine.slice(0, cursor)
const lineEnd = displayPos(fullVis, termCols)
const curEnd = displayPos(strBefore, termCols)
const paintRowCount = lineEnd.rows + 1
const needFullRefresh =
lineEnd.rows > 0 ||
curEnd.rows > 0 ||
prevCursorRows > 0 ||
prevPaintRowCount > 1
const out = prompt + coloredLine + ghostWrite
function moveUp(n) {
if (n <= 0) return
moveCursor(stdout, 0, -n)
}
function toCol0() {
cursorTo(stdout, 0)
}
function clrDown() {
clearScreenDown(stdout)
}
function toCol(x) {
cursorTo(stdout, x)
}
if (!needFullRefresh) {
clearEntireLine(stdout)
stdout.write(out)
toCol(curEnd.cols)
prevCursorRows = 0
prevPaintRowCount = paintRowCount
} else {
moveUp(prevCursorRows)
toCol0()
clrDown()
stdout.write(out)
toCol(curEnd.cols)
const diff = lineEnd.rows - curEnd.rows
if (diff > 0) moveUp(diff)
prevCursorRows = curEnd.rows
prevPaintRowCount = paintRowCount
}
}
@@ -296,6 +429,8 @@ export async function createFishReadLine(ctx, opts) {
historySearchQuery = ''
historySearchResults = []
historySearchIndex = -1
prevCursorRows = 0
prevPaintRowCount = 0
if (resolve) {
if (cmd === null) resolve(null)
else resolve(typeof cmd === 'string' ? cmd : '')
@@ -323,6 +458,8 @@ export async function createFishReadLine(ctx, opts) {
ghost = ''
tabMatches = []
tabIndex = -1
prevCursorRows = 0
prevPaintRowCount = 0
render()
return
}
@@ -555,6 +692,8 @@ export async function createFishReadLine(ctx, opts) {
return function fishReadLine(_prompt) {
return new Promise((resolve) => {
pendingResolve = resolve
prevCursorRows = 0
prevPaintRowCount = 0
line = ''
lines = ['']
currentLineIndex = 0
+1 -1
View File
@@ -278,7 +278,7 @@ export async function execShellLine(ctx, line) {
const eq = a.indexOf('=')
if (eq > 0) env[a.slice(0, eq)] = expandWord(a.slice(eq + 1), env)
}
} else if (name === 'exit') {
} else if (name === 'exit' || name === 'quit') {
code = 'exit'
} else {
const childCtx =
+70
View File
@@ -0,0 +1,70 @@
/**
* Minimal CSI helpers for TTY output (no node:readline).
* Semantics match Node readline: x/y are 0-based; CHA/CUP use 1-based parameters.
*/
/** @param {unknown} n */
function clampNonNegInt(n) {
if (!Number.isFinite(n)) return 0
const i = Math.trunc(n)
return i < 0 ? 0 : i
}
/**
* @param {import('stream').Writable | null | undefined} stream
* @param {number} x 0-based column
* @param {number} [y] 0-based row; if omitted, CHA only
*/
export function cursorTo(stream, x, y) {
if (stream == null || typeof stream.write !== 'function') return
const cx = clampNonNegInt(x)
if (typeof y === 'number' && Number.isFinite(y)) {
const cy = clampNonNegInt(y)
stream.write(`\x1b[${cy + 1};${cx + 1}H`)
} else {
stream.write(`\x1b[${cx + 1}G`)
}
}
/**
* @param {import('stream').Writable | null | undefined} stream
* @param {number} dx
* @param {number} dy
*/
export function moveCursor(stream, dx, dy) {
if (stream == null || typeof stream.write !== 'function') return
const ddx = Number.isFinite(dx) ? Math.trunc(dx) : 0
const ddy = Number.isFinite(dy) ? Math.trunc(dy) : 0
if (ddy < 0) stream.write(`\x1b[${-ddy}A`)
else if (ddy > 0) stream.write(`\x1b[${ddy}B`)
if (ddx < 0) stream.write(`\x1b[${-ddx}D`)
else if (ddx > 0) stream.write(`\x1b[${ddx}C`)
}
/**
* Erase from cursor to end of display (ED default).
* @param {import('stream').Writable | null | undefined} stream
*/
export function clearScreenDown(stream) {
if (stream == null || typeof stream.write !== 'function') return
stream.write('\x1b[J')
}
/**
* Carriage return + erase entire line (EL 2K).
* @param {import('stream').Writable | null | undefined} stream
*/
export function clearEntireLine(stream) {
if (stream == null || typeof stream.write !== 'function') return
stream.write('\r\x1b[2K')
}
/**
* Column 0 then EL entire line (same effect as clearEntireLine on most terminals).
* @param {import('stream').Writable | null | undefined} stream
*/
export function clearCurrentLine(stream) {
if (stream == null || typeof stream.write !== 'function') return
cursorTo(stream, 0)
stream.write('\x1b[2K')
}
+50
View File
@@ -17,6 +17,13 @@ import {
searchHistorySubstring
} from './lib/fish-history.js'
import { fuzzyMatch, SHELL_BUILTINS } from './lib/fish-readline.js'
import {
clearCurrentLine,
clearEntireLine,
clearScreenDown,
cursorTo,
moveCursor
} from './lib/tty-ansi.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -186,6 +193,8 @@ async function run(ctx, argv) {
t.is(ctx.vfs.getcwd(), '/bin')
await execShellLine(ctx, 'xy one two')
t.is(got[0], 'xy one two')
t.is(await execShellLine(ctx, 'exit'), 'exit')
t.is(await execShellLine(ctx, 'quit'), 'exit')
await store.close()
rmSync(dir, { recursive: true, force: true })
})
@@ -251,3 +260,44 @@ test('fuzzyMatch and SHELL_BUILTINS', async (t) => {
t.ok(fuzzyMatch('basename', 'bse'))
t.ok(SHELL_BUILTINS.includes('cd'))
})
test('tty-ansi emits CSI for cursor and clear', async (t) => {
/** @type {string[]} */
const chunks = []
const stream = {
write(s) {
chunks.push(typeof s === 'string' ? s : String(s))
}
}
cursorTo(stream, 0)
t.is(chunks.join(''), '\x1b[1G')
chunks.length = 0
cursorTo(stream, 4)
t.is(chunks.join(''), '\x1b[5G')
chunks.length = 0
cursorTo(stream, 2, 1)
t.is(chunks.join(''), '\x1b[2;3H')
chunks.length = 0
moveCursor(stream, 0, -2)
t.is(chunks.join(''), '\x1b[2A')
chunks.length = 0
moveCursor(stream, 3, 1)
t.is(chunks.join(''), '\x1b[1B\x1b[3C')
chunks.length = 0
clearScreenDown(stream)
t.is(chunks.join(''), '\x1b[J')
chunks.length = 0
clearEntireLine(stream)
t.is(chunks.join(''), '\r\x1b[2K')
chunks.length = 0
clearCurrentLine(stream)
t.is(chunks.join(''), '\x1b[1G\x1b[2K')
})
+1 -1
View File
@@ -1,5 +1,5 @@
async function run(ctx, argv) {
ctx.console.log(
'Bare OS — builtins: cd, export, exit | /bin: basename cat clear date dirname echo env false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
'Bare OS — builtins: cd, export, exit, quit | /bin: basename cat clear date dirname echo env false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
)
}
+1 -1
View File
@@ -5,6 +5,6 @@ function bareStdin(ctx) {
async function run(ctx, argv) {
ctx.console.log(
'Bare OS — builtins: cd, export, exit | /bin: basename cat clear date dirname echo env false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
'Bare OS — builtins: cd, export, exit, quit | /bin: basename cat clear date dirname echo env false head help hostname id ls nl pathchk printenv pwd rm seq sleep sort tail test touch true tty uname wc which whoami'
)
}
+1 -1
View File
@@ -7,7 +7,7 @@ async function start(ctx) {
const rel = await drive.get('/etc/os-release')
if (rel) console.log(b4a.toString(rel))
console.log(
'Bare operating system — POSIX-ish shell: cd, export, exit | try: help, ls /bin, pwd'
'Bare operating system — POSIX-ish shell: cd, export, exit, quit | try: help, ls /bin, pwd'
)
while (true) {
const line = await readLine('bare-os> ')