@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> ')
|
||||
|
||||
Reference in New Issue
Block a user