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"); + }); +});