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')
})