Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 69 additions & 24 deletions packages/tui/src/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -220,8 +223,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;
Expand Down Expand Up @@ -275,13 +278,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;
Expand All @@ -290,6 +295,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();
}
Expand Down Expand Up @@ -323,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);
};
Expand Down Expand Up @@ -381,7 +389,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();
}

Expand Down Expand Up @@ -427,15 +434,13 @@ export class Screen {
setCwd(cwd: string): void {
this.cwd = cwd;
this.commits.reset();
this.clearFooter();
this.paint();
}

/** Show tool output whole, or clipped to a few lines. Ctrl-O in the session. */
toggleVerbose(): void {
this.verbose = !this.verbose;
this.commits.reset();
this.clearFooter();
this.paint();
}

Expand All @@ -449,20 +454,57 @@ 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;
}
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);
}
}

/**
Expand Down Expand Up @@ -500,11 +542,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. */
Expand Down Expand Up @@ -559,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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/tui/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
151 changes: 151 additions & 0 deletions packages/tui/test/screen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* 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 { PassThrough } from "node:stream";
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.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[] = [];
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));
});
});
Loading