From ccf79b45f622d438504e175cc59c4f74cc3427be Mon Sep 17 00:00:00 2001 From: Wook Song Date: Tue, 22 Sep 2026 23:07:19 +0900 Subject: [PATCH 1/2] fix(tui): skip unchanged footer redraws Hidden reasoning and repeated activity strings no longer clear and redraw an unchanged footer. Real footer updates, including transcript rebuild paths, are fully enclosed in synchronized output so terminals do not show a blank frame. Fixes #27 --- packages/tui/src/screen.ts | 74 +++++++++++++++------ packages/tui/src/theme.ts | 6 ++ packages/tui/test/screen.test.ts | 107 +++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 packages/tui/test/screen.test.ts diff --git a/packages/tui/src/screen.ts b/packages/tui/src/screen.ts index a96ac01..1d0de1c 100644 --- a/packages/tui/src/screen.ts +++ b/packages/tui/src/screen.ts @@ -220,8 +220,8 @@ export class Screen { apply(event: LoopEvent): void { this.state = reduce(this.state, event); if (event.type === "stream") { - // Tokens arrive faster than a footer is worth repainting; one paint per - // frame or so keeps the text moving without the flicker. + // Tokens arrive faster than a footer is worth repainting. The cap only + // limits the rate; an unchanged footer is not painted at all. if (this.streamTimer) return; this.streamTimer = setTimeout(() => { this.streamTimer = null; @@ -275,13 +275,15 @@ export class Screen { */ setActivity(text: string | null): void { if (text === null) { + if (this.activity === null && this.ticker === null) return; this.activity = null; if (this.ticker) { clearInterval(this.ticker); this.ticker = null; } } else { - if (this.activity?.text !== text) this.activity = { text, since: this.now() }; + const same = this.activity?.text === text; + if (!same) this.activity = { text, since: this.now() }; if (!this.ticker && this.interactive) { this.ticker = setInterval(() => { this.tick += 1; @@ -290,6 +292,9 @@ export class Screen { // A ticker must not hold the process open once the loop is done. this.ticker.unref?.(); } + // The spinner and the elapsed second are the ticker's to paint. Reporting + // the same activity again is not a visible change. + if (same) return; } this.paint(); } @@ -381,7 +386,6 @@ export class Screen { // Showing rewrites lines already committed to scrollback, so the stable // region has to be rebuilt rather than appended to. this.commits.reset(); - this.clearFooter(); this.paint(); } @@ -427,7 +431,6 @@ export class Screen { setCwd(cwd: string): void { this.cwd = cwd; this.commits.reset(); - this.clearFooter(); this.paint(); } @@ -435,7 +438,6 @@ export class Screen { toggleVerbose(): void { this.verbose = !this.verbose; this.commits.reset(); - this.clearFooter(); this.paint(); } @@ -455,14 +457,48 @@ export class Screen { this.repaintAll(); return; } - this.clearFooter(); - // Only settled cells go to scrollback. A tool cell between `tool_start` and - // `tool_end` is still growing, and committing it there would print a tool - // that appears to have produced nothing. + const width = this.columns(); + const next = this.interactive ? this.buildFooter(width) : null; 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"); - this.paintFooter(); + const pending = Math.max(0, settled.length - this.commits.count); + // Compare before clearing. A hidden reasoning token changes no row, and + // erasing the footer to draw it back is the flash. + if (pending === 0 && this.sameFooter(next, width)) return; + this.withSync(() => { + this.clearFooter(); + // Only settled cells go to scrollback. A tool cell between `tool_start` and + // `tool_end` is still growing, and committing it there would print a tool + // that appears to have produced nothing. + 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"); + if (next) this.writeFooter(next, width); + else this.paintFooter(); + }); + } + + /** True when the footer about to be drawn is the one already on screen. */ + private sameFooter(next: { rows: Row[]; cursor: { row: number; col: number } | null } | null, width: number): boolean { + if (!this.interactive || next === null) return true; + if (this.paintedWidth !== width || this.footer.length !== next.rows.length) return false; + if (this.cursorAt?.row !== next.cursor?.row || this.cursorAt?.col !== next.cursor?.col) return false; + return this.footer.every((row, i) => row.text === next.rows[i]!.text); + } + + /** + * One terminal update. Synchronized output is requested only while a footer + * is actually being replaced; a skipped paint writes neither sequence. + */ + private withSync(body: () => void): void { + if (!this.interactive) { + body(); + return; + } + this.write(term.beginSync); + try { + body(); + } finally { + this.write(term.endSync); + } } /** @@ -500,11 +536,13 @@ export class Screen { 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.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); + this.withSync(() => { + 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"); + this.writeFooter(footer, width); + }); } /** Footer rows from text that may be longer than the width: split here, never wrapped by the terminal. */ diff --git a/packages/tui/src/theme.ts b/packages/tui/src/theme.ts index 4ad9bc9..30b4e2a 100644 --- a/packages/tui/src/theme.ts +++ b/packages/tui/src/theme.ts @@ -105,4 +105,10 @@ export const term = { down: (n: number) => (n > 0 ? `${ESC}${n}B` : ""), /** Absolute column, 1-based in the terminal; this takes 0-based. */ column: (col: number) => `${ESC}${col + 1}G`, + /** + * Synchronized output (DEC 2026). The erase and the redraw are one update, + * so a terminal that understands it paints the result and not the blank. + */ + beginSync: `${ESC}?2026h`, + endSync: `${ESC}?2026l`, } as const; diff --git a/packages/tui/test/screen.test.ts b/packages/tui/test/screen.test.ts new file mode 100644 index 0000000..31e4ddc --- /dev/null +++ b/packages/tui/test/screen.test.ts @@ -0,0 +1,107 @@ +/** + * Footer paints that reach the terminal. + * + * The render snapshots cannot see this: a skipped paint is the absence of a + * write, and a torn erase is two writes that should have been one update. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Screen } from "../src/screen.js"; +import { term } from "../src/theme.js"; + +function screen(write: (s: string) => void): Screen { + return new Screen({ + write, + columns: () => 80, + rows: () => 24, + interactive: true, + now: () => 1_000, + }); +} + +describe("unchanged footer redraws", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("writes nothing for a reasoning-only stream when the footer is unchanged", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setComposer({ draft: { text: "", cursor: 0 } }); + s.setActivity("Thinking…"); + out.length = 0; + + s.apply({ type: "stream", reasoning: "hidden" }); + s.apply({ type: "stream", reasoning: " still hidden" }); + vi.advanceTimersByTime(50); + + expect(out).toEqual([]); + }); + + it("does not repaint when setActivity is repeated, but the one-second tick does", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setActivity("Thinking…"); + out.length = 0; + + s.setActivity("Thinking…"); + expect(out).toEqual([]); + + vi.advanceTimersByTime(1000); + const painted = out.join(""); + expect(painted.startsWith(term.beginSync)).toBe(true); + expect(painted.endsWith(term.endSync)).toBe(true); + expect(painted).toContain("Thinking…"); + }); + + it("brackets an actual footer change in one synchronized update", () => { + 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.startsWith(term.beginSync)).toBe(true); + expect(painted.endsWith(term.endSync)).toBe(true); + expect(painted.indexOf(term.beginSync)).toBeLessThan(painted.indexOf("esc to interrupt")); + expect(painted.indexOf("esc to interrupt")).toBeLessThan(painted.lastIndexOf(term.endSync)); + }); + + it("still paints a streamed reply, inside the same bracket", () => { + vi.useFakeTimers(); + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setComposer({ draft: { text: "", cursor: 0 } }); + out.length = 0; + + s.apply({ type: "stream", content: "visible reply" }); + vi.advanceTimersByTime(40); + + const painted = out.join(""); + expect(painted).toContain("visible reply"); + expect(painted.startsWith(term.beginSync)).toBe(true); + expect(painted.endsWith(term.endSync)).toBe(true); + }); + + it.each([ + ["toggleThinking", (s: Screen) => s.toggleThinking()], + ["setCwd", (s: Screen) => s.setCwd("/tmp/elsewhere")], + ["toggleVerbose", (s: Screen) => s.toggleVerbose()], + ] as const)("%s clears the footer inside the synchronized update", (_name, change) => { + const out: string[] = []; + const s = screen((chunk) => out.push(chunk)); + s.setComposer({ draft: { text: "", cursor: 0 } }); + s.append({ kind: "user", text: "hello" }); + out.length = 0; + + change(s); + const painted = out.join(""); + expect(painted.startsWith(term.beginSync)).toBe(true); + expect(painted.endsWith(term.endSync)).toBe(true); + expect(painted.indexOf(term.clearLine)).toBeGreaterThan(term.beginSync.length); + expect(painted.indexOf(term.clearLine)).toBeLessThan(painted.lastIndexOf(term.endSync)); + }); +}); From 481247c0ae78a0a8432287064953dbcefd47e5bc Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Tue, 22 Sep 2026 23:51:59 +0900 Subject: [PATCH 2/2] fix(tui): rebuild the footer after terminal height changes --- packages/tui/src/screen.ts | 19 +++++++++----- packages/tui/test/screen.test.ts | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/screen.ts b/packages/tui/src/screen.ts index 1d0de1c..424b0d0 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. A + * height change can clip rows below the composer cursor and needs a rebuild. * * 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,8 @@ 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; + /** A height change may clip the footer even when its contents are unchanged. */ + private paintedHeight = 0; private composer: ComposerView | null = null; private hint = ""; private label = ""; @@ -328,12 +331,12 @@ export class Screen { } }; // A drag sends a burst of resizes; one repaint at the end is enough. - // Narrower than the last paint means a rebuild; wider is a repaint. + // Narrower or a different height means a rebuild; wider is a repaint. const onResize = (): void => { 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); }; @@ -451,9 +454,12 @@ 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) + ) { + // Resized since the last paint, with or without a resize event: rows + // may have reflowed or been clipped, so their old positions are stale. this.repaintAll(); return; } @@ -597,6 +603,7 @@ export class Screen { for (const r of rows) this.write(r.text + "\n"); this.footer = rows; this.paintedWidth = width; + this.paintedHeight = this.rowCount(); this.cursorAt = cursor; if (cursor) { diff --git a/packages/tui/test/screen.test.ts b/packages/tui/test/screen.test.ts index 31e4ddc..60766e4 100644 --- a/packages/tui/test/screen.test.ts +++ b/packages/tui/test/screen.test.ts @@ -5,6 +5,7 @@ * write, and a torn erase is two writes that should have been one update. */ +import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Screen } from "../src/screen.js"; import { term } from "../src/theme.js"; @@ -39,6 +40,49 @@ describe("unchanged footer redraws", () => { expect(out).toEqual([]); }); + it.each(["resize event", "paint before resize event"])("rebuilds on height changes via %s", (trigger) => { + vi.useFakeTimers(); + let rows = 24; + const out: string[] = []; + const stdin = Object.assign(new PassThrough(), { isTTY: true, setRawMode: vi.fn() }); + const s = new Screen({ write: (chunk) => out.push(chunk), columns: () => 80, rows: () => rows, interactive: true }); + const draft = { text: "draft", cursor: 5 }; + try { + s.attachInput(stdin as unknown as NodeJS.ReadStream, () => {}); + s.setComposer({ draft }); + s.setHint("resize hint"); + s.append({ kind: "assistant", text: Array.from({ length: 45 }, (_, i) => `line ${i}`).join("\n") }); + + for (const height of [12, 24]) { + out.length = 0; + rows = height; + if (trigger === "resize event") process.stdout.emit("resize"); + else s.setComposer({ draft }); + vi.advanceTimersByTime(40); + + const painted = out.join(""); + expect(painted).toContain(term.clearScreen + term.home); + expect(painted).toContain("line 44"); + expect(painted).not.toContain("line 0\n"); + expect(painted).toContain("draft"); + expect(painted).toContain("╰"); + expect(painted).toContain("resize hint"); + expect(painted).toContain(term.showCursor); + expect(painted.startsWith(term.beginSync)).toBe(true); + expect(painted.endsWith(term.endSync)).toBe(true); + + out.length = 0; + process.stdout.emit("resize"); + s.apply({ type: "stream", reasoning: "hidden" }); + vi.advanceTimersByTime(40); + expect(out).toEqual([]); + } + } finally { + s.finish(); + stdin.destroy(); + } + }); + it("does not repaint when setActivity is repeated, but the one-second tick does", () => { vi.useFakeTimers(); const out: string[] = [];