diff --git a/bun.lock b/bun.lock index ae3d2d2ba..c7d5eb4d9 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@qawolf/cli", "dependencies": { + "@clack/core": "1.4.1", "@clack/prompts": "1.5.1", "@napi-rs/keyring": "1.3.0", "@oxc-node/core": "0.1.0", diff --git a/package.json b/package.json index bd703bbe6..e23c2f5cf 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "version-packages": "changeset version && bun run format" }, "dependencies": { + "@clack/core": "1.4.1", "@clack/prompts": "1.5.1", "@napi-rs/keyring": "1.3.0", "@oxc-node/core": "0.1.0", diff --git a/src/core/ansi.ts b/src/core/ansi.ts index 35bafac0a..ec1632e3d 100644 --- a/src/core/ansi.ts +++ b/src/core/ansi.ts @@ -5,5 +5,8 @@ import { displayWidth } from "./displayWidth.js"; // the outer one off. export const bold = (text: string): string => `\x1b[1m${text}\x1b[22m`; export const dim = (text: string): string => `\x1b[2m${text}\x1b[22m`; +export const strike = (text: string): string => `\x1b[9m${text}\x1b[29m`; +export const inverse = (text: string): string => `\x1b[7m${text}\x1b[27m`; +export const cyan = (text: string): string => `\x1b[36m${text}\x1b[39m`; export const visibleLength = displayWidth; diff --git a/src/core/displayWidth.test.ts b/src/core/displayWidth.test.ts index 890a5f5f2..a938ad9af 100644 --- a/src/core/displayWidth.test.ts +++ b/src/core/displayWidth.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; -import { displayWidth, padColumns } from "./displayWidth.js"; +import { clipColumns, displayWidth, padColumns } from "./displayWidth.js"; describe("display columns", () => { it.each([ @@ -16,7 +17,26 @@ describe("display columns", () => { expect(displayWidth(text)).toBe(width); }); - it("pads by display columns", () => { + it("clips at grapheme boundaries and preserves ANSI resets", () => { + const clipped = clipColumns("\x1b[2m登录👩🏽‍💻text\x1b[22m", 6); + expect(stripVTControlCharacters(clipped)).toBe("登录…"); + expect(clipped).toEndWith("\x1b[22m"); + expect(clipColumns("caféx", 5)).toBe("caféx"); + expect(clipColumns("caféxy", 5)).toBe("café…"); + }); + + it("keeps a whole Unicode suffix for paths", () => { + expect(clipColumns("prefix/登录.ts", 8, true)).toBe("…登录.ts"); + }); + + it("preserves ANSI spans across grapheme clusters", () => { + const clipped = clipColumns("e\x1b[2ḿx👩🏽‍💻\x1b[22m", 3); + expect(stripVTControlCharacters(clipped)).toBe("éx…"); + expect(clipped).toEndWith("\x1b[22m"); + }); + + it("returns nothing for zero available columns and pads by columns", () => { + expect(clipColumns("abc", 0)).toBe(""); expect(padColumns("登录", 6)).toBe("登录 "); }); }); diff --git a/src/core/displayWidth.ts b/src/core/displayWidth.ts index c25580e4d..3cc2375fa 100644 --- a/src/core/displayWidth.ts +++ b/src/core/displayWidth.ts @@ -1,6 +1,68 @@ +import { stripVTControlCharacters } from "node:util"; import stringWidth from "fast-string-width"; +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const escapeSequence = + // oxlint-disable-next-line no-control-regex + /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; + export const displayWidth = (text: string): number => stringWidth(text); +function styledSlice( + text: string, + start: number, + end: number, + keepEnd: boolean, +): string { + let output = keepEnd ? "…" : ""; + let offset = 0; + let cursor = 0; + let ended = false; + const append = (part: string): void => { + const from = Math.max(0, start - offset); + const to = Math.max(0, Math.min(part.length, end - offset)); + output += part.slice(from, to); + offset += part.length; + if (!keepEnd && !ended && offset >= end) { + output += "…"; + ended = true; + } + }; + // Keep escape sequences outside the slice too: their resets close styles + // opened before the clipping boundary, including styles around the cursor. + for (const match of text.matchAll(escapeSequence)) { + append(text.slice(cursor, match.index)); + output += match[0]; + cursor = match.index + match[0].length; + } + append(text.slice(cursor)); + return output; +} + +export function clipColumns( + text: string, + width: number, + keepEnd = false, +): string { + if (width <= 0) return ""; + if (displayWidth(text) <= width) return text; + const plain = stripVTControlCharacters(text); + const segments = [...segmenter.segment(plain)]; + if (keepEnd) segments.reverse(); + let used = 0; + let length = 0; + for (const { segment } of segments) { + used += displayWidth(segment); + if (used > width - 1) break; + length += segment.length; + } + return styledSlice( + text, + keepEnd ? plain.length - length : 0, + keepEnd ? plain.length : length, + keepEnd, + ); +} + export const padColumns = (text: string, width: number): string => `${text}${" ".repeat(Math.max(0, width - displayWidth(text)))}`; diff --git a/src/shell/ui/renderers/altScreen.test.ts b/src/shell/ui/renderers/altScreen.test.ts new file mode 100644 index 000000000..d9a276a74 --- /dev/null +++ b/src/shell/ui/renderers/altScreen.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "bun:test"; + +import { openAltScreen } from "./altScreen.js"; + +function terminal(columns: number, rows: number) { + const cells = Array.from({ length: rows }, () => + Array(columns).fill(" "), + ); + let x = 0; + let y = 0; + return { + lines: () => cells.map((row) => row.join("")), + write(chunk: string) { + // A terminal keeps the cursor in its last column until another printable + // character wraps it; erase commands act on that last column immediately. + // oxlint-disable-next-line no-control-regex + for (const token of chunk.match(/\x1b\[[\d;?]*[A-Za-z]|[^]/g) ?? []) { + if (token === "\x1b[H") { + x = 0; + y = 0; + } else if (token === "\x1b[K" || token === "\x1b[J") { + cells[y]?.fill(" ", x); + if (token === "\x1b[J") + for (let row = y + 1; row < rows; row++) cells[row]?.fill(" "); + } else if (token === "\r") x = 0; + else if (token === "\n") y++; + else if (!token.startsWith("\x1b")) { + const row = cells[y]; + if (row) row[x] = token; + x = Math.min(columns - 1, x + 1); + } + } + return true; + }, + }; +} + +it("keeps the final cell of rows that fill the terminal width", () => { + const output = terminal(10, 2); + const screen = openAltScreen(output); + screen.paint("1234567890\nabcdefghij"); + expect(output.lines()).toEqual(["1234567890", "abcdefghij"]); + screen.close(); +}); + +it("clears stale cells and rows when the next frame gets smaller", () => { + const output = terminal(10, 3); + const screen = openAltScreen(output); + screen.paint("1234567890\nabcdefghij\n0123456789"); + screen.paint("short\nx"); + expect(output.lines()).toEqual(["short ", "x ", " "]); + screen.close(); +}); diff --git a/src/shell/ui/renderers/altScreen.ts b/src/shell/ui/renderers/altScreen.ts new file mode 100644 index 000000000..c0aac4a91 --- /dev/null +++ b/src/shell/ui/renderers/altScreen.ts @@ -0,0 +1,46 @@ +// The alternate screen prevents resized frames from entering scrollback and +// restores the user's original screen when the view closes. +const enterAltScreen = "\x1b[?1049h"; +const leaveAltScreen = "\x1b[?1049l"; +const hideCursor = "\x1b[?25l"; +const showCursor = "\x1b[?25h"; +// Synchronized output: supporting terminals show each frame whole rather than +// half-written; the rest ignore the sequence. +const beginFrame = "\x1b[?2026h\x1b[H"; +const endFrame = "\x1b[?2026l"; +const clearToLineEnd = "\x1b[K"; +const clearToScreenEnd = "\x1b[J"; + +type Writer = { write(chunk: string): unknown }; + +export function openAltScreen(output: Writer): { + paint: (frame: string) => void; + close: () => void; +} { + let open = true; + const close = (): void => { + if (!open) return; + open = false; + process.off("exit", close); + output.write(`${showCursor}${leaveAltScreen}`); + }; + // Handed back however the view ends — even if the process exits first. + process.once("exit", close); + output.write(`${enterAltScreen}${hideCursor}`); + + return { + paint(frame) { + if (!open) return; + // Clear before writing: after an exact-width line, the cursor still + // occupies its last cell and a trailing erase would remove that cell. + const lines = frame + .split("\n") + .map( + (line, index, all) => + `${index === all.length - 1 ? clearToScreenEnd : clearToLineEnd}${line}`, + ); + output.write(`${beginFrame}${lines.join("\r\n")}${endFrame}`); + }, + close, + }; +} diff --git a/src/shell/ui/renderers/debouncedResize.test.ts b/src/shell/ui/renderers/debouncedResize.test.ts new file mode 100644 index 000000000..0beb7b9bc --- /dev/null +++ b/src/shell/ui/renderers/debouncedResize.test.ts @@ -0,0 +1,97 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, mock } from "bun:test"; + +import { sleep } from "~/core/sleep.js"; + +import { withDebouncedResize } from "./debouncedResize.js"; + +/** `later` calls back the way a real terminal does, after the write returns. */ +function fakeTerminal(callBack: "now" | "later" = "now") { + const writes: string[] = []; + return Object.assign(new EventEmitter(), { + columns: 100, + rows: 30, + isTTY: true, + writes, + write( + chunk: Uint8Array | string, + callback: (error?: Error | null) => void, + ) { + writes.push(String(chunk)); + if (callBack === "now") callback(); + else setImmediate(() => callback()); + return true; + }, + }); +} + +const settleMs = 20; + +describe("withDebouncedResize", () => { + it("passes writes straight through to the terminal", async () => { + const terminal = fakeTerminal(); + const { output, dispose } = withDebouncedResize(terminal, settleMs); + + await new Promise((resolve) => + output.write("frame", () => resolve()), + ); + + expect(terminal.writes.join("")).toBe("frame"); + dispose(); + }); + + // A write held back until the terminal calls back is overtaken by whatever + // is written straight to the terminal meanwhile — a command's own output + // landing above the prompt's summary. + it("hands each write on at once, even while the terminal has yet to call back", () => { + const terminal = fakeTerminal("later"); + const { output, dispose } = withDebouncedResize(terminal, settleMs); + + output.write("summary"); + output.write("\n"); + + expect(terminal.writes).toEqual(["summary", "\n"]); + dispose(); + }); + + it("reports the terminal's size as it is now, not as it was", () => { + const terminal = fakeTerminal(); + const { output, dispose } = withDebouncedResize(terminal, settleMs); + + terminal.columns = 70; + terminal.rows = 18; + + expect(Reflect.get(output, "columns")).toBe(70); + expect(Reflect.get(output, "rows")).toBe(18); + dispose(); + }); + + // Dragging a window edge fires a resize for every size on the way. + it("turns a burst of resizes into one, after they stop", async () => { + const terminal = fakeTerminal(); + const { output, dispose } = withDebouncedResize(terminal, settleMs); + const resized = mock(); + output.on("resize", resized); + + for (let i = 0; i < 10; i += 1) terminal.emit("resize"); + expect(resized).not.toHaveBeenCalled(); + await sleep(settleMs * 3); + + expect(resized).toHaveBeenCalledTimes(1); + dispose(); + }); + + it("drops a pending resize once disposed", async () => { + const terminal = fakeTerminal(); + const { output, dispose } = withDebouncedResize(terminal, settleMs); + const resized = mock(); + output.on("resize", resized); + + terminal.emit("resize"); + dispose(); + terminal.emit("resize"); + await sleep(settleMs * 3); + + expect(resized).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shell/ui/renderers/debouncedResize.ts b/src/shell/ui/renderers/debouncedResize.ts new file mode 100644 index 000000000..defd2764f --- /dev/null +++ b/src/shell/ui/renderers/debouncedResize.ts @@ -0,0 +1,62 @@ +import { Writable } from "node:stream"; + +/** The parts of a terminal stream a prompt draws with; `process.stdout` is one. */ +export type TerminalStream = { + readonly columns?: number | undefined; + readonly rows?: number | undefined; + readonly isTTY?: boolean | undefined; + write( + chunk: Uint8Array | string, + callback: (error?: Error | null) => void, + ): boolean; + on(event: "resize", listener: () => void): unknown; + off(event: "resize", listener: () => void): unknown; +}; + +/** + * A stand-in for `terminal` whose "resize" fires once, after the window stops + * changing size, instead of once for every size it passes through. + */ +export function withDebouncedResize( + terminal: TerminalStream, + settleMs: number, +): { output: Writable; dispose: () => void } { + const output = new Writable({ + decodeStrings: false, + // Handed on and called back at once. A terminal calls back later, and + // until then this stream would hold further writes in a queue of its own — + // letting what is written straight to the terminal in the meantime, such + // as what a command prints once the prompt ends, overtake them. The + // terminal keeps its own queue, in order. + write(chunk: Uint8Array | string, _encoding, callback) { + terminal.write(chunk, () => {}); + callback(); + }, + }); + // Read live rather than copied: a prompt measures the stream it draws to on + // every frame, and the frame must match the window as it is now. + for (const key of ["columns", "rows", "isTTY"] as const) { + Object.defineProperty(output, key, { + enumerable: true, + get: () => terminal[key], + }); + } + + let timer: ReturnType | undefined; + const onResize = (): void => { + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + output.emit("resize"); + }, settleMs); + }; + terminal.on("resize", onResize); + + return { + output, + dispose() { + terminal.off("resize", onResize); + if (timer !== undefined) clearTimeout(timer); + }, + }; +} diff --git a/src/shell/ui/renderers/filterFrame.test.ts b/src/shell/ui/renderers/filterFrame.test.ts new file mode 100644 index 000000000..644fef61b --- /dev/null +++ b/src/shell/ui/renderers/filterFrame.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test"; + +import { type FilterFrame, renderFilterFrame } from "./filterFrame.js"; + +// oxlint-disable-next-line no-control-regex +const strip = (s: string): string => s.replace(/\x1b\[[\d;]*m/g, ""); + +const frame = (over: Partial = {}): FilterFrame => ({ + state: "active", + message: "Filter flows", + search: "exam", + searchWithCursor: "exam█", + header: "name file", + rowCount: 20, + line: (index) => `flow ${String(index + 1)}`, + focus: 0, + detail: "id flow-1 · src/flows/1.flow.ts", + columns: 100, + // Leaves room for 4 rows once the frame's other lines are counted. + terminalRows: 12, + count: "20 of 100 flows", + ...over, +}); + +const lines = (over: Partial = {}): string[] => + strip(renderFilterFrame(frame(over))).split("\n"); + +const rowsShown = (over: Partial = {}): string[] => + lines(over) + .map((line) => /flow (\d+)\s*$/.exec(line)?.[1]) + .filter((n): n is string => n !== undefined); + +describe("renderFilterFrame", () => { + it("shows the search, an upper-case header over a rule, and the rows that fit", () => { + const out = lines(); + expect(out).toContain("│ Search: exam█"); + expect(out).toContain("│ NAME FILE"); + expect(out.some((line) => line.startsWith("│ ───"))).toBe(true); + expect(rowsShown()).toEqual(["1", "2", "3", "4"]); + }); + + it("marks the highlighted row", () => { + const out = lines({ focus: 2 }); + expect(out.find((line) => line.includes("flow 3"))).toStartWith("│› "); + expect(out.find((line) => line.includes("flow 2"))).toStartWith("│ "); + }); + + it("moves the window only once the highlight would leave it", () => { + expect(rowsShown({ focus: 3 })).toEqual(["1", "2", "3", "4"]); + expect(rowsShown({ focus: 10 })).toEqual(["8", "9", "10", "11"]); + expect(lines({ focus: 10 }).at(-2)).toContain("rows 8–11"); + }); + + it("stops at the last row", () => { + expect(rowsShown({ focus: 19 })).toEqual(["17", "18", "19", "20"]); + }); + + it("describes the highlighted row under the table", () => { + expect(lines()).toContain("│ id flow-1 · src/flows/1.flow.ts"); + }); + + // A frame taller than the screen scrolls the terminal. + it("never draws more lines than the terminal has", () => { + expect(lines().length).toBeLessThanOrEqual(12); + }); + + it("keeps every line within the width, the highlight bar included", () => { + for (const line of lines({ columns: 40, focus: 1 })) { + expect(line.length).toBeLessThanOrEqual(40); + } + }); + + it.each([1, 2, 4, 8, 24])("fits within a terminal with %i rows", (height) => { + expect(lines({ terminalRows: height }).length).toBeLessThanOrEqual(height); + }); + + it("clips long titles, searches, headers and Unicode rows by display columns", () => { + const out = lines({ + columns: 40, + message: "登录".repeat(30), + searchWithCursor: "x".repeat(120), + header: "name".repeat(30), + rowCount: 1, + line: () => "登录".repeat(25), + }); + for (const line of out) + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(40); + }); + + it("keeps multiline metadata and controls on single display lines", () => { + const over = { + message: "Filter\nflows", + search: "with\npassword", + searchWithCursor: "with\npassword█", + header: "name\tfile", + line: () => "Log in\nwith password\t!", + detail: "src/flow\r\nname.flow.ts", + count: "20\nflows", + terminalRows: 24, + }; + const out = lines(over); + expect(out.length).toBeLessThanOrEqual(24); + expect(out.join("\n")).toContain("Log in with password !"); + expect(out.join("\n")).toContain("Filter flows"); + expect(out.join("\n")).toContain("src/flow name.flow.ts"); + expect(out.join("\n")).not.toMatch(/[\t\r\b]/); + expect(lines({ ...over, state: "submit" }).length).toBe(3); + expect(over.search).toBe("with\npassword"); + }); + + it("says when nothing matches", () => { + expect(lines({ rowCount: 0, count: "0 of 100 flows" })).toContain( + "│ Nothing matches.", + ); + }); + + // The kept rows are printed in full afterwards; a second table is noise. + it("collapses to a summary once submitted", () => { + const out = lines({ state: "submit" }); + expect(out.join("\n")).not.toContain("flow 1"); + expect(out.at(-1)).toContain("exam 20 of 100 flows"); + expect(out).toHaveLength(3); + }); + + it("collapses to the struck-out search when cancelled", () => { + const out = lines({ state: "cancel" }); + expect(out.join("\n")).not.toContain("flow 1"); + expect(out.at(-1)).toContain("exam"); + expect(out).toHaveLength(3); + }); +}); diff --git a/src/shell/ui/renderers/filterFrame.ts b/src/shell/ui/renderers/filterFrame.ts new file mode 100644 index 000000000..6f757d8db --- /dev/null +++ b/src/shell/ui/renderers/filterFrame.ts @@ -0,0 +1,110 @@ +import { S_BAR, S_BAR_END, symbol } from "@clack/prompts"; + +import { cyan, dim, inverse, strike } from "~/core/ansi.js"; +import { clipColumns, displayWidth, padColumns } from "~/core/displayWidth.js"; + +import { singleLine } from "./singleLine.js"; + +export const frameGutter = `${S_BAR} `; + +export type FilterFrame = { + readonly state: "initial" | "active" | "cancel" | "submit" | "error"; + readonly message: string; + readonly search: string; + readonly searchWithCursor: string; + readonly header: string; + readonly rowCount: number; + readonly line: (index: number) => string; + readonly focus: number; + readonly detail: string | undefined; + readonly columns: number; + readonly terminalRows: number; + readonly count: string; +}; + +function collapsed(frame: FilterFrame, title: string): string[] { + const kept = `${frame.search === "" ? "" : `${frame.search} `}${frame.count}`; + const typed = frame.state === "submit" ? dim(kept) : strike(frame.search); + return [S_BAR, title, `${frameGutter}${typed}`]; +} + +function rowGutter(focused: boolean): string { + const sign = focused ? cyan("›") : " "; + return `${S_BAR}${sign} `; +} + +function keyHints(room: number): string { + const enter = "Enter prints matches"; + const essential = `${enter} · Esc cancels`; + const full = `↑/↓ move · ${essential}`; + if (displayWidth(full) <= room) return full; + return displayWidth(essential) <= room + ? essential + : "Enter print · Esc cancel"; +} + +export function renderFilterFrame(frame: FilterFrame): string { + const columns = Math.max(0, frame.columns); + const height = Math.max(1, frame.terminalRows); + const room = Math.max(0, columns - displayWidth(frameGutter)); + const title = `${symbol(frame.state)} ${frame.message}`; + const fit = (lines: readonly string[]): string => + lines + .slice(-height) + .map((line) => clipColumns(singleLine(line), columns)) + .join("\n"); + if (frame.state === "submit" || frame.state === "cancel") + return fit(collapsed(frame, title)); + + const full = height >= 10; + const headings = height >= 6; + const before = [ + ...(full ? [S_BAR] : []), + ...(headings ? [title] : []), + ...(height > 1 + ? [ + `${frameGutter}Search: ${clipColumns(singleLine(frame.searchWithCursor), Math.max(0, room - 8), true)}`, + ] + : []), + ...(headings && frame.rowCount > 0 + ? [`${frameGutter}${dim(frame.header.toUpperCase())}`] + : []), + ...(full && frame.rowCount > 0 + ? [`${frameGutter}${dim("─".repeat(room))}`] + : []), + ]; + const detail = + full && frame.rowCount > 0 && frame.detail !== undefined + ? [`${frameGutter}${dim(frame.detail)}`] + : []; + const visible = Math.max( + 0, + height - before.length - detail.length - (headings ? 2 : 1), + ); + const start = Math.min( + Math.max(0, frame.focus - visible + 1), + Math.max(0, frame.rowCount - visible), + ); + const count = Math.min(visible, frame.rowCount - start); + const shown = Array.from({ length: count }, (_, index) => { + const at = start + index; + const line = clipColumns(singleLine(frame.line(at)), room); + const focused = at === frame.focus; + return `${rowGutter(focused)}${focused ? inverse(padColumns(line, room)) : line}`; + }); + if (frame.rowCount === 0 && visible > 0) + shown.push(`${frameGutter}${dim("Nothing matches.")}`); + + const position = + frame.rowCount > visible && count > 0 + ? ` · rows ${String(start + 1)}–${String(start + count)}` + : ""; + const statusLine = `${frameGutter}${dim(`${frame.count}${position}`)}`; + return fit([ + ...before, + ...shown, + ...detail, + ...(headings ? [statusLine] : []), + `${S_BAR_END} ${dim(keyHints(room))}`, + ]); +} diff --git a/src/shell/ui/renderers/filterList.test.ts b/src/shell/ui/renderers/filterList.test.ts new file mode 100644 index 000000000..beeae7422 --- /dev/null +++ b/src/shell/ui/renderers/filterList.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; +import { PassThrough } from "node:stream"; + +import { sleep } from "~/core/sleep.js"; + +import { createFilterList } from "./filterList.js"; +import { fakeTerminal, open, typed, paints } from "./filterList.testUtils.js"; + +const enterAltScreen = "\x1b[?1049h"; +const leaveAltScreen = "\x1b[?1049l"; +const frameStart = "\x1b[?2026h"; + +describe("createFilterList", () => { + it("keeps what matches the search when Enter is pressed", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "be"); + input.write("\r"); + + expect(await result).toEqual({ ok: true, value: ["beta"] }); + }); + + // Drawn inline, a resize let the terminal rewrap the frame into scrollback. + it("draws only on the alternate screen, and hands the screen back", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "\r"); + await result; + + const out = terminal.writes.join(""); + const entered = out.indexOf(enterAltScreen); + const left = out.lastIndexOf(leaveAltScreen); + expect(entered).toBeGreaterThanOrEqual(0); + expect(left).toBeGreaterThan(entered); + const frame = out.indexOf(frameStart, entered); + const alpha = out.indexOf("alpha", frame); + expect(frame).toBeGreaterThan(entered); + expect(frame).toBeLessThan(left); + expect(alpha).toBeGreaterThan(frame); + expect(alpha).toBeLessThan(left); + expect(out.lastIndexOf(frameStart)).toBeLessThan(left); + }); + + it("removes its resize listener when search setup fails", async () => { + const terminal = fakeTerminal(); + const existingListener = () => {}; + terminal.on("resize", existingListener); + const failure = new Error("search setup failed"); + const filter = createFilterList({ + mode: "human", + input: new PassThrough(), + output: terminal, + }); + let caught: unknown; + try { + await filter({ + message: "Filter things", + items: ["alpha"], + searchText: () => { + throw failure; + }, + table: () => ({ header: "name", line: (item) => item }), + describeCount: () => "1 of 1", + detail: (item) => item, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe(failure); + expect(terminal.listeners("resize")).toEqual([existingListener]); + expect(terminal.writes).toEqual([]); + }); + + it("leaves the search and its match count on the normal screen", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "be"); + input.write("\r"); + await result; + + const out = terminal.writes.join(""); + const after = out.slice(out.lastIndexOf(leaveAltScreen)); + expect(after).toContain("be"); + expect(after).toContain("1 of 3"); + expect(after).not.toContain("gamma"); + }); + + it("describes the highlighted row", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "\x1b[B"); + + expect(paints(terminal).at(-1)).toContain("about beta"); + input.write("\x03"); + await result; + }); + + it("repaints once when the window settles at a new size", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + await sleep(20); + const before = paints(terminal).length; + + terminal.columns = 40; + for (let i = 0; i < 5; i += 1) terminal.emit("resize"); + await sleep(200); + + expect(paints(terminal).length).toBe(before + 1); + input.write("\r"); + await result; + }); + + it("hands the screen back and resolves not ok when cancelled", async () => { + const terminal = fakeTerminal(); + const { input, result } = open(terminal); + + await typed(input, "\x03"); + + expect(await result).toEqual({ ok: false }); + expect(terminal.writes.join("")).toContain(leaveAltScreen); + }); +}); diff --git a/src/shell/ui/renderers/filterList.testUtils.ts b/src/shell/ui/renderers/filterList.testUtils.ts new file mode 100644 index 000000000..849b519d1 --- /dev/null +++ b/src/shell/ui/renderers/filterList.testUtils.ts @@ -0,0 +1,51 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { sleep } from "~/core/sleep.js"; +import { createFilterList } from "./filterList.js"; +const frameStart = "\x1b[?2026h"; +export function fakeTerminal() { + const writes: string[] = []; + return Object.assign(new EventEmitter(), { + columns: 80, + rows: 24, + isTTY: true, + writes, + write( + chunk: Uint8Array | string, + callback: (error?: Error | null) => void, + ) { + writes.push(String(chunk)); + // Later, as a real terminal calls back; at once would hide writes + // left waiting in a queue. + setImmediate(() => callback()); + return true; + }, + }); +} + +/** Opens the real prompt against fake streams; type into `input` to drive it. */ +export function open(terminal: ReturnType) { + const input = new PassThrough(); + const previousTerm = process.env["TERM"]; + process.env["TERM"] = "xterm-256color"; + const result = createFilterList({ mode: "human", input, output: terminal })({ + message: "Filter things", + items: ["alpha", "beta", "gamma"], + searchText: (item) => [item], + table: () => ({ header: "name", line: (item) => item }), + describeCount: (matched, total) => `${String(matched)} of ${String(total)}`, + detail: (item) => `about ${item}`, + }); + if (previousTerm === undefined) delete process.env["TERM"]; + else process.env["TERM"] = previousTerm; + return { input, result }; +} + +export const typed = (input: PassThrough, text: string) => { + input.write(text); + // Let readline turn the bytes into keypresses before looking. + return sleep(20); +}; + +export const paints = (terminal: ReturnType): string[] => + terminal.writes.filter((write) => write.startsWith(frameStart)); diff --git a/src/shell/ui/renderers/filterList.ts b/src/shell/ui/renderers/filterList.ts new file mode 100644 index 000000000..0e9861de1 --- /dev/null +++ b/src/shell/ui/renderers/filterList.ts @@ -0,0 +1,77 @@ +import type { Readable } from "node:stream"; +import { AutocompletePrompt, isCancel } from "@clack/core"; + +import { createSearchIndex } from "~/core/textSearch.js"; +import type { OutputMode } from "~/shell/ui/env.js"; + +import { openAltScreen } from "./altScreen.js"; +import { assertHumanMode } from "./assertHumanMode.js"; +import { type TerminalStream, withDebouncedResize } from "./debouncedResize.js"; +import { createFrameDrawer } from "./filterView.js"; +import type { FilterListArgs, FilterListFn, PromptResult } from "./types.js"; + +type Deps = { + mode: OutputMode; + input?: Readable; + output?: TerminalStream; +}; + +const resizeSettleMs = 100; + +const isDone = (state: string): boolean => + state === "submit" || state === "cancel"; + +export function createFilterList(deps: Deps): FilterListFn { + return async function filterList( + args: FilterListArgs, + ): Promise> { + assertHumanMode(deps.mode, "filterList"); + const terminal = withDebouncedResize( + deps.output ?? process.stdout, + resizeSettleMs, + ); + try { + const output = terminal.output; + + const options = args.items.map((item, index) => ({ value: index, item })); + const matches = createSearchIndex(options, (option) => + args.searchText(option.item), + ); + + const draw = createFrameDrawer(args, output); + + const screen = openAltScreen(output); + try { + const prompt = new AutocompletePrompt({ + options, + filter: matches, + output, + input: deps.input ?? process.stdin, + // Paint whole frames after resizes; returning an empty frame prevents + // clack from diffing against its stale pre-resize screen. + render() { + if (!isDone(this.state)) screen.paint(draw(this)); + return ""; + }, + }); + const result = await prompt.prompt().finally(() => { + screen.close(); + }); + + output.write( + `${draw(prompt, isCancel(result) ? "cancel" : "submit")}\n`, + ); + + if (isCancel(result)) return { ok: false }; + return { + ok: true, + value: prompt.filteredOptions.map((option) => option.item), + }; + } finally { + screen.close(); + } + } finally { + terminal.dispose(); + } + }; +} diff --git a/src/shell/ui/renderers/filterView.test.ts b/src/shell/ui/renderers/filterView.test.ts new file mode 100644 index 000000000..ca184bfe2 --- /dev/null +++ b/src/shell/ui/renderers/filterView.test.ts @@ -0,0 +1,38 @@ +import { PassThrough } from "node:stream"; +import { expect, it, mock } from "bun:test"; + +import { createFrameDrawer } from "./filterView.js"; + +it("formats only visible rows and reuses them until width changes", () => { + const output = Object.assign(new PassThrough(), { columns: 80, rows: 12 }); + const items = Array.from({ length: 1000 }, (_, index) => String(index)); + const line = mock((item: string) => item); + const table = mock(() => ({ header: "name", line })); + const draw = createFrameDrawer( + { + message: "Filter", + items, + table, + searchText: (item) => [item], + describeCount: (matched) => String(matched), + detail: (item) => item, + }, + output, + ); + const view = { + state: "active" as const, + userInput: "", + userInputWithCursor: "█", + filteredOptions: items.map((item, value) => ({ item, value })), + cursor: 0, + }; + draw(view); + expect(line.mock.calls.length).toBeLessThanOrEqual(output.rows); + const first = line.mock.calls.length; + draw({ ...view, cursor: 1 }); + expect(line.mock.calls.length).toBe(first); + output.columns = 60; + draw(view); + expect(table).toHaveBeenCalledTimes(2); + expect(line.mock.calls.length).toBe(first * 2); +}); diff --git a/src/shell/ui/renderers/filterView.ts b/src/shell/ui/renderers/filterView.ts new file mode 100644 index 000000000..5727c666d --- /dev/null +++ b/src/shell/ui/renderers/filterView.ts @@ -0,0 +1,69 @@ +import type { Writable } from "node:stream"; +import { getColumns, getRows } from "@clack/core"; + +import { displayWidth } from "~/core/displayWidth.js"; + +import { + type FilterFrame, + frameGutter, + renderFilterFrame, +} from "./filterFrame.js"; +import type { FilterListArgs, FilterTable } from "./types.js"; + +export type PromptView = { + readonly state: FilterFrame["state"]; + readonly userInput: string; + readonly userInputWithCursor: string; + readonly filteredOptions: readonly { + readonly value: number; + readonly item: Item; + }[]; + readonly cursor: number; +}; + +export function createFrameDrawer( + args: FilterListArgs, + output: Writable, +): (view: PromptView, state?: FilterFrame["state"]) => string { + // Laid out from every item rather than the current matches, so columns + // hold still while the list narrows. Redone only on a resize. + const cached = new Map(); + let layout: { width: number; table: FilterTable } | undefined; + const tableFor = (width: number): FilterTable => { + if (layout === undefined || layout.width !== width) { + layout = { width, table: args.table(args.items, width) }; + cached.clear(); + } + return layout.table; + }; + + return (view, state = view.state) => { + const columns = getColumns(output); + const table = tableFor(Math.max(0, columns - displayWidth(frameGutter))); + const highlighted = view.filteredOptions[view.cursor]; + return renderFilterFrame({ + state, + message: args.message, + search: view.userInput, + searchWithCursor: view.userInputWithCursor, + header: table.header, + rowCount: view.filteredOptions.length, + line: (index) => { + const option = view.filteredOptions[index]; + if (option === undefined) return ""; + let line = cached.get(option.value); + if (line === undefined) { + line = table.line(option.item); + cached.set(option.value, line); + } + return line; + }, + focus: view.cursor, + detail: + highlighted === undefined ? undefined : args.detail(highlighted.item), + columns, + terminalRows: getRows(output), + count: args.describeCount(view.filteredOptions.length, args.items.length), + }); + }; +} diff --git a/src/shell/ui/renderers/singleLine.ts b/src/shell/ui/renderers/singleLine.ts new file mode 100644 index 000000000..9fea9b70a --- /dev/null +++ b/src/shell/ui/renderers/singleLine.ts @@ -0,0 +1,19 @@ +import { stripVTControlCharacters } from "node:util"; + +export function singleLine(text: string): string { + return ( + text + // Preserve SGR styling while removing terminal commands and line controls. + // oxlint-disable-next-line no-control-regex + .split(/(\x1b\[[\d;:]*m)/g) + .map((part, index) => + index % 2 === 1 + ? part + : stripVTControlCharacters(part).replace( + /[\p{Control}\p{Line_Separator}\p{Paragraph_Separator}]+/gu, + " ", + ), + ) + .join("") + ); +} diff --git a/src/shell/ui/renderers/types.ts b/src/shell/ui/renderers/types.ts index 83cdbf12e..31f7b85cb 100644 --- a/src/shell/ui/renderers/types.ts +++ b/src/shell/ui/renderers/types.ts @@ -1 +1,24 @@ export type PromptResult = { ok: true; value: T } | { ok: false }; + +/** A table laid out for a width: its header, and how to draw any one item. */ +export type FilterTable = { + readonly header: string; + readonly line: (item: Item) => string; +}; + +export type FilterListArgs = { + readonly message: string; + readonly items: readonly Item[]; + /** Text a search matches against. It need not all be on screen. */ + readonly searchText: (item: Item) => readonly (string | undefined)[]; + /** Fixes column widths for the full list while the visible rows change. */ + readonly table: (items: readonly Item[], width: number) => FilterTable; + readonly describeCount: (matched: number, total: number) => string; + /** One line about the highlighted item, shown under the table. */ + readonly detail: (item: Item) => string; +}; + +/** A searchable table that returns every match when Enter is pressed. */ +export type FilterListFn = ( + args: FilterListArgs, +) => Promise>;