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
18 changes: 10 additions & 8 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
},
{
"path": "tui-main-screen.ts",
"currentSha256": "f38671720867d2baa633a0e8ca826bd36474edefc281efe6b6e6a2f81e7d3ddb",
"currentSha256": "b8f93a0b2e93f7295b3330d9af3d98a577d2c4a5d4023b1833c77d5226780ffb",
"changeIds": [
"L005",
"L017",
Expand All @@ -175,15 +175,16 @@
"L039",
"L041",
"L044",
"L046"
"L046",
"L047"
],
"upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91",
"reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure.",
"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. Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output."
"reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure. Defer history reconstruction after output-driven layout shrink until user input.",
"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. Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output. A scrolled-up reader stays in place when a transient layout shrinks without user input; the reconstruction runs after the next key, a resize replay or at stop."
},
{
"path": "tui.ts",
"currentSha256": "a661a3e326e1f10c9567b99c23fec5269849856a54417439597801f75ec077f8",
"currentSha256": "d5e21c53b782084104023744030926e9c0ad179b4422ebab47b7ab95366783c2",
"changeIds": [
"L005",
"L015",
Expand All @@ -194,10 +195,11 @@
"L040",
"L041",
"L044",
"L046"
"L046",
"L047"
],
"reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure.",
"behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。 Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output."
"reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure. Report user-originated input to the renderer.",
"behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。 Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output. Key and paste input, parsed apart from terminal reports in the same chunk, lets the regular renderer run deferred history reconstruction."
},
{
"path": "utils.ts",
Expand Down
11 changes: 11 additions & 0 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. L038 keeps freed visible rows temporarily blank instead of reconstructing unchanged history.
- Evidence: local-delta tests assert every visible row and the complete history, while real Tasks and feature lifecycle tests cover short/long content, background growth, paging, resize, nested panels and return to chat. Queue lifecycle tests replay bracketed CJK paste, Alt+Enter, auto-drain, and history refresh through Ghostty; equal-height and growing historical edits are also covered by xterm. Virtual terminals do not establish native Windows Terminal or iTerm2 touchpad acceptance.
- Product boundary: the regular chat layout keeps rows that may still change out of native history (see L045), so this reconstruction remains for resize and for content that changes after being reported final.
- Scrolled-up readers: reconstruction after an output-driven layout shrink is deferred until the next user input (see L047); resize, input-driven reconstructions and changed historical text remain immediate.
- Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior.

## L036: Unframed multiline paste chunks
Expand Down Expand Up @@ -170,6 +171,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
- Minimal difference: components may expose the layout key of their last rendered frame. MainScreen permits L038 padding only when every root explicitly supplies the same key and no overlay was present. ChatLayout includes every transient section's height and interaction state, while SurfaceHost includes the active feature. Unknown or changed layouts use L034 reconstruction only when scrolled rows must return. Keys are captured with native render state and cleared on reset. This replaces the earlier completion-specific resize callback and full-viewport close exception.
- Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing.
- Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance.
- Timing: when an unknown or changed layout shrinks without recent user input, L047 pads first and restores the scrolled rows after the next key.
- Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink.

## L042: Preserve product mention bindings in prompt history
Expand Down Expand Up @@ -209,3 +211,12 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
- Product boundary: the MCode Transcript only drops whole units of final rows far above the screen, so the remaining output is an exact suffix of the previous output. A later reconstruction, such as a resize, replays only the retained rows.
- Evidence: `tui-engine-local-deltas.test.ts` retained-document cases assert no scrollback erase and exact native history after trimming (failing without the rebase), and exact history without stale rows when a root reports too few or too many discarded rows.
- Removal condition: the selected Pi baseline supports discarding a committed document prefix.

## L047: Keep scrolled-up readers in place after output-driven layout shrink

- Product contract: a reader who scrolled up in native history is not moved to the top of the scrollback when a transient layout region shrinks without user input, such as the task list collapsing when a reply finishes. Tailing readers stay at the bottom, and input-driven reconstructions keep the L034 complete-viewport and unique-history behavior (#426).
- Cause: L034 clears with ED 3 and replays the document. Hosts such as xterm.js keep their scrolled state while scrollback is rebuilt, so ED 3 moves the viewport to line 0 and the replay does not move it down again. No output sequence can restore the host's scroll offset.
- Minimal difference: `tui.ts` calls a protected `onUserInput()` hook when an input chunk contains a key or paste. The chunk is split into control sequences; focus, window, cursor-position, device-attribute, device-status, kitty-flag and mode reports, OSC/DCS/APC strings, SGR mouse reports and key releases do not count, so a key sharing a chunk with reports still does. In `tui-main-screen.ts`, a shrink in an unknown or changed layout reconstructs immediately only within one second of user input, which covers input-driven closes; otherwise it uses L038 padding and marks a deferred replay. Only user input after the deferral, a settled resize replay or `stop()` runs the deferred L034 reconstruction. Resize keeps its existing delayed replay, and changed historical text still reconstructs immediately.
- Tradeoff: after an output-driven layout shrink, blank rows can separate history from the screen until the next key. The one-second input window is a heuristic: a reader who scrolls up within it while a layout shrink lands can still be moved. A host that does not scroll to the bottom on input can still be moved to the top by the deferred replay. Legacy Shift+F3-style keys encoded as `CSI 1;n R` are indistinguishable from cursor position reports and do not trigger the replay. A resize that settles while the reader is scrolled up still moves the viewport to the top.
- Evidence: `tui-engine-local-deltas.test.ts` scrolls xterm up, collapses the Tasks section without input, and asserts no ED 3, an unchanged scroll position and unique history, then exact history after the next key; a tailing reader stays at the bottom when output ends and the task list collapses; recent input reconstructs immediately; resize keeps its replay; each report kind and a report-only mixed chunk do not trigger the replay, while keys, Escape and a paste mixed with reports do; and the deferred replay runs before stop. The shrink and input cases emit ED 3 or fail without the change, and cursor position and mixed report chunks counted as input under whole-chunk matching. Native Windows Terminal and ConPTY acceptance remain separate.
- Removal condition: the selected Pi baseline preserves the host scroll position through history reconstruction, or MCode stops reconstructing native history outside user input.
74 changes: 70 additions & 4 deletions packages/tui/src/tui/engine/tui-main-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export interface TuiMainScreenRenderState {
hadOverlays: boolean;
}

/**
* How long after user input the host is assumed to follow the bottom again.
* Terminals scroll to the bottom on key input, so a reconstruction in this
* window cannot strand a reader at the top of the replayed history (L047).
*/
const USER_INPUT_FOLLOW_WINDOW_MS = 1000;

/** TUI implementation that renders into the terminal's main screen and scrollback. */
export class TuiMainScreen extends TuiBase implements TUI {
readonly mode = "regular" as const;
Expand All @@ -131,6 +138,15 @@ export class TuiMainScreen extends TuiBase implements TUI {
private previousViewportTop = 0;
private resizeTimer: ReturnType<typeof setTimeout> | undefined;
private historyReplayPending = false;
// L047: a full reconstruction (ED 3 + replay) moves a host that is scrolled
// up to the top of the replayed history, because the host keeps its scrolled
// state while scrollback is rebuilt beneath it. Reconstruction after an
// output-driven layout shrink is therefore deferred until the next user
// input, which makes hosts return to the bottom first.
private historyReplayDeferred = false;
private historyReplayDeferredAt = 0;
private lastUserInputAt = Number.NEGATIVE_INFINITY;
private forceHistoryReplay = false;
private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = [];
private hadOverlays = false;

Expand All @@ -150,6 +166,29 @@ export class TuiMainScreen extends TuiBase implements TUI {
this.requestImmediateRender();
}

protected override onUserInput(): void {
this.lastUserInputAt = performance.now();
if (this.historyReplayDeferred) this.requestRender();
}

/** Whether the host has just scrolled back to the bottom for user input (L047). */
private hostFollowsBottom(): boolean {
return this.forceHistoryReplay || performance.now() - this.lastUserInputAt <= USER_INPUT_FOLLOW_WINDOW_MS;
}

/**
* Whether user input arrived after the given time (L047). Input before an
* output-driven event does not count: the reader may have scrolled up since.
*/
private userInputSince(time: number): boolean {
return this.forceHistoryReplay || this.lastUserInputAt >= time;
}

private deferHistoryReplay(): void {
if (!this.historyReplayDeferred) this.historyReplayDeferredAt = performance.now();
this.historyReplayDeferred = true;
}

private cancelResize(): void {
if (this.resizeTimer) clearTimeout(this.resizeTimer);
this.resizeTimer = undefined;
Expand All @@ -160,8 +199,16 @@ export class TuiMainScreen extends TuiBase implements TUI {
this.cancelResize();
// Ordinary stop must retain the latest transcript even when output is held.
// Mode switches already captured the current render state before stop.
if (!this.stopped && (this.historyReplayPending || (!options.preserveScreen && this.hasPendingRender()))) {
this.doRender();
if (
!this.stopped &&
(this.historyReplayPending || this.historyReplayDeferred || (!options.preserveScreen && this.hasPendingRender()))
) {
this.forceHistoryReplay = true;
try {
this.doRender();
} finally {
this.forceHistoryReplay = false;
}
}
super.stop(options);
}
Expand All @@ -183,6 +230,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
restoreRenderState(state: TuiMainScreenRenderState): void {
this.cancelResize();
this.historyReplayPending = false;
this.historyReplayDeferred = false;
this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line));
this.previousKittyImageIds = new Set();
this.previousWidth = state.previousWidth;
Expand All @@ -200,6 +248,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
this.hadOverlays = false;
this.cancelResize();
this.historyReplayPending = false;
this.historyReplayDeferred = false;
this.previousLines = [];
this.previousWidth = -1;
this.previousHeight = -1;
Expand Down Expand Up @@ -297,6 +346,8 @@ export class TuiMainScreen extends TuiBase implements TUI {
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
let viewportTop = prevViewportTop;
const followsBottom = this.hostFollowsBottom();
const runDeferredReplay = this.historyReplayDeferred && this.userInputSince(this.historyReplayDeferredAt);
let hardwareCursorRow = this.hardwareCursorRow;
const computeLineDiff = (targetRow: number): number => {
const currentScreenRow = hardwareCursorRow - prevViewportTop;
Expand Down Expand Up @@ -349,8 +400,11 @@ export class TuiMainScreen extends TuiBase implements TUI {
// When only addressable rows shrink, absorb the freed rows at the top of the
// screen instead. The composer stays at the bottom, historical rows stay unique,
// and later output consumes this temporary space before scrolling again.
// L047: an output-driven layout shrink uses the same padding instead of an
// immediate reconstruction, and reconstructs after the next user input.
if (
stableLayout && !hadOverlays && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries &&
(stableLayout || !followsBottom) && !runDeferredReplay &&
!hadOverlays && !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries &&
prevViewportTop > 0 && newLines.length > prevViewportTop &&
newLines.length < prevViewportTop + height &&
this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine)
Expand All @@ -365,6 +419,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
if (unchangedHistory) {
const padding = Array<string>(prevViewportTop + height - newLines.length).fill("");
newLines = [...newLines.slice(0, prevViewportTop), ...padding, ...newLines.slice(prevViewportTop)];
if (!stableLayout) this.deferHistoryReplay();
}
}

Expand Down Expand Up @@ -454,7 +509,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
if (this.historyReplayPending) {
const viewportOnly = this.resizeTimer !== undefined;
fullRender(true, viewportOnly);
if (!viewportOnly) this.historyReplayPending = false;
if (!viewportOnly) {
this.historyReplayPending = false;
// The resize replay rebuilt history, including any deferred shrink.
this.historyReplayDeferred = false;
}
return;
}

if (runDeferredReplay) {
logRedraw("deferred history replay after user input");
this.historyReplayDeferred = false;
fullRender(true);
return;
}

Expand Down
Loading
Loading