Skip to content

Commit bcb1d96

Browse files
authored
fix(tui): erase viewport redraws without saving stale history (#309)
1 parent ed1f927 commit bcb1d96

5 files changed

Lines changed: 108 additions & 8 deletions

File tree

‎docs/tui-capabilities.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ screen, the renderer keeps native scrollback and the Composer position stable.
9090
Freed rows temporarily remain blank at the top of the active screen and subsequent
9191
output reuses them. This avoids resetting the host's scroll position when a turn
9292
finishes. Redundant resize notifications with unchanged dimensions do not rebuild
93-
history.
93+
history. Viewport-only redraws erase rows in place so terminals that save a cleared
94+
screen to scrollback, including Apple Terminal, do not retain the old Composer,
95+
status line or duplicate transcript rows.
9496

9597
When a change removes or replaces text already in scrollback, the renderer still
9698
reconstructs the current session to avoid stale or duplicate history. Real resizes

‎packages/tui/src/tui/engine/LOCAL_CHANGES.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,11 @@
163163
},
164164
{
165165
"path": "tui-main-screen.ts",
166-
"currentSha256": "73789fb955252262ad03907446e4f4a6052bfcb86bf2a4fdc77ddaa1dd7ee9ee",
167-
"changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038"],
166+
"currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7",
167+
"changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"],
168168
"upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91",
169169
"reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。",
170-
"behaviorImpact": "Regular updates preserve native scrollback. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored."
170+
"behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored."
171171
},
172172
{
173173
"path": "tui.ts",

‎packages/tui/src/tui/engine/LOCAL_CHANGES.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
146146
- Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode.
147147
- Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate.
148148
- Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink.
149+
150+
## L039: Erase regular viewport redraws in place
151+
152+
- Product contract: repainting the visible regular-mode screen must not append the previous transcript, Composer or status line to native history.
153+
- Minimal difference: viewport-only full redraws home the cursor, erase each screen row with EL 2 using cursor-down movement, and return home before painting. This avoids ED 2, which saves the old screen to scrollback in Apple Terminal. Full structural reconstruction still clears and rebuilds history.
154+
- Evidence: local-delta tests exercise xterm and a clear-to-scrollback host model, covering historical style changes, simultaneous growth, short-document shrink, subsequent differential output, host scrolling and resize preview/replay. Native Apple Terminal replay of synthetic renderer output reproduces duplicate rows before the fix and preserves the exact document afterward.
155+
- Boundary: native replay covers synthetic output, not every live-model interaction or other terminal emulator.
156+
- Removal condition: the selected Pi baseline supplies equivalent in-place viewport erasure without retaining stale rows in native history.

‎packages/tui/src/tui/engine/tui-main-screen.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
348348
output.append("\x1b[?2026h"); // Begin synchronized output
349349
if (clear) {
350350
output.append(this.deleteKittyImages(this.previousKittyImageIds));
351-
output.append(viewportOnly ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J");
351+
if (viewportOnly) {
352+
// ED 2 saves the old screen to scrollback in Apple Terminal. Erase
353+
// each row in place so old transcript/footer rows cannot survive there.
354+
output.append("\x1b[H");
355+
for (let row = 0; row < height; row++) {
356+
if (row > 0) output.append("\x1b[1B");
357+
output.append("\x1b[2K");
358+
}
359+
output.append("\x1b[H");
360+
} else {
361+
output.append("\x1b[2J\x1b[H\x1b[3J");
362+
}
352363
}
353364
for (let i = start; i < newLines.length; i++) {
354365
if (i > start) output.append("\r\n");

‎packages/tui/test/unit/tui-engine-local-deltas.test.ts‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ class RecordingVirtualTerminal extends VirtualTerminal {
3737
}
3838
}
3939

40+
// Apple Terminal preserves the old screen in scrollback on ED 2. Model that
41+
// behavior explicitly: xterm's default erase implementation does not expose it.
42+
class ClearToScrollbackTerminal extends RecordingVirtualTerminal {
43+
override write(data: string): void {
44+
super.write(data.replaceAll('\x1b[2J', `\x1b[${this.rows};1H${'\r\n'.repeat(this.rows)}\x1b[2J`));
45+
}
46+
}
47+
4048
class MutableLines implements Component {
4149
lines: string[] = [];
4250

@@ -48,6 +56,68 @@ class MutableLines implements Component {
4856
}
4957

5058
describe('MCode Pi Engine local deltas', () => {
59+
describe.each([
60+
['xterm', RecordingVirtualTerminal],
61+
['clear-to-scrollback host', ClearToScrollbackTerminal],
62+
] as const)('%s viewport repaint', (_name, Terminal) => {
63+
it.each([0, 30])('keeps unique history after a style update and %i new rows', async (growth) => {
64+
const terminal = new Terminal(60, 12);
65+
const tui = new TuiMainScreen(terminal);
66+
const component = new MutableLines();
67+
const answer = Array.from({ length: 40 }, (_, index) => `Answer ${index}`);
68+
component.lines = [...answer, `old composer${CURSOR_MARKER}`, 'running'];
69+
tui.addChild(component);
70+
tui.renderNow();
71+
await terminal.flush();
72+
terminal.scrollLines(-10);
73+
const before = terminal.getScrollPosition();
74+
terminal.takeWrites();
75+
76+
const more = Array.from({ length: growth }, (_, index) => `More ${index}`);
77+
component.lines = [...answer, ...more, `composer${CURSOR_MARKER}`, 'idle'];
78+
component.lines[0] = '\x1b[1mAnswer 0\x1b[0m';
79+
tui.renderNow();
80+
await terminal.flush();
81+
82+
expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'composer', 'idle']);
83+
expect(terminal.getScrollPosition().viewport).toBe(before.viewport);
84+
expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 10 });
85+
const writes = terminal.takeWrites();
86+
expect(writes).not.toContain('\x1b[2J');
87+
expect(writes).not.toContain('\x1b[3J');
88+
89+
// Subsequent differential output must still overwrite the current footer.
90+
component.lines.splice(-2, 0, 'Next response');
91+
tui.renderNow();
92+
await terminal.flush();
93+
expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'Next response', 'composer', 'idle']);
94+
terminal.scrollLines(1000);
95+
expect(terminal.getViewport().slice(-3)).toEqual(['Next response', 'composer', 'idle']);
96+
});
97+
98+
it('erases stale rows when a short document shrinks', async () => {
99+
const terminal = new Terminal(60, 12);
100+
const tui = new TuiMainScreen(terminal);
101+
tui.setClearOnShrink(true);
102+
const component = new MutableLines();
103+
component.lines = ['answer', 'activity 1', 'activity 2', `old composer${CURSOR_MARKER}`, 'running'];
104+
tui.addChild(component);
105+
tui.renderNow();
106+
await terminal.flush();
107+
terminal.takeWrites();
108+
109+
component.lines = ['answer', `composer${CURSOR_MARKER}`, 'idle'];
110+
tui.renderNow();
111+
await terminal.flush();
112+
113+
expect(terminal.getScrollBuffer()).toEqual(['answer', 'composer', 'idle', ...Array<string>(9).fill('')]);
114+
expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 });
115+
const writes = terminal.takeWrites();
116+
expect(writes).not.toContain('\x1b[2J');
117+
expect(writes).not.toContain('\x1b[3J');
118+
});
119+
});
120+
51121
it.each([1, 8, 30])('preserves a scrolled host viewport when %i visible activity rows settle', async (activityRows) => {
52122
const terminal = new RecordingVirtualTerminal(60, 44);
53123
const tui = new TuiMainScreen(terminal);
@@ -330,8 +400,11 @@ describe('MCode Pi Engine local deltas', () => {
330400
expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 });
331401
});
332402

333-
it('still previews the resized tail and restores ordered scrollback after resize settles', async () => {
334-
const terminal = new RecordingVirtualTerminal(67, 44);
403+
it.each([
404+
['xterm', RecordingVirtualTerminal],
405+
['clear-to-scrollback host', ClearToScrollbackTerminal],
406+
] as const)('previews the resized tail and restores ordered scrollback on %s', async (_name, Terminal) => {
407+
const terminal = new Terminal(67, 44);
335408
const tui = new TuiMainScreen(terminal);
336409
const component = new MutableLines();
337410
component.lines = [
@@ -348,7 +421,13 @@ describe('MCode Pi Engine local deltas', () => {
348421
terminal.resize(60, 30);
349422
tui.renderNow();
350423
await terminal.flush();
351-
expect(terminal.takeWrites()).not.toContain('\x1b[3J');
424+
const previewWrites = terminal.takeWrites();
425+
expect(previewWrites).not.toContain('\x1b[2J');
426+
expect(previewWrites).not.toContain('\x1b[3J');
427+
expect(terminal.getScrollBuffer()).toEqual([
428+
...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`),
429+
'composer',
430+
]);
352431
expect(terminal.getViewport()).toEqual([
353432
...Array.from({ length: 29 }, (_, index) => `Answer line ${index + 51}`),
354433
'composer',

0 commit comments

Comments
 (0)