From 1d4a2271c854eaa3450446e38d6e1322d715687e Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Wed, 23 Sep 2026 00:11:10 +0900 Subject: [PATCH 1/2] fix(tui): keep live footer within terminal viewport --- README.md | 5 + package.json | 1 + packages/tui/package.json | 5 +- packages/tui/src/composer.ts | 15 +- packages/tui/src/screen.ts | 85 ++++++--- packages/tui/src/theme.ts | 2 + packages/tui/src/width.ts | 22 ++- packages/tui/test/composer.test.ts | 39 ++++- packages/tui/test/screen-viewport.test.ts | 204 ++++++++++++++++++++++ packages/tui/test/width.test.ts | 44 +++++ pnpm-lock.yaml | 17 ++ 11 files changed, 399 insertions(+), 40 deletions(-) create mode 100644 packages/tui/test/screen-viewport.test.ts create mode 100644 packages/tui/test/width.test.ts diff --git a/README.md b/README.md index 5a6d2ea..64b9c67 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,11 @@ Flags: `--model`, `--endpoint`, `--env-file`, `--theme`, `--thinking`, `--verbose`, `--permissions ask|auto`, `--cwd`, `--channel`, `--max-turns`, `--max-output-tokens`, `--seed`, `--no-hero`. `motif --help` has the full list. +If your terminal is configured to render East Asian Ambiguous characters as +two columns, launch with `MOTIF_AMBIGUOUS_WIDTH=2 motif` to match its cursor and +wrapping behavior. The default is one column. Set this in the shell environment; +it is a terminal preference, not an API configuration value in `.env`. + --- ## Get an API key from Infron diff --git a/package.json b/package.json index b9f22a9..c949b30 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "@xterm/headless": "6.0.0", "esbuild": "^0.28.2", "tsx": "^4.23.12", "typescript": "^5.9.3", diff --git a/packages/tui/package.json b/packages/tui/package.json index f8b3b0f..b50dd07 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -6,5 +6,8 @@ "description": "Terminal UI: typed cells, two-region streaming, local instruments", "main": "./src/index.ts", "exports": { ".": "./src/index.ts" }, - "dependencies": { "@motifcode/core": "workspace:*" } + "dependencies": { + "@motifcode/core": "workspace:*", + "get-east-asian-width": "1.7.0" + } } diff --git a/packages/tui/src/composer.ts b/packages/tui/src/composer.ts index a4fc51f..17c86cb 100644 --- a/packages/tui/src/composer.ts +++ b/packages/tui/src/composer.ts @@ -14,7 +14,7 @@ * is not text. */ -import { displayWidth } from "./width.js"; +import { displayWidth, truncateToWidth } from "./width.js"; export interface ComposerSnapshot { text: string; @@ -286,14 +286,16 @@ const DEFAULT_PROMPT = "❯ "; * the next typed character would land. */ export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOptions): ComposerRender { - const prompt = opts.prompt ?? DEFAULT_PROMPT; + const width = Math.max(1, opts.width); + // Leave an editable column even when the prompt is wider than the viewport. + const prompt = truncateToWidth(opts.prompt ?? DEFAULT_PROMPT, width - 1, ""); const prefixWidth = displayWidth(prompt); - const room = Math.max(1, opts.width - prefixWidth); + const room = width - prefixWidth; const continuation = " ".repeat(prefixWidth); if (state.text === "") { return { - rows: [{ prefix: prompt, body: opts.placeholder ?? "" }], + rows: [{ prefix: prompt, body: truncateToWidth(opts.placeholder ?? "", room) }], cursorRow: 0, cursorCol: prefixWidth, placeholder: true, @@ -323,14 +325,15 @@ export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOpti index += 1; continue; } - const w = displayWidth(ch); + const shown = displayWidth(ch) > room ? "?" : ch; + const w = displayWidth(shown); if (used + w > room && used > 0) flush(); if (index === state.cursor && used === 0) { // The cursor character itself wrapped; follow it. cursorRow = rows.length; cursorCol = prefixWidth; } - body += ch; + body += shown; used += w; index += 1; } diff --git a/packages/tui/src/screen.ts b/packages/tui/src/screen.ts index a96ac01..d4a51f4 100644 --- a/packages/tui/src/screen.ts +++ b/packages/tui/src/screen.ts @@ -24,7 +24,8 @@ * the terminal changes under it, and it has the cost it has there: Terminal * and iTerm keep the erased screen in scrollback, so each narrowing leaves a * copy behind. A widening costs nothing — no row that fitted before can wrap - * now — so it is an ordinary repaint. + * now — so it is an ordinary repaint unless the height also changes. Height + * changes rebuild too, because the footer is clipped to the viewport. * * The terminal's cursor is left inside the composer after each paint, so the * terminal's own input method composes where the text will land — Hangul @@ -142,6 +143,7 @@ export class Screen { private cursorAt: { row: number; col: number } | null = null; /** The width the footer was painted at, to notice a resize. */ private paintedWidth = 0; + private paintedHeight = 0; private composer: ComposerView | null = null; private hint = ""; private label = ""; @@ -328,7 +330,7 @@ export class Screen { if (this.resizeTimer) clearTimeout(this.resizeTimer); this.resizeTimer = setTimeout(() => { this.resizeTimer = null; - if (this.columns() < this.paintedWidth) this.repaintAll(); + if (this.columns() < this.paintedWidth || this.rowCount() !== this.paintedHeight) this.repaintAll(); else this.paint(); }, 40); }; @@ -449,9 +451,9 @@ export class Screen { } private paint(): void { - if (this.interactive && this.footer.length > 0 && this.columns() < this.paintedWidth) { - // Narrowed since the last paint, with or without a resize event: the - // rows on screen have been reflowed and cannot be counted over. + if (this.interactive && this.footer.length > 0 && + (this.columns() < this.paintedWidth || this.rowCount() !== this.paintedHeight)) { + // A resize can reflow or clip rows, invalidating their old positions. this.repaintAll(); return; } @@ -495,15 +497,18 @@ export class Screen { const width = this.columns(); const settled = this.settledLines(); const fresh = this.commits.take(settled.map((l) => l.text)); - void fresh; + const previous = settled.slice(0, settled.length - fresh.length); const footer = 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 })); + for (const l of previous) 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.write(term.clearScreen + term.home); for (const line of physical.slice(Math.max(0, physical.length - keep))) this.write(line + "\n"); + // Completion can arrive before the resize debounce. Commit every newly + // settled line, including the prefix previously clipped from the live view. + for (const line of settled.slice(previous.length)) this.write(this.colour(line) + "\n"); this.writeFooter(footer, width); } @@ -550,22 +555,37 @@ export class Screen { } else { rows.push(...this.rows(this.statusOnly(), (t) => t)); } - return { rows, cursor }; + // Leave one row below the footer for the parked cursor. Only the newest + // live rows are visible; omitted rows remain live and are never committed. + const limit = Math.max(0, this.rowCount() - 1); + let start = Math.max(0, rows.length - limit); + // A large draft/menu can itself exceed the viewport. Keep the editing + // cursor visible instead of showing only the end of the composer. + if (cursor && cursor.row < start) start = Math.max(0, cursor.row - Math.floor(limit / 2)); + return { + rows: rows.slice(start, start + limit), + cursor: cursor && cursor.row >= start && cursor.row < start + limit + ? { row: cursor.row - start, col: cursor.col } : null, + }; } private writeFooter(footer: { rows: Row[]; cursor: { row: number; col: number } | null }, width: number): void { const { rows, cursor } = footer; if (this.composer) this.write(term.hideCursor); - for (const r of rows) this.write(r.text + "\n"); + // Reserve room while it contains only cleared/settled content. Advancing + // after drawing a live row could scroll that row into permanent history. + this.write(term.lineStart + term.index.repeat(rows.length) + term.up(rows.length)); + for (const r of rows) this.write(r.text + term.down(1) + term.lineStart); this.footer = rows; this.paintedWidth = width; + this.paintedHeight = this.rowCount(); 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); - } + } else if (rows.length === 0) this.write(term.showCursor); } /** @@ -575,7 +595,15 @@ export class Screen { * and nothing wraps. Returns where the cursor belongs among the rows. */ private composerRows(view: ComposerView, width: number): { rows: Row[]; cursorRow: number; cursorCol: number } { - const inner = Math.max(4, width - 4); + const boxed = width >= 8; + const inner = boxed ? width - 4 : Math.max(1, width); + // Box drawing is Ambiguous-width in Unicode. ASCII decorations preserve + // the four-column frame on terminals configured to render it wide. + const wideBox = displayWidth("─") > 1; + const side = wideBox ? "|" : "│"; + const left = boxed ? `${side} ` : ""; + const right = boxed ? ` ${side}` : ""; + const edge = boxed ? 2 : 0; // 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 } @@ -586,10 +614,10 @@ export class Screen { ...(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(`${wideBox ? "+" : l}${(wideBox ? "-" : "─").repeat(inner + 2)}${wideBox ? "+" : r}`, style.faint), width: inner + 4, }); - const rows: Row[] = [border("╭", "╮")]; + const rows: Row[] = boxed ? [border("╭", "╮")] : []; if (view.confirm) { const fit = (s: string): string => { const t = truncateToWidth(s, inner); @@ -606,12 +634,12 @@ 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(left, style.faint)}${painted}${paint(right, style.faint)}`, width }); } - rows.push(border("╰", "╯")); + if (boxed) 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 }; + return { rows, cursorRow: Number(boxed) + firstChoice + Math.max(0, selectedLine), cursorCol: edge }; } let header = 0; if (view.secret) { @@ -626,7 +654,7 @@ 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(left, style.faint)}${painted}${paint(right, style.faint)}`, width }); } header = body.length; } @@ -635,16 +663,21 @@ export class Screen { const plainWidth = displayWidth(row.prefix) + displayWidth(row.body); const pad = " ".repeat(Math.max(0, inner - plainWidth)); rows.push({ - text: `${paint("│ ", style.faint)}${paint(row.prefix, style.accent)}${body}${pad}${paint(" │", style.faint)}`, - width: inner + 4, + text: `${paint(left, style.faint)}${paint(row.prefix, style.accent)}${body}${pad}${paint(right, style.faint)}`, + width, }); } // +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("╰", "╯")); + const cursorRow = Number(boxed) + header + render.cursorRow; + const cursorCol = Math.min(edge + render.cursorCol, Math.max(0, width - 1)); + if (boxed) 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 } : {}) }); + // Reserve the composer, hint and parked cursor before sizing the menu. + // Its own window follows the selection even on a short terminal. + const maxRows = Math.max(0, Math.min(8, this.rowCount() - rows.length - 2)); + const menu = maxRows > 0 + ? renderMenu(view.menu.items, view.menu.selected, { width, maxRows, ...(view.menu.prefix ? { prefix: view.menu.prefix } : {}) }) + : { rows: [], selectedRow: -1 }; menu.rows.forEach((row, i) => { const t = truncateToWidth(row, width); rows.push({ @@ -659,7 +692,7 @@ export class Screen { /** * The line under the prompt: a hint on the left, the session's numbers on * the right. The right side goes first when the two do not fit, and the - * hint is never truncated — "esc cl…" tells nobody what Esc does. + * hint is clipped only when it cannot fit by itself. */ private hintRows(width: number): Row[] { const left = this.hint === "" ? "? for shortcuts" : this.hint; @@ -673,7 +706,7 @@ export class Screen { ] .filter(Boolean) .join(paint(" · ", style.dim)); - const leftText = ` ${truncateToWidth(left, Math.max(1, width - 2))}`; + const leftText = truncateToWidth(` ${left}`, width); const gap = width - displayWidth(leftText) - displayWidth(rightPlain); if (rightPlain === "" || gap < 2) return [{ text: paint(leftText, style.faint), width: displayWidth(leftText) }]; return [{ text: `${paint(leftText, style.faint)}${" ".repeat(gap)}${rightPainted}`, width }]; @@ -709,7 +742,7 @@ 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)); this.footer = []; diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index 4ad9bc9..98530f1 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -101,6 +101,8 @@ export const term = { /** Top-left of the visible screen. */ home: `${ESC}H`, lineStart: "\r", + /** Reserve a blank row before painting live content; may scroll at the bottom. */ + index: "\x1bD", up: (n: number) => (n > 0 ? `${ESC}${n}A` : ""), down: (n: number) => (n > 0 ? `${ESC}${n}B` : ""), /** Absolute column, 1-based in the terminal; this takes 0-based. */ diff --git a/packages/tui/src/width.ts b/packages/tui/src/width.ts index fb8b945..c196ed7 100644 --- a/packages/tui/src/width.ts +++ b/packages/tui/src/width.ts @@ -13,7 +13,9 @@ * the model will get blamed for. */ -/** Ranges from Unicode's East_Asian_Width = W or F. Coarse, and sufficient. */ +import { eastAsianWidth } from "get-east-asian-width"; + +/** Preserve the existing terminal/emoji widths alongside current Unicode data. */ const WIDE_RANGES: [number, number][] = [ [0x1100, 0x115f], // Hangul Jamo [0x2e80, 0x303e], // CJK radicals, Kangxi @@ -58,7 +60,8 @@ export function displayWidth(s: string): number { const cp = ch.codePointAt(0)!; if (cp < 0x20 || cp === 0x7f) continue; // control characters print nothing if (isZeroWidth(cp)) continue; - w += isWide(cp) ? 2 : 1; + // Terminal width preferences cannot be inferred reliably from locale. + w += isWide(cp) ? 2 : eastAsianWidth(cp, { ambiguousAsWide: process.env["MOTIF_AMBIGUOUS_WIDTH"] === "2" }); } return w; } @@ -68,9 +71,11 @@ export function displayWidth(s: string): number { * Never splits a code point, and never overshoots the budget. */ export function truncateToWidth(s: string, columns: number, ellipsis = "…"): string { + if (columns <= 0) return ""; if (displayWidth(s) <= columns) return s; + if (displayWidth(ellipsis) > columns) return truncateToWidth(ellipsis, columns, ""); const budget = columns - displayWidth(ellipsis); - if (budget <= 0) return ellipsis.slice(0, Math.max(0, columns)); + if (budget <= 0) return ellipsis; let out = ""; let w = 0; for (const ch of s) { @@ -84,9 +89,11 @@ export function truncateToWidth(s: string, columns: number, ellipsis = "…"): s /** Keep the tail rather than the head — for a live ticker of the newest text. */ export function truncateEndToWidth(s: string, columns: number, ellipsis = "…"): string { + if (columns <= 0) return ""; if (displayWidth(s) <= columns) return s; + if (displayWidth(ellipsis) > columns) return truncateToWidth(ellipsis, columns, ""); const budget = columns - displayWidth(ellipsis); - if (budget <= 0) return ellipsis.slice(0, Math.max(0, columns)); + if (budget <= 0) return ellipsis; const chars = [...s]; let out = ""; let w = 0; @@ -119,13 +126,16 @@ export function wrapToWidth(s: string, columns: number): string[] { let row = ""; let used = 0; for (const ch of s) { - const w = displayWidth(ch); + // A two-column glyph cannot fit a one-column terminal. Keep the source + // intact and use an ASCII placeholder only in this displayed projection. + const shown = displayWidth(ch) > width ? "?" : ch; + const w = displayWidth(shown); if (used + w > width && used > 0) { rows.push(row); row = ""; used = 0; } - row += ch; + row += shown; used += w; } rows.push(row); diff --git a/packages/tui/test/composer.test.ts b/packages/tui/test/composer.test.ts index e85769c..5cf68ac 100644 --- a/packages/tui/test/composer.test.ts +++ b/packages/tui/test/composer.test.ts @@ -5,7 +5,7 @@ * matter are the ones where those differ. */ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Composer, renderComposer } from "../src/composer.js"; describe("editing", () => { @@ -146,6 +146,43 @@ describe("rendering", () => { }); }); +describe("narrow composer rendering", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("fits a long placeholder after a clipped prompt", () => { + const rendered = renderComposer({ text: "", cursor: 0 }, { width: 3, prompt: "API key > ", placeholder: "type here" }); + expect(rendered.rows).toEqual([{ prefix: "AP", body: "…" }]); + expect(rendered).toMatchObject({ cursorRow: 0, cursorCol: 2 }); + }); + + it("projects a wide glyph in a one-column viewport without changing the draft", () => { + const c = new Composer(); + c.insert("한a"); + c.left(); + const rendered = renderComposer(c.snapshot(), { width: 1 }); + expect(rendered.rows).toEqual([{ prefix: "", body: "?" }, { prefix: "", body: "a" }]); + expect(rendered).toMatchObject({ cursorRow: 1, cursorCol: 0 }); + expect(c.submit()).toBe("한a"); + }); + + it("locates the caret after an ambiguous glyph under either terminal policy", () => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "1"); + expect(renderComposer({ text: "Ωa", cursor: 1 }, { width: 4, prompt: "> " })) + .toMatchObject({ rows: [{ prefix: "> ", body: "Ωa" }], cursorRow: 0, cursorCol: 3 }); + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "2"); + expect(renderComposer({ text: "Ωa", cursor: 1 }, { width: 4, prompt: "> " })) + .toMatchObject({ rows: [{ prefix: "> ", body: "Ω" }, { prefix: " ", body: "a" }], cursorRow: 1, cursorCol: 2 }); + }); + + it("wraps a rocket emoji and keeps its editing cursor on the physical row", () => { + const rendered = renderComposer({ text: "ab🚀x", cursor: 2 }, { width: 5, prompt: "> " }); + expect(rendered.rows).toEqual([{ prefix: "> ", body: "ab" }, { prefix: " ", body: "🚀x" }]); + expect(rendered).toMatchObject({ cursorRow: 1, cursorCol: 2 }); + expect(renderComposer({ text: "🚀", cursor: 1 }, { width: 1 })) + .toMatchObject({ rows: [{ prefix: "", body: "?" }, { prefix: "", body: "" }], cursorRow: 1, cursorCol: 0 }); + }); +}); + describe("pasted prompt history", () => { const block = Array.from({ length: 9 }, (_, i) => `line ${i}`).join("\n"); diff --git a/packages/tui/test/screen-viewport.test.ts b/packages/tui/test/screen-viewport.test.ts new file mode 100644 index 0000000..f21386b --- /dev/null +++ b/packages/tui/test/screen-viewport.test.ts @@ -0,0 +1,204 @@ +import xterm from "@xterm/headless"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Screen, type ComposerView } from "../src/screen.js"; + +const { Terminal } = xterm; +type TerminalInstance = InstanceType; +const write = (terminal: TerminalInstance, text: string): Promise => new Promise((resolve) => terminal.write(text, resolve)); +const lines = (terminal: TerminalInstance, end = terminal.buffer.active.length): string[] => + Array.from({ length: end }, (_, i) => terminal.buffer.active.getLine(i)!.translateToString(true)); + +function recording(cols: number, rows: number, run: (screen: Screen, frame: () => void) => void): string[] { + const chunks: string[] = []; + const frames: string[] = []; + const screen = new Screen({ write: (text) => chunks.push(text), columns: () => cols, rows: () => rows, interactive: true }); + vi.useFakeTimers(); + try { + run(screen, () => { frames.push(chunks.join("")); chunks.length = 0; }); + } finally { + screen.finish(); + vi.useRealTimers(); + } + return frames; +} + +// An independent terminal policy for these fixtures: ASCII=1, the tested +// Ambiguous glyphs=2, Hangul=2. Do not measure with the renderer under test. +function wideAmbiguous(terminal: TerminalInstance): void { + const ambiguous = new Set([..."Ωα·…─│╭╮╰╯•"].map((ch) => ch.codePointAt(0)!)); + terminal.unicode.register({ + version: "fixture-wide", + wcwidth: (cp) => cp < 32 ? 0 : ambiguous.has(cp) || (cp >= 0xac00 && cp <= 0xd7a3) ? 2 : 1, + charProperties(cp) { return this.wcwidth(cp) << 1; }, + }); + terminal.unicode.activeVersion = "fixture-wide"; +} + +afterEach(() => { vi.unstubAllEnvs(); vi.useRealTimers(); }); + +describe("physical footer viewport", () => { + it("keeps repeated live frames out of scrollback and commits the answer once", async () => { + const answer = Array.from({ length: 45 }, (_, i) => `LIVE_${String(i).padStart(2, "0")}`).join("\n"); + const frames = recording(40, 8, (screen, frame) => { + screen.setComposer({ draft: { text: "", cursor: 0 } }); + screen.append({ kind: "user", text: "SETTLED_USER" }); + frame(); + for (const line of answer.split("\n")) { + screen.apply({ type: "stream", content: line + "\n" }); + vi.advanceTimersByTime(40); + frame(); + } + screen.apply({ type: "content_delta", text: answer }); + frame(); + }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols: 40, rows: 8, scrollback: 2000 }); + try { + for (const frame of frames.slice(0, -1)) { + await write(terminal, frame); + const scrollback = lines(terminal, terminal.buffer.active.baseY).join("\n"); + expect(scrollback).not.toMatch(/LIVE_|╭|╰|for shortcuts/); + } + expect(lines(terminal).join("\n")).toContain("LIVE_44"); + expect(lines(terminal).join("\n")).not.toContain("LIVE_00"); + await write(terminal, frames.at(-1)!); + const all = lines(terminal).join("\n"); + for (const line of answer.split("\n")) expect(all.split(line), line).toHaveLength(2); + expect(all.split("SETTLED_USER")).toHaveLength(2); + } finally { terminal.dispose(); } + }); + + it.each([["height", 40, 12], ["narrowing", 20, 8]] as const)( + "commits the entire answer when completion coincides with %s resize", + async (_kind, nextCols, nextRows) => { + let cols = 40; + let rows = 8; + const chunks: string[] = []; + const screen = new Screen({ write: (text) => chunks.push(text), columns: () => cols, rows: () => rows, interactive: true }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols, rows, scrollback: 2000 }); + const answer = Array.from({ length: 45 }, (_, i) => `RESIZE_${String(i).padStart(2, "0")}`).join("\n"); + try { + vi.useFakeTimers(); + screen.setComposer({ draft: { text: "", cursor: 0 } }); + screen.apply({ type: "stream", content: answer }); + vi.advanceTimersByTime(40); + vi.useRealTimers(); + await write(terminal, chunks.splice(0).join("")); + + cols = nextCols; + rows = nextRows; + terminal.resize(cols, rows); + // Completion arrives before the debounced resize repaint. Its settled + // prefix has never been displayed and must not be silently committed. + screen.apply({ type: "content_delta", text: answer }); + await write(terminal, chunks.splice(0).join("")); + const all = lines(terminal).join("\n"); + for (const line of answer.split("\n")) expect(all.split(line), line).toHaveLength(2); + expect(terminal.buffer.active.cursorX).toBeLessThan(cols); + expect(terminal.buffer.active.cursorY).toBeLessThan(rows); + } finally { screen.finish(); terminal.dispose(); } + }, + ); + + it("uses no LF to repaint a footer even when the terminal starts at the bottom", async () => { + const frames = recording(40, 8, (screen, frame) => { + screen.setComposer({ draft: { text: "draft", cursor: 3 } }); + frame(); + screen.setHint("changed hint"); + frame(); + }); + expect(frames.join("")).not.toContain("\n"); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols: 40, rows: 8 }); + try { + await write(terminal, "\x1b[8;1H"); + for (const frame of frames) await write(terminal, frame); + expect(lines(terminal, terminal.buffer.active.baseY).join("\n")).not.toContain("draft"); + expect(lines(terminal).join("\n")).toContain("changed hint"); + expect(terminal.buffer.active.cursorX).toBe(7); + } finally { terminal.dispose(); } + }); + + it.each([1, 2, 3, 4, 7, 8, 20, 80])("fits composer rows in %i columns with Ambiguous width 2", async (cols) => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "2"); + const frames = recording(cols, 12, (screen, frame) => { + screen.setComposer({ draft: { text: "", cursor: 0 }, placeholder: "placeholder too long Ω·" }); + frame(); + screen.setComposer({ draft: { text: "Ω·한", cursor: 1 } }); + frame(); + screen.setHint("ambiguous Ω· hint"); + frame(); + }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols, rows: 12 }); + wideAmbiguous(terminal); + try { + for (const frame of frames) { + await write(terminal, frame); + expect(terminal.buffer.active.baseY).toBe(0); + for (let i = 0; i < terminal.buffer.active.length; i++) { + expect(terminal.buffer.active.getLine(i)!.isWrapped).toBe(false); + } + expect(terminal.buffer.active.cursorX).toBeLessThan(cols); + } + } finally { terminal.dispose(); } + }); + + it.each(["draft", "confirmation", "secret"])("keeps the cursor in an oversized %s", async (kind) => { + const long = Array.from({ length: 30 }, (_, i) => `row ${i}`); + const view: ComposerView = { draft: { text: long.join("\n"), cursor: 2 } }; + if (kind === "confirmation") view.confirm = { title: "Approval", lines: long, choices: ["❯ Yes", "No"] }; + if (kind === "secret") view.secret = { title: "Login", lines: long, prompt: "key: " }; + const frames = recording(40, 6, (screen, frame) => { screen.setComposer(view); frame(); }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols: 40, rows: 6 }); + try { + await write(terminal, frames[0]!); + expect(terminal.buffer.active.baseY).toBe(0); + const cursorLine = terminal.buffer.active.getLine(terminal.buffer.active.cursorY)!.translateToString(true); + expect(cursorLine).toContain(kind === "confirmation" ? "Yes" : kind === "secret" ? "•" : "row 0"); + } finally { terminal.dispose(); } + }); + it("keeps the selected menu item visible in a short viewport", async () => { + const frames = recording(40, 8, (screen, frame) => { + screen.setComposer({ draft: { text: "/", cursor: 1 }, menu: { + items: Array.from({ length: 10 }, (_, i) => ({ name: `option${i}`, description: "command" })), selected: 7, + } }); + frame(); + }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols: 40, rows: 8 }); + try { + await write(terminal, frames[0]!); + expect(lines(terminal).join("\n")).toContain("❯ /option7"); + expect(terminal.buffer.active.baseY).toBe(0); + expect(terminal.buffer.active.getLine(terminal.buffer.active.cursorY)!.translateToString(true)).toContain("> /"); + } finally { terminal.dispose(); } + }); + + it("rebuilds after height changes and narrowing before the next repaint", async () => { + let cols = 40; + let rows = 12; + const chunks: string[] = []; + const screen = new Screen({ write: (text) => chunks.push(text), columns: () => cols, rows: () => rows, interactive: true }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols, rows }); + const flush = async (): Promise => { + const text = chunks.splice(0).join(""); + await write(terminal, text); + return text; + }; + try { + screen.setComposer({ draft: { text: "EDIT_HERE", cursor: 4 } }); + await flush(); + for (const [nextCols, nextRows] of [[40, 6], [40, 10], [7, 10], [40, 10]]) { + cols = nextCols!; rows = nextRows!; + terminal.resize(cols, rows); + screen.setHint(`size ${cols}x${rows}`); + const frame = await flush(); + if (cols === 7 || rows !== 10) expect(frame).toContain("\x1b[2J"); + expect(terminal.buffer.active.cursorX).toBeLessThan(cols); + expect(terminal.buffer.active.cursorY).toBeLessThan(rows); + const visible = lines(terminal).slice(terminal.buffer.active.baseY).join("\n"); + expect(visible).toContain(cols === 7 ? "EDIT_" : "EDIT_HERE"); + screen.setHint("repaint"); + expect(await flush()).not.toContain("\n"); + } + } finally { screen.finish(); terminal.dispose(); } + }); + +}); diff --git a/packages/tui/test/width.test.ts b/packages/tui/test/width.test.ts new file mode 100644 index 0000000..312948e --- /dev/null +++ b/packages/tui/test/width.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { displayWidth, truncateEndToWidth, truncateToWidth, wrapToWidth } from "../src/width.js"; + +afterEach(() => vi.unstubAllEnvs()); + +describe("terminal width policy", () => { + it.each(["1", "2"])("matches Ambiguous-width=%s without widening combining marks", (policy) => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", policy); + expect(displayWidth("·─…Ωé")).toBe(5 * Number(policy)); + expect(displayWidth("e\u0301")).toBe(1); + expect(displayWidth("한글🙂🚀🫠⌚A")).toBe(14); + expect(displayWidth("ascii")).toBe(5); + }); + + it("defaults to narrow ambiguous characters", () => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", undefined); + expect(displayWidth("·─…Ωé")).toBe(5); + }); + + it("does not emit a two-column ellipsis into a one-column budget", () => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "2"); + for (const truncate of [truncateToWidth, truncateEndToWidth]) { + expect(truncate("abcdef", 0)).toBe(""); + expect(truncate("abcdef", 1)).toBe(""); + expect(truncate("abcdef", 2)).toBe("…"); + expect(truncate("abcdef", 1, ".")).toBe("."); + } + expect(truncateToWidth("abcdef", 4)).toBe("ab…"); + expect(truncateEndToWidth("abcdef", 4)).toBe("…ef"); + }); + + it("wraps ambiguous characters using the selected cell width", () => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "2"); + expect(wrapToWidth("aΩb·", 3)).toEqual(["aΩ", "b·"]); + expect(wrapToWidth("한Ωa", 1)).toEqual(["?", "?", "a"]); + }); + + it("wraps and truncates wide emoji outside the legacy ranges", () => { + vi.stubEnv("MOTIF_AMBIGUOUS_WIDTH", "1"); + expect(wrapToWidth("a🚀b🫠", 3)).toEqual(["a🚀", "b🫠"]); + expect(wrapToWidth("🚀", 1)).toEqual(["?"]); + expect(truncateToWidth("ab🚀", 3)).toBe("ab…"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe4da8b..44a5a1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/node': specifier: ^22.20.1 version: 22.20.1 + '@xterm/headless': + specifier: 6.0.0 + version: 6.0.0 esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -117,6 +120,9 @@ importers: '@motifcode/core': specifier: workspace:* version: link:../core + get-east-asian-width: + specifier: 1.7.0 + version: 1.7.0 packages: @@ -597,6 +603,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@xterm/headless@6.0.0': + resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -651,6 +660,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1069,6 +1082,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@xterm/headless@6.0.0': {} + assertion-error@2.0.1: {} cac@6.7.14: {} @@ -1155,6 +1170,8 @@ snapshots: fsevents@2.3.3: optional: true + get-east-asian-width@1.7.0: {} + loupe@3.2.1: {} magic-string@0.30.21: From fff3354cfca92e318ee62c04b1f30bc9ced1b946 Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Wed, 23 Sep 2026 00:20:53 +0900 Subject: [PATCH 2/2] fix(tui): expand pasted tabs within measured display rows --- packages/tui/src/composer.ts | 33 +++++++++++++---------- packages/tui/src/width.ts | 15 ++++++++++- packages/tui/test/composer.test.ts | 17 ++++++++++++ packages/tui/test/screen-viewport.test.ts | 21 +++++++++++++++ packages/tui/test/width.test.ts | 12 ++++++++- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/packages/tui/src/composer.ts b/packages/tui/src/composer.ts index 17c86cb..981f05b 100644 --- a/packages/tui/src/composer.ts +++ b/packages/tui/src/composer.ts @@ -14,7 +14,7 @@ * is not text. */ -import { displayWidth, truncateToWidth } from "./width.js"; +import { displayWidth, expandTabs, truncateToWidth } from "./width.js"; export interface ComposerSnapshot { text: string; @@ -316,25 +316,30 @@ export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOpti }; for (const ch of state.text) { - if (index === state.cursor) { - cursorRow = rows.length; - cursorCol = prefixWidth + used; - } if (ch === "\n") { + if (index === state.cursor) { + cursorRow = rows.length; + cursorCol = prefixWidth + used; + } flush(); index += 1; continue; } - const shown = displayWidth(ch) > room ? "?" : ch; - const w = displayWidth(shown); - if (used + w > room && used > 0) flush(); - if (index === state.cursor && used === 0) { - // The cursor character itself wrapped; follow it. - cursorRow = rows.length; - cursorCol = prefixWidth; + // A tab expands only in the display. Its spaces may span several rows, + // while it remains one editable code point in the original draft. + let first = true; + for (const part of expandTabs(ch)) { + const shown = displayWidth(part) > room ? "?" : part; + const w = displayWidth(shown); + if (used + w > room && used > 0) flush(); + if (index === state.cursor && first) { + cursorRow = rows.length; + cursorCol = prefixWidth + used; + } + body += shown; + used += w; + first = false; } - body += shown; - used += w; index += 1; } // The cursor after the last character. diff --git a/packages/tui/src/width.ts b/packages/tui/src/width.ts index c196ed7..94137ca 100644 --- a/packages/tui/src/width.ts +++ b/packages/tui/src/width.ts @@ -15,6 +15,13 @@ import { eastAsianWidth } from "get-east-asian-width"; +const TAB_SPACES = " "; + +/** Display tabs as four spaces, independent of the terminal's tab stops. */ +export function expandTabs(s: string): string { + return s.replace(/\t/g, TAB_SPACES); +} + /** Preserve the existing terminal/emoji widths alongside current Unicode data. */ const WIDE_RANGES: [number, number][] = [ [0x1100, 0x115f], // Hangul Jamo @@ -58,6 +65,7 @@ export function displayWidth(s: string): number { let w = 0; for (const ch of s) { const cp = ch.codePointAt(0)!; + if (ch === "\t") { w += TAB_SPACES.length; continue; } if (cp < 0x20 || cp === 0x7f) continue; // control characters print nothing if (isZeroWidth(cp)) continue; // Terminal width preferences cannot be inferred reliably from locale. @@ -71,6 +79,8 @@ export function displayWidth(s: string): number { * Never splits a code point, and never overshoots the budget. */ export function truncateToWidth(s: string, columns: number, ellipsis = "…"): string { + s = expandTabs(s); + ellipsis = expandTabs(ellipsis); if (columns <= 0) return ""; if (displayWidth(s) <= columns) return s; if (displayWidth(ellipsis) > columns) return truncateToWidth(ellipsis, columns, ""); @@ -89,6 +99,8 @@ export function truncateToWidth(s: string, columns: number, ellipsis = "…"): s /** Keep the tail rather than the head — for a live ticker of the newest text. */ export function truncateEndToWidth(s: string, columns: number, ellipsis = "…"): string { + s = expandTabs(s); + ellipsis = expandTabs(ellipsis); if (columns <= 0) return ""; if (displayWidth(s) <= columns) return s; if (displayWidth(ellipsis) > columns) return truncateToWidth(ellipsis, columns, ""); @@ -108,6 +120,7 @@ export function truncateEndToWidth(s: string, columns: number, ellipsis = "…") /** Pad on the right to a column budget. */ export function padToWidth(s: string, columns: number, fill = " "): string { + s = expandTabs(s); const gap = columns - displayWidth(s); return gap > 0 ? s + fill.repeat(gap) : s; } @@ -125,7 +138,7 @@ export function wrapToWidth(s: string, columns: number): string[] { const rows: string[] = []; let row = ""; let used = 0; - for (const ch of s) { + for (const ch of expandTabs(s)) { // A two-column glyph cannot fit a one-column terminal. Keep the source // intact and use an ASCII placeholder only in this displayed projection. const shown = displayWidth(ch) > width ? "?" : ch; diff --git a/packages/tui/test/composer.test.ts b/packages/tui/test/composer.test.ts index 5cf68ac..97c4cc3 100644 --- a/packages/tui/test/composer.test.ts +++ b/packages/tui/test/composer.test.ts @@ -181,6 +181,23 @@ describe("narrow composer rendering", () => { expect(renderComposer({ text: "🚀", cursor: 1 }, { width: 1 })) .toMatchObject({ rows: [{ prefix: "", body: "?" }, { prefix: "", body: "" }], cursorRow: 1, cursorCol: 0 }); }); + + it("expands a pasted tab across narrow rows without changing its source or cursor index", () => { + const c = new Composer(); + c.paste("a\tb"); + c.left(); + expect(c.cursor).toBe(2); + expect(renderComposer(c.snapshot(), { width: 1 })).toMatchObject({ + rows: ["a", " ", " ", " ", " ", "b"].map((body) => ({ prefix: "", body })), + cursorRow: 5, cursorCol: 0, + }); + c.left(); + expect(renderComposer(c.snapshot(), { width: 1 })).toMatchObject({ cursorRow: 1, cursorCol: 0 }); + c.right(); + c.right(); + expect(renderComposer(c.snapshot(), { width: 7 })).toMatchObject({ cursorRow: 1, cursorCol: 3 }); + expect(c.submit()).toBe("a\tb"); + }); }); describe("pasted prompt history", () => { diff --git a/packages/tui/test/screen-viewport.test.ts b/packages/tui/test/screen-viewport.test.ts index f21386b..af4b65c 100644 --- a/packages/tui/test/screen-viewport.test.ts +++ b/packages/tui/test/screen-viewport.test.ts @@ -155,6 +155,27 @@ describe("physical footer viewport", () => { expect(cursorLine).toContain(kind === "confirmation" ? "Yes" : kind === "secret" ? "•" : "row 0"); } finally { terminal.dispose(); } }); + + it.each([1, 2, 3, 7, 20, 80])("keeps pasted tabs and their caret within physical rows at %i columns", async (cols) => { + const frames = recording(cols, 12, (screen, frame) => { + screen.setComposer({ draft: { text: "a\tb", cursor: 2 } }); + frame(); + screen.setHint("repaint"); + frame(); + }); + const terminal = new Terminal({ convertEol: true, allowProposedApi: true, cols, rows: 12 }); + try { + for (const frame of frames) { + expect(frame).not.toContain("\t"); + await write(terminal, frame); + const buffer = terminal.buffer.active; + expect(buffer.baseY).toBe(0); + for (let i = 0; i < buffer.length; i++) expect(buffer.getLine(i)!.isWrapped).toBe(false); + expect(buffer.getLine(buffer.cursorY)!.getCell(buffer.cursorX)!.getChars()).toBe("b"); + } + } finally { terminal.dispose(); } + }); + it("keeps the selected menu item visible in a short viewport", async () => { const frames = recording(40, 8, (screen, frame) => { screen.setComposer({ draft: { text: "/", cursor: 1 }, menu: { diff --git a/packages/tui/test/width.test.ts b/packages/tui/test/width.test.ts index 312948e..399af25 100644 --- a/packages/tui/test/width.test.ts +++ b/packages/tui/test/width.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { displayWidth, truncateEndToWidth, truncateToWidth, wrapToWidth } from "../src/width.js"; +import { displayWidth, padToWidth, truncateEndToWidth, truncateToWidth, wrapToWidth } from "../src/width.js"; afterEach(() => vi.unstubAllEnvs()); @@ -41,4 +41,14 @@ describe("terminal width policy", () => { expect(wrapToWidth("🚀", 1)).toEqual(["?"]); expect(truncateToWidth("ab🚀", 3)).toBe("ab…"); }); + + it("measures and projects tabs consistently without emitting terminal tab movement", () => { + expect(displayWidth("a\tb")).toBe(6); + expect(wrapToWidth("a\tb", 3)).toEqual(["a ", " b"]); + expect(wrapToWidth("\t", 1)).toEqual([" ", " ", " ", " "]); + expect(truncateToWidth("a\tb", 6)).toBe("a b"); + expect(truncateToWidth("a\tb", 4)).toBe("a …"); + expect(truncateEndToWidth("a\tb", 4)).toBe("… b"); + expect(padToWidth("a\tb", 7)).toBe("a b "); + }); });