Skip to content

Commit 8c6a8dd

Browse files
committed
fix(tui): isolate transient overlays from native scrollback
1 parent 1aeb4aa commit 8c6a8dd

7 files changed

Lines changed: 321 additions & 29 deletions

File tree

‎docs/tui-capabilities.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ redraw preserve pending output and native history. Explicit resume-start history
2020
clearing remains a separate startup policy. Native image history is retained until
2121
the terminal itself evicts it.
2222

23+
Transient overlays use an alternate buffer so opening, nesting or resizing panels
24+
cannot place menu rows in native history. Closing the last overlay restores the
25+
main buffer and reconciles background output; terminal shutdown also restores it.
26+
2327
## Output speed
2428

2529
The activity line and completed-turn summary show provider output tokens divided by

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
},
164164
{
165165
"path": "tui-main-screen.ts",
166-
"currentSha256": "ba062254468194ab92576dbdf342a96180e68c999a3763b206b49cef191ad523",
166+
"currentSha256": "0095b75384b9f99cc979e55c5ef40a94c58d79ea9e9394ad978ad61f0c2db3d8",
167167
"changeIds": [
168168
"L005",
169169
"L017",
@@ -179,7 +179,7 @@
179179
],
180180
"upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91",
181181
"reason": "Align bounded regular-mode projections with emitted row identities and preserve native history across folding, footer shrink, redraw and resize.",
182-
"behaviorImpact": "Native scrollback retains emitted snapshots and pre-launch shell history. Automatic folding and footer updates preserve scrolling; unmatched projections archive the visible body and start a labelled snapshot. Resize retains pending rows, forced redraw retains physical state, and historical image resources survive viewport repaints."
182+
"behaviorImpact": "Native scrollback retains emitted snapshots and pre-launch shell history. Automatic folding and footer updates preserve scrolling; unmatched projections archive the visible body and start a labelled snapshot. Resize retains pending rows, forced redraw retains physical state, and historical image resources survive viewport repaints. Transient overlays use a separate terminal buffer and return to main on close or stop, preserving main geometry across resize."
183183
},
184184
{
185185
"path": "tui.ts",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
211211
- Minimal difference: an optional `ScrollbackLayout` carries stable transcript row identities and a body/footer boundary through the public component API. MainScreen aligns a bounded logical cache at a shared nonblank row with unchanged text. Read groups identify individual member rows rather than the group's changing first cell. Later edits to already-emitted history do not replay it. Reclaimed rows remain between body and footer, preserving tables and the input cursor.
212212
- Reconstruction: an unmatched projection commits the old visible body, erases addressable rows in place, and starts a labelled snapshot. It never emits ED 3 or uses ED 2. Footer overflow retains its physical offset; newly appended body rows are emitted before replacing the footer. Historical Kitty resources are not freed by a viewport repaint.
213213
- Geometry: resize uses the hardware cursor's screen row to determine the retained origin. Safe anchors preserve every pending row, including a burst arriving during resize; an unalignable reflow starts a labelled snapshot. Forced redraw and regular/fullscreen state transfers retain physical coordinates and anchors.
214+
- Transient screens: visible overlays share an alternate buffer until the last one closes. Main-buffer anchors and cursor geometry are retained independently through every resize; stop restores the main buffer before returning terminal ownership. Panel rows never enter native history.
214215
- Evidence: engine and application regressions verify host scroll position, unique emitted rows, Todo completion, grouped reads and long-turn/turn-window eviction, contiguous tables, transient panels, cursor placement, bounded caches, oversized footers, resize bursts, image-resource lifetime, forced refresh, and mode-state restore under xterm and an ED 2 clear-to-scrollback model.
215216
- Boundary: old native rows retain their historical text and wrapping. Explicit resume-start clearing is unchanged. Native remote-host and cross-platform acceptance remain separate from local emulator verification.
216217
- Removal condition: the selected upstream engine supports immutable native output with bounded anchored live projections.

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

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,17 @@ export class TuiMainScreen extends TuiBase implements TUI {
136136
private hadOverlays = false;
137137
private scrollbackLayout: ScrollbackLayout | undefined;
138138
private segmentHeader = false;
139+
private overlayScreenActive = false;
140+
private overlayScreenWidth = 0;
141+
private overlayMainReflowed = false;
142+
private renderingMainOnStop = false;
139143

140144
protected override onTerminalResize(): void {
145+
if (this.overlayScreenActive) {
146+
this.updateOverlayMainGeometry();
147+
this.requestRender();
148+
return;
149+
}
141150
// Some hosts repeat resize notifications while scrolling or reconnecting.
142151
// An unchanged geometry must not clear and replay native scrollback.
143152
if (this.previousWidth === this.terminal.columns && this.previousHeight === this.terminal.rows) return;
@@ -159,14 +168,17 @@ export class TuiMainScreen extends TuiBase implements TUI {
159168
}
160169

161170
override stop(options: TuiStopOptions = {}): void {
162-
// Flush pending output before handing the main screen back to its host.
163-
this.cancelResize();
164-
// Ordinary stop must retain the latest transcript even when output is held.
165-
// Mode switches already captured the current render state before stop.
166-
if (!this.stopped && (this.resizePending || (!options.preserveScreen && this.hasPendingRender()))) {
167-
this.doRender();
171+
this.renderingMainOnStop = true;
172+
try {
173+
// Flush the main document and exit 1049 before returning terminal ownership.
174+
this.cancelResize();
175+
if (!this.stopped && (this.overlayScreenActive || this.resizePending || (!options.preserveScreen && this.hasPendingRender()))) {
176+
this.doRender();
177+
}
178+
super.stop(options);
179+
} finally {
180+
this.renderingMainOnStop = false;
168181
}
169-
super.stop(options);
170182
}
171183

172184
captureRenderState(): TuiMainScreenRenderState {
@@ -292,8 +304,66 @@ export class TuiMainScreen extends TuiBase implements TUI {
292304
return this.deleteKittyImages(ids);
293305
}
294306

307+
private renderOverlayScreen(): void {
308+
const width = this.terminal.columns;
309+
const height = this.terminal.rows;
310+
if (!this.overlayScreenActive) {
311+
this.terminal.write("\x1b[?1049h");
312+
this.overlayScreenActive = true;
313+
this.overlayScreenWidth = this.previousWidth > 0 ? this.previousWidth : width;
314+
}
315+
this.updateOverlayMainGeometry();
316+
const background = this.render(width).slice(-height).map((line) => isImageLine(line) ? "" : line.replace(OSC133_ZONE_PREFIX, ""));
317+
const lines = this.compositeOverlays(background, width, height).slice(-height);
318+
const cursor = this.extractCursorPosition(lines, height);
319+
this.applyLineResets(lines);
320+
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
321+
output.append("\x1b[?2026h");
322+
for (let row = 0; row < height; row++) {
323+
output.append(`\x1b[${row + 1};1H\x1b[2K${lines[row] ?? ""}`);
324+
}
325+
if (cursor) output.append(`\x1b[${cursor.row + 1};${cursor.col + 1}H`);
326+
output.append(cursor && this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l");
327+
output.append("\x1b[?2026l");
328+
output.flush();
329+
}
330+
331+
private updateOverlayMainGeometry(): void {
332+
const width = this.terminal.columns;
333+
const height = this.terminal.rows;
334+
// The inactive main buffer still resizes. Track its cursor-based origin
335+
// independently of the cursor used by the transient overlay screen.
336+
const oldCursor = Math.max(0, this.hardwareCursorRow - this.previousViewportTop);
337+
if (this.previousHeight > 0 && height !== this.previousHeight) {
338+
if (height < this.previousHeight) this.previousViewportTop += Math.max(0, oldCursor - height + 1);
339+
else if (oldCursor === this.previousHeight - 1) {
340+
this.previousViewportTop = Math.max(0, this.previousViewportTop - (height - this.previousHeight));
341+
}
342+
}
343+
if (this.overlayScreenWidth !== width && this.previousLines.some((line) =>
344+
!isImageLine(line) && visibleWidth(line) > Math.min(width, this.overlayScreenWidth))) {
345+
this.overlayMainReflowed = true;
346+
}
347+
this.overlayScreenWidth = width;
348+
this.previousHeight = height;
349+
}
350+
295351
protected doRender(): void {
296352
if (this.stopped) return;
353+
// Transient screens must never enter native history, even when the host
354+
// shrinks before delivering its resize notification. Keep them on 1049.
355+
if (!this.renderingMainOnStop && this.hasOverlay()) {
356+
this.renderOverlayScreen();
357+
return;
358+
}
359+
const returningFromOverlay = this.overlayScreenActive;
360+
if (returningFromOverlay) {
361+
this.updateOverlayMainGeometry();
362+
this.terminal.write("\x1b[?1049l");
363+
this.overlayScreenActive = false;
364+
}
365+
const overlayMainReflowed = this.overlayMainReflowed;
366+
this.overlayMainReflowed = false;
297367
const width = this.terminal.columns;
298368
const height = this.terminal.rows;
299369
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
@@ -340,7 +410,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
340410
const replacedTranscript = !!nextScrollbackLayout && !!oldScrollbackLayout?.anchors.length &&
341411
(evictedVisibleBlock || !oldScrollbackLayout.anchors.some(({ id }) => currentAnchorIds.has(id)));
342412
let anchored = false;
343-
if (!replacedTranscript && nextScrollbackLayout && oldScrollbackLayout && this.viewportLayouts[0]?.component === this.children[0] && prevViewportTop > 0 &&
413+
if (!overlayMainReflowed && !replacedTranscript && nextScrollbackLayout && oldScrollbackLayout && this.viewportLayouts[0]?.component === this.children[0] && prevViewportTop > 0 &&
344414
this.previousLines.every((line) => isImageLine(line) || visibleWidth(line) <= width)) {
345415
const currentRows = new Map(nextScrollbackLayout.anchors.map(({ id, row }) => [id, row]));
346416
let match: { before: number; after: number } | undefined;
@@ -411,7 +481,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
411481
// screen instead. The composer stays at the bottom, historical rows stay unique,
412482
// and later output consumes this temporary space before scrolling again.
413483
if (
414-
(stableLayout || anchored || !nextScrollbackLayout) && ((!widthChanged && !heightChanged && !this.resizePending) || anchored) &&
484+
(stableLayout || anchored || !nextScrollbackLayout) && ((!widthChanged && !heightChanged && !this.resizePending) || anchored || (returningFromOverlay && !overlayMainReflowed)) &&
415485
prevViewportTop > 0 && newLines.length > prevViewportTop &&
416486
newLines.length < prevViewportTop + height &&
417487
!this.hasImageAtOrBelow(this.previousLines, prevViewportTop) && !this.hasImageAtOrBelow(newLines, prevViewportTop)
@@ -432,11 +502,6 @@ export class TuiMainScreen extends TuiBase implements TUI {
432502
}
433503
}
434504

435-
// Composite overlays into the rendered lines (before differential compare)
436-
if (this.hasOverlayEntries) {
437-
newLines = this.compositeOverlays(newLines, width, height);
438-
}
439-
440505
// Extract cursor position before applying line resets (marker must be found first)
441506
let cursorPos = this.extractCursorPosition(newLines, height);
442507

@@ -453,7 +518,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
453518
viewportOnly = false;
454519
}
455520
const start = viewportOnly ? prevViewportTop : 0;
456-
if (clear && !viewportOnly && this.previousLines.length > 0 && (prevViewportTop > 0 || replacedTranscript) && !this.segmentHeader) {
521+
if (clear && !viewportOnly && this.previousLines.length > 0 && (prevViewportTop > 0 || replacedTranscript || overlayMainReflowed) && !this.segmentHeader) {
457522
this.segmentHeader = true;
458523
newLines.unshift(segmentLabel.slice(0, width));
459524
if (cursorPos) cursorPos = { ...cursorPos, row: cursorPos.row + 1 };
@@ -471,8 +536,8 @@ export class TuiMainScreen extends TuiBase implements TUI {
471536
// Before starting a new snapshot, commit the old visible transcript.
472537
// Otherwise a projection with no overlap would silently discard rows
473538
// that had been displayed but had not reached native history yet.
474-
const archiveRows = !viewportOnly && (prevViewportTop > 0 || replacedTranscript)
475-
? widthChanged ? height : Math.min(height, Math.max(0, previousBodyEnd - prevViewportTop)) : 0;
539+
const archiveRows = !viewportOnly && (prevViewportTop > 0 || replacedTranscript || overlayMainReflowed)
540+
? widthChanged || overlayMainReflowed ? height : Math.min(height, Math.max(0, previousBodyEnd - prevViewportTop)) : 0;
476541
if (archiveRows > 0) {
477542
for (let row = archiveRows; row < height; row++) {
478543
output.append(`\x1b[${row + 1};1H\x1b[2K`);
@@ -537,12 +602,16 @@ export class TuiMainScreen extends TuiBase implements TUI {
537602
// Preserve the emitted prefix only when it can still be aligned. A genuine
538603
// reflow without a safe anchor starts a labelled snapshot rather than dropping
539604
// pending output by painting only the final screenful.
540-
const resizeOriginPreserved = anchored || this.previousLines.slice(0, prevViewportTop).every((line, row) =>
541-
visibleWidth(line) <= width && stripTerminalSequences(line) === stripTerminalSequences(newLines[row] ?? ""));
605+
const resizeOriginPreserved = !overlayMainReflowed && (anchored || this.previousLines.slice(0, prevViewportTop).every((line, row) =>
606+
visibleWidth(line) <= width && stripTerminalSequences(line) === stripTerminalSequences(newLines[row] ?? "")));
542607
if (replacedTranscript) {
543608
fullRender(true);
544609
return;
545610
}
611+
if (returningFromOverlay) {
612+
fullRender(true, resizeOriginPreserved);
613+
return;
614+
}
546615
if (this.resizePending) {
547616
fullRender(true, resizeOriginPreserved);
548617
if (this.resizeTimer === undefined) this.resizePending = false;

‎packages/tui/test/unit/tui-background-work-panel.test.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ describe('Tasks in the regular terminal viewport', () => {
375375
expect(terminal.getViewport()).toEqual(
376376
afterShrink,
377377
);
378+
const historyAfterShrink = terminal.getScrollBuffer();
378379
const presenter = new TuiOverlayRegularFeaturePresenter(terminal, tui, () =>
379380
tui.requestRender(),
380381
);
@@ -387,10 +388,8 @@ describe('Tasks in the regular terminal viewport', () => {
387388
handle.close();
388389
tui.renderNow();
389390
await terminal.flush();
390-
expect(terminal.getViewport()).toEqual(
391-
[...chatLines, 'COMPOSER', 'STATUS'].slice(-terminal.rows),
392-
);
393-
expect(terminal.getScrollBuffer()).toEqual([...chatLines, 'COMPOSER', 'STATUS']);
391+
expect(terminal.getViewport()).toEqual(afterShrink);
392+
expect(terminal.getScrollBuffer()).toEqual(historyAfterShrink);
394393
handle = presenter.show(panel, panel);
395394
tui.setFocus(handle.focus);
396395
tui.renderNow();
@@ -428,8 +427,13 @@ describe('Tasks in the regular terminal viewport', () => {
428427
await terminal.flush();
429428
expect(terminal.getScrollBuffer().join('\n')).not.toContain('task-row-');
430429
});
431-
for (const line of chatLines)
432-
expect(terminal.getScrollBuffer().filter((row) => row === line)).toHaveLength(1);
430+
const history = terminal.getScrollBuffer();
431+
const boundary = history.findLastIndex((line) => line.startsWith('── Transcript refreshed'));
432+
const currentSnapshot = history.slice(boundary + 1).filter(Boolean);
433+
expect(currentSnapshot).toEqual([...chatLines, 'COMPOSER', 'STATUS']);
434+
if (shrinkRows === 15) {
435+
expect(history.slice(0, boundary)).toEqual(historyAfterShrink.slice(0, boundary));
436+
}
433437
expect(terminal.getViewport().join('\n')).toContain('STATUS');
434438
} finally {
435439
tui.stop();

‎packages/tui/test/unit/tui-feature-viewport-contract.test.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,22 @@ describe.each(Object.entries(factories))('%s regular viewport lifecycle', (_name
220220
await vi.waitFor(async () => {
221221
await terminal.flush();
222222
const expected = [...chat(), ...Array(Math.max(0, rows - chat().length)).fill('')];
223-
expect(terminal.getViewport()).toEqual(expected.slice(-rows));
224-
expect(terminal.getScrollBuffer()).toEqual(expected);
223+
const visible = terminal.getViewport().filter(Boolean);
224+
expect(visible).toEqual(chat().slice(-visible.length));
225+
const history = terminal.getScrollBuffer().filter(Boolean);
226+
const boundary = history.findIndex((line) => line.startsWith('── Transcript refreshed'));
227+
if (chatCount === 80 && rows === 8) {
228+
// Deletion reached native history: retain the old snapshot, then label
229+
// the current document. Transient panels must occur in neither segment.
230+
expect(history.slice(0, boundary)).toEqual([
231+
...Array.from({ length: 80 }, (_, index) => `BACKGROUND-${index}`),
232+
'COMPOSER', 'STATUS',
233+
]);
234+
expect(history.slice(boundary + 1)).toEqual(chat());
235+
} else {
236+
expect(boundary).toBe(-1);
237+
expect(history).toEqual(expected.filter(Boolean));
238+
}
225239
});
226240
} finally {
227241
host.dispose();

0 commit comments

Comments
 (0)