From b41b6e0348f3a3bc8813093641445672b69c579d Mon Sep 17 00:00:00 2001 From: Wook Song Date: Wed, 23 Sep 2026 00:10:27 +0900 Subject: [PATCH] fix(tui): keep footer redraws out of scrollback Use counted cursor movement for steady footer redraws, clip live content to the viewport, and keep composer rows within terminal width. Account conservatively for settled rows and pasted tabs so footer reservations remain safe across terminal width policies. Fixes #28 --- packages/cli/test/chat.test.ts | 2 +- packages/tui/src/screen.ts | 164 +++++++++++++++++++++++------- packages/tui/src/width.ts | 128 ++++++++++++++++++++++- packages/tui/test/screen.test.ts | 169 +++++++++++++++++++++++++++++++ 4 files changed, 424 insertions(+), 39 deletions(-) create mode 100644 packages/tui/test/screen.test.ts diff --git a/packages/cli/test/chat.test.ts b/packages/cli/test/chat.test.ts index baec3e4..16d8edd 100644 --- a/packages/cli/test/chat.test.ts +++ b/packages/cli/test/chat.test.ts @@ -568,7 +568,7 @@ describe("interactive session", () => { open.push(s); await vi.waitFor(() => expect(s.screen()).toContain("Paste your Infron API key to get started")); s.type("sk-bad"); - await vi.waitFor(() => expect(s.screen()).toContain("key › ••••••")); + await vi.waitFor(() => expect(s.screen()).toContain("key › ******")); expect(s.screen()).not.toContain("sk-bad"); s.type("\r"); await vi.waitFor(() => expect(s.screen()).toContain("rejected this key")); diff --git a/packages/tui/src/screen.ts b/packages/tui/src/screen.ts index a96ac01..19da4aa 100644 --- a/packages/tui/src/screen.ts +++ b/packages/tui/src/screen.ts @@ -48,7 +48,15 @@ import { import { compactReadings, readings, statusLine } from "./statusline.js"; import { CommitTracker } from "./stream.js"; import { NO_COLOR, paint, severityColor, style, term } from "./theme.js"; -import { displayWidth, truncateToWidth, wrapToWidth } from "./width.js"; +import { + boundaryWidth, + displayWidth, + expandTabsToBoundary, + truncateToBoundary, + truncateToWidth, + wrapToBoundary, + wrapToWidth, +} from "./width.js"; import type { LoopEvent } from "@motifcode/core"; export interface ScreenOptions { @@ -138,6 +146,8 @@ export class Screen { private settledMemo: { cells: readonly unknown[]; key: string; lines: StyledLine[] } | null = null; /** The rows of the last painted footer, top to bottom. */ private footer: Row[] = []; + /** Rows just cleared, so a redraw can reuse them without scrolling. */ + private reservedFooterRows = 0; /** Where the cursor was left: the footer row index and column, or null when below the footer. */ private cursorAt: { row: number; col: number } | null = null; /** The width the footer was painted at, to notice a resize. */ @@ -461,7 +471,18 @@ export class Screen { // that appears to have produced nothing. const settled = this.settledLines(); const fresh = this.commits.take(settled.map((l) => l.text)); - for (const line of settled.slice(settled.length - fresh.length)) this.write(this.colour(line) + "\n"); + const freshLines = settled.slice(settled.length - fresh.length); + // Those lines are written where the footer just was. The reservation has + // to shrink by every row they can occupy at the safe boundary width, or + // the next footer may draw into rows the transcript already consumed. + const width = this.columns(); + let emitted = 0; + for (const line of freshLines) { + const text = expandTabsToBoundary(line.text); + emitted += wrapToBoundary(text, width).length; + this.write(this.colour({ ...line, text }) + "\n"); + } + this.reservedFooterRows = Math.max(0, this.reservedFooterRows - emitted); this.paintFooter(); } @@ -496,12 +517,13 @@ export class Screen { const settled = this.settledLines(); const fresh = this.commits.take(settled.map((l) => l.text)); void fresh; - const footer = this.buildFooter(width); + const footer = this.clipFooter(this.buildFooter(width)); const physical: string[] = []; for (const l of settled) for (const t of wrapToWidth(l.text, width)) physical.push(this.colour({ ...l, text: t })); const keep = Math.max(0, this.rowCount() - footer.rows.length - 1); this.footer = []; this.cursorAt = null; + this.reservedFooterRows = 0; this.write(term.clearScreen + term.home); for (const line of physical.slice(Math.max(0, physical.length - keep))) this.write(line + "\n"); this.writeFooter(footer, width); @@ -516,7 +538,39 @@ export class Screen { private paintFooter(): void { if (!this.interactive) return; const width = this.columns(); - this.writeFooter(this.buildFooter(width), width); + this.writeFooter(this.clipFooter(this.buildFooter(width)), width); + } + + /** + * Drop rows that cannot fit above the last screen row. + * + * The kept rows are the bottom of the footer, so the newest tail text and + * the composer stay. The omitted prefix is not committed: it is still the + * unstable tail, and it appears once when the turn settles. + */ + private clipFooter(footer: { rows: Row[]; cursor: { row: number; col: number } | null }): { + rows: Row[]; + cursor: { row: number; col: number } | null; + } { + const limit = Math.max(1, this.rowCount() - 1); + if (footer.rows.length <= limit) return footer; + const dropped = footer.rows.length - limit; + const rows = footer.rows.slice(dropped); + if (!footer.cursor) return { rows, cursor: null }; + return { rows, cursor: { row: Math.max(0, footer.cursor.row - dropped), col: footer.cursor.col } }; + } + + /** + * A footer row must stay one terminal row even where Ambiguous is width 2. + * Tabs are expanded first. Colour is kept when the original plain text fits; + * otherwise the row is cut as plain text rather than wrapped by the terminal. + */ + private fitBoundary(row: Row, width: number): Row { + const plain = row.text.replace(/\x1b\[[0-9;]*m/g, ""); + const expanded = expandTabsToBoundary(plain); + if (expanded === plain && boundaryWidth(plain) <= width) return row; + const fitted = truncateToBoundary(expanded, width); + return { text: fitted, width: displayWidth(fitted) }; } /** The footer's rows for a width, and where the cursor belongs among them. */ @@ -532,7 +586,7 @@ export class Screen { if (this.activity) { const glyph = SPINNER[this.tick % SPINNER.length]!; const seconds = Math.max(0, Math.floor((this.now() - this.activity.since) / 1000)); - const text = `${glyph} ${this.activity.text} (esc to interrupt · ${seconds}s)`; + const text = `${glyph} ${this.activity.text} (esc to interrupt | ${seconds}s)`; rows.push(...this.rows(truncateToWidth(text, width), (t) => paint(t, style.faint))); } @@ -554,42 +608,71 @@ export class Screen { } private writeFooter(footer: { rows: Row[]; cursor: { row: number; col: number } | null }, width: number): void { - const { rows, cursor } = footer; + const rows = footer.rows.map((row) => this.fitBoundary(row, width)); + const cursor = footer.cursor; + const reserved = this.reservedFooterRows; + this.reservedFooterRows = 0; + // A same-height redraw moves with CUU/CUD. LF is only the growth, and only + // as many as the new rows: one LF per row is what scrolls a copy into scrollback. + const growth = Math.max(0, rows.length - reserved); + if (growth > 0) { + if (reserved > 0) this.write(term.down(reserved)); + this.write("\n".repeat(growth)); + this.write(term.up(reserved + growth)); + } if (this.composer) this.write(term.hideCursor); - for (const r of rows) this.write(r.text + "\n"); + for (let i = 0; i < rows.length; i++) { + this.write(rows[i]!.text + term.lineStart); + if (i < rows.length - 1) this.write(term.down(1)); + } this.footer = rows; this.paintedWidth = width; this.cursorAt = cursor; - if (cursor) { - // The cursor sits on the empty line below the footer; lift it into the - // composer so the terminal's input method composes in the right place. - this.write(term.up(rows.length - cursor.row) + term.column(cursor.col) + term.showCursor); + if (cursor && rows.length > 0) { + // The cursor is on the last footer row. Lift it into the composer so the + // terminal's input method composes in the right place. + this.write(term.up(rows.length - 1 - cursor.row) + term.column(cursor.col) + term.showCursor); + } else if (rows.length > 0) { + this.write(term.down(1) + term.lineStart); } } /** * The composer block: a bordered box around the editor rows, then the menu. * - * Every box row is exactly the terminal width, so the right edge lines up - * and nothing wraps. Returns where the cursor belongs among the rows. + * The box is ASCII, not box-drawing. Those glyphs are East Asian Width + * Ambiguous, and a row of them counted as one column wraps on a terminal + * that draws them as two. The last column is left empty so the following + * cursor move cannot trip a pending wrap. A terminal narrower than the box + * gets one truncated row instead. Returns where the cursor belongs. */ private composerRows(view: ComposerView, width: number): { rows: Row[]; cursorRow: number; cursorCol: number } { - const inner = Math.max(4, width - 4); + const limit = Math.max(1, width - 1); + // One visible column per input code point keeps the renderer's cursor index + // valid while preventing a pasted tab from moving to a terminal tab stop. + const draftText = view.secret ? "*".repeat([...view.draft.text].length) : view.draft.text.replace(/\t/g, " "); + if (limit < 7) { + const shown = truncateToWidth(draftText === "" ? "> " : `> ${draftText}`, limit); + return { + rows: [{ text: paint(shown, style.faint), width: displayWidth(shown) }], + cursorRow: 0, + cursorCol: Math.min(displayWidth(shown), Math.max(0, limit - 1)), + }; + } + const inner = limit - 4; // The box takes four columns: its edges and a space inside each. - const draft = view.secret - ? { text: "•".repeat([...view.draft.text].length), cursor: view.draft.cursor } - : view.draft; + const draft = { ...view.draft, text: draftText }; const render = renderComposer(draft, { width: inner, prompt: view.secret ? view.secret.prompt : "> ", ...(view.placeholder !== undefined && !view.secret ? { placeholder: view.placeholder } : {}), }); const border = (l: string, r: string): Row => ({ - text: paint(`${l}${"─".repeat(inner + 2)}${r}`, style.faint), + text: paint(`${l}${"-".repeat(inner + 2)}${r}`, style.faint), width: inner + 4, }); - const rows: Row[] = [border("╭", "╮")]; + const rows: Row[] = [border("+", "+")]; if (view.confirm) { const fit = (s: string): string => { const t = truncateToWidth(s, inner); @@ -606,9 +689,9 @@ export class Screen { ? paint(fit(l), style.accent) : paint(fit(l), style.faint) : fit(l); - rows.push({ text: `${paint("│ ", style.faint)}${painted}${paint(" │", style.faint)}`, width: inner + 4 }); + rows.push({ text: `${paint("| ", style.faint)}${painted}${paint(" |", style.faint)}`, width: inner + 4 }); } - rows.push(border("╰", "╯")); + rows.push(border("+", "+")); // The cursor rests on the selected choice; there is nothing to type. const selectedLine = view.confirm.choices.findIndex((c) => c.startsWith("❯")); return { rows, cursorRow: 1 + firstChoice + Math.max(0, selectedLine), cursorCol: 2 }; @@ -626,27 +709,34 @@ export class Screen { body.push({ text: "", title: false }); for (const { text, title } of body) { const painted = title ? paint(fit(text), style.bold) : paint(fit(text), style.faint); - rows.push({ text: `${paint("│ ", style.faint)}${painted}${paint(" │", style.faint)}`, width: inner + 4 }); + rows.push({ text: `${paint("| ", style.faint)}${painted}${paint(" |", style.faint)}`, width: inner + 4 }); } header = body.length; } for (const row of render.rows) { - const body = render.placeholder ? paint(row.body, style.faint) : row.body; - const plainWidth = displayWidth(row.prefix) + displayWidth(row.body); - const pad = " ".repeat(Math.max(0, inner - plainWidth)); + let prefix = row.prefix; + let bodyText = row.body; + if (displayWidth(prefix) > inner) { + prefix = truncateToWidth(prefix, inner); + bodyText = ""; + } else if (displayWidth(prefix) + displayWidth(bodyText) > inner) { + bodyText = truncateToWidth(bodyText, inner - displayWidth(prefix)); + } + const body = render.placeholder ? paint(bodyText, style.faint) : bodyText; + const pad = " ".repeat(Math.max(0, inner - displayWidth(prefix) - displayWidth(bodyText))); rows.push({ - text: `${paint("│ ", style.faint)}${paint(row.prefix, style.accent)}${body}${pad}${paint(" │", style.faint)}`, + text: `${paint("| ", style.faint)}${paint(prefix, style.accent)}${body}${pad}${paint(" |", style.faint)}`, width: inner + 4, }); } // +1 for the top border, plus any header rows; +2 for the box's left edge. const cursorRow = 1 + header + render.cursorRow; const cursorCol = Math.min(2 + render.cursorCol, Math.max(0, width - 1)); - rows.push(border("╰", "╯")); + rows.push(border("+", "+")); if (view.menu && view.menu.items.length > 0 && !view.secret) { - const menu = renderMenu(view.menu.items, view.menu.selected, { width, ...(view.menu.prefix ? { prefix: view.menu.prefix } : {}) }); + const menu = renderMenu(view.menu.items, view.menu.selected, { width: limit, ...(view.menu.prefix ? { prefix: view.menu.prefix } : {}) }); menu.rows.forEach((row, i) => { - const t = truncateToWidth(row, width); + const t = truncateToWidth(row, limit); rows.push({ text: i === menu.selectedRow ? paint(t, style.accent + style.bold) : paint(t, style.faint), width: displayWidth(t), @@ -664,7 +754,8 @@ export class Screen { private hintRows(width: number): Row[] { const left = this.hint === "" ? "? for shortcuts" : this.hint; const parts = compactReadings(this.state.instruments); - const rightPlain = [this.label, ...parts.map((r) => `${r.label} ${r.value}`)].filter(Boolean).join(" · "); + const sep = " | "; + const rightPlain = [this.label, ...parts.map((r) => `${r.label} ${r.value}`)].filter(Boolean).join(sep); const rightPainted = NO_COLOR ? rightPlain : [ @@ -672,9 +763,9 @@ export class Screen { ...parts.map((r) => paint(`${r.label} ${r.value}`, severityColor(r.severity))), ] .filter(Boolean) - .join(paint(" · ", style.dim)); - const leftText = ` ${truncateToWidth(left, Math.max(1, width - 2))}`; - const gap = width - displayWidth(leftText) - displayWidth(rightPlain); + .join(paint(sep, style.dim)); + const leftText = truncateToBoundary(` ${left}`, Math.max(1, width)); + const gap = width - boundaryWidth(leftText) - boundaryWidth(rightPlain); if (rightPlain === "" || gap < 2) return [{ text: paint(leftText, style.faint), width: displayWidth(leftText) }]; return [{ text: `${paint(leftText, style.faint)}${" ".repeat(gap)}${rightPainted}`, width }]; } @@ -687,7 +778,7 @@ export class Screen { const body = r.label ? `${paint(r.label, style.dim)} ${r.value}` : r.value; return paint(body, severityColor(r.severity)); }) - .join(paint(" · ", style.dim)); + .join(paint(" | ", style.dim)); } /** @@ -709,9 +800,10 @@ export class Screen { this.write(term.up(total)); for (let i = 0; i < total; i++) { this.write(term.clearLine + term.lineStart); - if (i < total - 1) this.write("\n"); + if (i < total - 1) this.write(term.down(1)); } - this.write(term.up(total - 1)); + if (total > 1) this.write(term.up(total - 1)); + this.reservedFooterRows = total; this.footer = []; } diff --git a/packages/tui/src/width.ts b/packages/tui/src/width.ts index fb8b945..9be0399 100644 --- a/packages/tui/src/width.ts +++ b/packages/tui/src/width.ts @@ -63,6 +63,121 @@ export function displayWidth(s: string): number { return w; } +/** + * East_Asian_Width = A. Not used by prose truncation: counting these as wide + * would shorten ordinary text on terminals where they are one column. The + * footer boundary uses them as a worst case, because a row that wraps there + * desynchronises the row count. + */ +const AMBIGUOUS_RANGES: [number, number][] = [ + [0x00a1, 0x00a1], [0x00a4, 0x00a4], [0x00a7, 0x00a8], [0x00aa, 0x00aa], [0x00ad, 0x00ae], + [0x00b0, 0x00b4], [0x00b6, 0x00ba], [0x00bc, 0x00bf], [0x00c6, 0x00c6], [0x00d0, 0x00d0], + [0x00d7, 0x00d8], [0x00de, 0x00e1], [0x00e6, 0x00e6], [0x00e8, 0x00ea], [0x00ec, 0x00ed], + [0x00f0, 0x00f0], [0x00f2, 0x00f3], [0x00f7, 0x00fa], [0x00fc, 0x00fc], [0x00fe, 0x00fe], + [0x0101, 0x0101], [0x0111, 0x0111], [0x0113, 0x0113], [0x011b, 0x011b], [0x0126, 0x0127], + [0x012b, 0x012b], [0x0131, 0x0133], [0x0138, 0x0138], [0x013f, 0x0142], [0x0144, 0x0144], + [0x0148, 0x014b], [0x014d, 0x014d], [0x0152, 0x0153], [0x0166, 0x0167], [0x016b, 0x016b], + [0x01ce, 0x01ce], [0x01d0, 0x01d0], [0x01d2, 0x01d2], [0x01d4, 0x01d4], [0x01d6, 0x01d6], + [0x01d8, 0x01d8], [0x01da, 0x01da], [0x01dc, 0x01dc], [0x0251, 0x0251], [0x0261, 0x0261], + [0x02c4, 0x02c4], [0x02c7, 0x02c7], [0x02c9, 0x02cb], [0x02cd, 0x02cd], [0x02d0, 0x02d0], + [0x02d8, 0x02db], [0x02dd, 0x02dd], [0x02df, 0x02df], [0x0300, 0x036f], [0x0391, 0x03a1], + [0x03a3, 0x03a9], [0x03b1, 0x03c1], [0x03c3, 0x03c9], [0x0401, 0x0401], [0x0410, 0x044f], + [0x0451, 0x0451], [0x2010, 0x2010], [0x2013, 0x2016], [0x2018, 0x2019], [0x201c, 0x201d], + [0x2020, 0x2022], [0x2024, 0x2027], [0x2030, 0x2030], [0x2032, 0x2033], [0x2035, 0x2035], + [0x203b, 0x203b], [0x203e, 0x203e], [0x2074, 0x2074], [0x207f, 0x207f], [0x2081, 0x2084], + [0x20ac, 0x20ac], [0x2103, 0x2103], [0x2105, 0x2105], [0x2109, 0x2109], [0x2113, 0x2113], + [0x2116, 0x2116], [0x2121, 0x2122], [0x2126, 0x2126], [0x212b, 0x212b], [0x2153, 0x2154], + [0x215b, 0x215e], [0x2160, 0x216b], [0x2170, 0x2179], [0x2189, 0x2189], [0x2190, 0x2199], + [0x21b8, 0x21b9], [0x21d2, 0x21d2], [0x21d4, 0x21d4], [0x21e7, 0x21e7], [0x2200, 0x2200], + [0x2202, 0x2203], [0x2207, 0x2208], [0x220b, 0x220b], [0x220f, 0x220f], [0x2211, 0x2211], + [0x2215, 0x2215], [0x221a, 0x221a], [0x221d, 0x2220], [0x2223, 0x2223], [0x2225, 0x2225], + [0x2227, 0x222c], [0x222e, 0x222e], [0x2234, 0x2237], [0x223c, 0x223d], [0x2248, 0x2248], + [0x224c, 0x224c], [0x2252, 0x2252], [0x2260, 0x2261], [0x2264, 0x2267], [0x226a, 0x226b], + [0x226e, 0x226f], [0x2282, 0x2283], [0x2286, 0x2287], [0x2295, 0x2295], [0x2299, 0x2299], + [0x22a5, 0x22a5], [0x22bf, 0x22bf], [0x2312, 0x2312], [0x2460, 0x24e9], [0x24eb, 0x254b], + [0x2550, 0x2573], [0x2580, 0x258f], [0x2592, 0x2595], [0x25a0, 0x25a1], [0x25a3, 0x25a9], + [0x25b2, 0x25b3], [0x25b6, 0x25b7], [0x25bc, 0x25bd], [0x25c0, 0x25c1], [0x25c6, 0x25c8], + [0x25cb, 0x25cb], [0x25ce, 0x25d1], [0x25e2, 0x25e5], [0x25ef, 0x25ef], [0x2605, 0x2606], + [0x2609, 0x2609], [0x260e, 0x260f], [0x261c, 0x261c], [0x261e, 0x261e], [0x2640, 0x2640], + [0x2642, 0x2642], [0x2660, 0x2661], [0x2663, 0x2665], [0x2667, 0x266a], [0x266c, 0x266d], + [0x266f, 0x266f], [0x269e, 0x269f], [0x26bf, 0x26bf], [0x26c6, 0x26cd], [0x26cf, 0x26d3], + [0x26d5, 0x26e1], [0x26e3, 0x26e3], [0x26e8, 0x26e9], [0x26eb, 0x26f1], [0x26f4, 0x26f4], + [0x26f6, 0x26f9], [0x26fb, 0x26fc], [0x26fe, 0x26ff], [0x273d, 0x273d], [0x2776, 0x277f], + [0x2b56, 0x2b59], [0x3248, 0x324f], [0xe000, 0xf8ff], [0xfe00, 0xfe0f], [0xfffd, 0xfffd], + [0x1f100, 0x1f10a], [0x1f110, 0x1f12d], [0x1f130, 0x1f169], [0x1f170, 0x1f18d], + [0x1f18f, 0x1f190], [0x1f19b, 0x1f1ac], [0xe0100, 0xe01ef], [0xf0000, 0xffffd], + [0x100000, 0x10fffd], +]; + +function isAmbiguous(cp: number): boolean { + for (const [lo, hi] of AMBIGUOUS_RANGES) { + if (cp >= lo && cp <= hi) return true; + if (cp < lo) break; + } + return false; +} + +/** + * Columns if every Ambiguous character is wide. Footer rows are fitted to this + * so a CJK terminal cannot wrap a row the narrow count thought was one line. + */ +export function boundaryWidth(s: string): number { + let w = 0; + for (const ch of s) { + const cp = ch.codePointAt(0)!; + if (cp < 0x20 || cp === 0x7f) continue; + if (isZeroWidth(cp)) continue; + w += isWide(cp) || isAmbiguous(cp) ? 2 : 1; + } + return w; +} + +/** Replace tabs with spaces at eight-column stops under the boundary width. */ +export function expandTabsToBoundary(s: string): string { + let out = ""; + let column = 0; + for (const ch of s) { + if (ch === "\n") { + out += ch; + column = 0; + } else if (ch === "\t") { + const spaces = 8 - (column % 8); + out += " ".repeat(spaces); + column += spaces; + } else { + out += ch; + column += boundaryWidth(ch); + } + } + return out; +} + +/** Truncate to a boundary-width budget. Same rules as `truncateToWidth`. */ +export function truncateToBoundary(s: string, columns: number, ellipsis = "…"): string { + if (boundaryWidth(s) <= columns) return s; + const budget = columns - boundaryWidth(ellipsis); + if (budget <= 0) { + let out = ""; + let w = 0; + for (const ch of s) { + const cw = boundaryWidth(ch); + if (w + cw > columns) break; + out += ch; + w += cw; + } + return out; + } + let out = ""; + let w = 0; + for (const ch of s) { + const cw = boundaryWidth(ch); + if (w + cw > budget) break; + out += ch; + w += cw; + } + return out + ellipsis; +} + /** * Truncate to a column budget, appending an ellipsis when anything was cut. * Never splits a code point, and never overshoots the budget. @@ -113,13 +228,13 @@ export function padToWidth(s: string, columns: number, fill = " "): string { * wrapped on its own is two rows the count does not know about — which is how * a long command left its first half behind on every repaint. */ -export function wrapToWidth(s: string, columns: number): string[] { +function wrapWith(s: string, columns: number, measure: (text: string) => number): string[] { const width = Math.max(1, columns); const rows: string[] = []; let row = ""; let used = 0; for (const ch of s) { - const w = displayWidth(ch); + const w = measure(ch); if (used + w > width && used > 0) { rows.push(row); row = ""; @@ -131,3 +246,12 @@ export function wrapToWidth(s: string, columns: number): string[] { rows.push(row); return rows; } + +export function wrapToWidth(s: string, columns: number): string[] { + return wrapWith(s, columns, displayWidth); +} + +/** Split into rows that also fit when Ambiguous characters are wide. */ +export function wrapToBoundary(s: string, columns: number): string[] { + return wrapWith(s, columns, boundaryWidth); +} diff --git a/packages/tui/test/screen.test.ts b/packages/tui/test/screen.test.ts new file mode 100644 index 0000000..f07a4a1 --- /dev/null +++ b/packages/tui/test/screen.test.ts @@ -0,0 +1,169 @@ +/** + * Footer paints that reach the terminal. + * + * Scrollback copies are writes, not render snapshots: a redraw that moves with + * LF, a tail taller than the screen, or a composer row wider than the terminal. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Screen } from "../src/screen.js"; +import { boundaryWidth, displayWidth } from "../src/width.js"; + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; + +function visible(chunk: string): string { + return chunk.replace(ANSI, "").replace(/\r/g, ""); +} + +function terminalBoundaryWidth(text: string): number { + let column = 0; + for (const ch of text) { + if (ch === "\t") column += 8 - (column % 8); + else column += boundaryWidth(ch); + } + return column; +} + +function screen(write: (s: string) => void, opts: { columns?: number; rows?: number } = {}): Screen { + return new Screen({ + write, + columns: () => opts.columns ?? 80, + rows: () => opts.rows ?? 24, + interactive: true, + now: () => 1_000, + }); +} + +describe("footer scrollback", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("redraws an existing footer without LF", () => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setComposer({ draft: { text: "", cursor: 0 } }); + out.length = 0; + + s.setHint("esc to interrupt"); + const painted = out.join(""); + expect(painted).toContain("esc to interrupt"); + expect(painted).not.toContain("\n"); + }); + + it("clips a tail taller than the viewport and commits it only when the turn settles", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns: 20, rows: 8 }); + s.setComposer({ draft: { text: "", cursor: 0 } }); + out.length = 0; + + const lines = Array.from({ length: 30 }, (_, i) => `L${String(i).padStart(2, "0")}`); + s.apply({ type: "stream", content: lines.join("\n") }); + vi.advanceTimersByTime(40); + + const live = out.join(""); + expect(live).toContain("L29"); + expect(live).not.toContain("L00"); + + out.length = 0; + s.apply({ type: "content_delta", text: lines.join("\n") }); + const settled = out.join(""); + expect(settled).toContain("L00"); + expect(settled).toContain("L29"); + }); + + it("replaces footer rows a settled line just consumed", () => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns: 40, rows: 8 }); + s.setComposer({ draft: { text: "", cursor: 0 } }); + out.length = 0; + + s.append({ kind: "user", text: "hello" }); + // `> hello` and the blank under it take two physical rows out of the + // reservation. A same-height footer must allocate those two back. Trusting + // the old count emits only the transcript newlines. + expect(out).toContain("\n\n"); + }); + + it("counts a wrapped settled line as more than one reserved row", () => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns: 10, rows: 12 }); + s.setComposer({ draft: { text: "", cursor: 0 } }); + out.length = 0; + + s.append({ kind: "user", text: "abcdefghij" }); + // `> abcdefghij` wraps to two rows at 10 columns, plus the blank line. + // Subtracting the two writes instead of the three physical rows leaves a + // growth of two newlines. + expect(out).toContain("\n\n\n"); + }); + + it("reserves rows for settled text when Ambiguous characters are wide", () => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns: 10, rows: 12 }); + s.setComposer({ draft: { text: "", cursor: 0 } }); + out.length = 0; + + s.append({ kind: "user", text: "········" }); + + const transcript = out.filter((chunk) => chunk.includes("·")).map(visible); + expect(transcript.join("").replace(/\n/g, "")).toBe("> ········"); + // The text takes two rows when Ambiguous is wide, and the blank after the + // user cell takes a third, so a same-height footer allocates three back. + expect(out).toContain("\n\n\n"); + }); + + it("keeps a padded status hint within the boundary width", () => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns: 60, rows: 24 }); + s.setLabel("motif"); + s.apply({ + type: "usage", + contextTokens: 12000, + kvBytes: 1024, + requestMs: 1000, + promptTokens: 12000, + cachedTokens: 8000, + }); + out.length = 0; + s.setComposer({ draft: { text: "", cursor: 0 } }); + + const painted = out.join(""); + expect(painted).toContain("ctx"); + expect(painted).not.toContain("\u00b7"); + for (const chunk of out) { + const text = visible(chunk); + if (text === "") continue; + expect(boundaryWidth(text)).toBeLessThanOrEqual(60); + } + }); + + it.each([1, 7, 80])("keeps every composer row within %i columns", (columns) => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns, rows: 24 }); + s.setComposer({ draft: { text: "hello", cursor: 5 } }); + + for (const chunk of out) { + const text = visible(chunk); + if (text === "") continue; + expect(displayWidth(text)).toBeLessThanOrEqual(columns); + expect(text).not.toMatch(/[\u2500-\u257F]/); + } + }); + + it("expands pasted tabs before writing composer rows", () => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk), { columns: 10, rows: 24 }); + s.setComposer({ draft: { text: "\t12345", cursor: 6 } }); + + for (const chunk of out) { + const text = visible(chunk); + if (text === "" || text.includes("\n")) continue; + expect(terminalBoundaryWidth(text)).toBeLessThanOrEqual(10); + } + const painted = visible(out.join("")); + expect(painted.replace(/\D/g, "")).toBe("12345"); + expect(painted).not.toContain("\t"); + }); +});