From 4b30343206966d26e676cb38392cc932d91e2d67 Mon Sep 17 00:00:00 2001 From: Tao He Date: Sun, 4 Oct 2026 18:00:37 +0800 Subject: [PATCH 1/2] fix(tui): keep scrolled-up readers in place on output-driven redraws (#426) The regular-mode history reconstruction (L034) clears scrollback with ED 3 and replays the document. xterm.js keeps its scrolled state while the scrollback is rebuilt, so a reader who had scrolled up was moved to line 0 whenever a resize settled (for example a fit addon reacting to a layout change) or a transient layout shrank while a reply streamed. Output-driven reconstructions now wait for the next user input, when terminals scroll back to the bottom: - TuiBase reports key and paste input through a protected onUserInput() hook; focus, size, mode and kitty-flag reports and key releases do not count. - A settled resize replays history only if input arrived after the resize notification; otherwise it finishes as an in-place viewport redraw and the replay is deferred. - A shrink in an unknown or changed layout pads like L038 unless input arrived within the last second (input-driven closes still restore rows immediately), and the replay is deferred. - The next input after the deferral, or stop(), runs the deferred replay, so native history ends up exact and unique as before. Changed historical text and size changes without a resize notification still reconstruct immediately. Recorded as L047 in LOCAL_CHANGES. --- .../tui/src/tui/engine/LOCAL_CHANGES.json | 18 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 11 + .../tui/src/tui/engine/tui-main-screen.ts | 88 ++++++- packages/tui/src/tui/engine/tui.ts | 16 ++ .../test/unit/tui-engine-local-deltas.test.ts | 238 ++++++++++++++++++ 5 files changed, 357 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 8f3529b4..26db2170 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,7 +163,7 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "f38671720867d2baa633a0e8ca826bd36474edefc281efe6b6e6a2f81e7d3ddb", + "currentSha256": "1aceb55a3bda1514feb4a3f332d194774f3d7e1ca8ae7b807b523ab13e84c1b6", "changeIds": [ "L005", "L017", @@ -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 output-driven history reconstruction 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 resize settles or a layout shrinks during output; the reconstruction runs after the next key or at stop." }, { "path": "tui.ts", - "currentSha256": "a661a3e326e1f10c9567b99c23fec5269849856a54417439597801f75ec077f8", + "currentSha256": "ae08b009e066cf1668acde2df0cb03e9781775055c8196a0a0c3f41fd779c009", "changeIds": [ "L005", "L015", @@ -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, excluding terminal reports, lets the regular renderer run deferred history reconstruction." }, { "path": "utils.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 7a5e6672..c300a00b 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -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: reconstructions caused by output or resize rather than input are deferred until the next user input (see L047); 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 @@ -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 @@ -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 during output-driven reconstruction + +- Product contract: a reader who scrolled up in native history is not moved to the top of the scrollback by a reconstruction they did not cause, such as a resize settling or a layout shrink while a reply streams. Tailing readers 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 for key and paste input, excluding focus, size, mode and kitty-flag reports and key releases. In `tui-main-screen.ts`, a settled resize replays history only when user input arrived after the resize notification; otherwise it finishes as an in-place viewport redraw and marks a deferred replay. 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, or `stop()`, runs the deferred L034 reconstruction. Changed historical text and size changes without a resize notification still reconstruct immediately. +- Tradeoff: until the next key after a resize, native history keeps rows at the previous width and has a seam at the previous screen boundary. The terminal's rows before the seam are not rewritten, and rows after it are positioned for the new geometry, so a height change or rewrapping above the screen can omit or repeat rows at the seam: in an xterm.js PTY replay, narrowing by one column omitted 7-10 rows. This also applies to a tailing reader who resizes without typing. After an output-driven layout shrink, blank rows can separate history from the screen until the next key. A host that does not scroll to the bottom on input can still be moved to the top by the deferred replay. +- Evidence: `tui-engine-local-deltas.test.ts` scrolls xterm up during streaming, settles width and height resizes and an output-driven Tasks shrink, and asserts no ED 3, a viewport that stays near the reading row, no replay for a key sent before the reader scrolled up, exact unique history after the next key, a tailing reader that stays at the bottom, terminal reports that do not trigger the replay, and a replay before stop. The new cases emit ED 3 and fail without the deferral. 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. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 3d752ae6..725904da 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -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; @@ -131,6 +138,16 @@ export class TuiMainScreen extends TuiBase implements TUI { private previousViewportTop = 0; private resizeTimer: ReturnType | 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. Output-driven reconstructions + // are therefore deferred until the next user input, which makes hosts return + // to the bottom first. + private historyReplayDeferred = false; + private historyReplayDeferredAt = 0; + private lastResizeAt = 0; + private lastUserInputAt = Number.NEGATIVE_INFINITY; + private forceHistoryReplay = false; private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; private hadOverlays = false; @@ -142,6 +159,7 @@ export class TuiMainScreen extends TuiBase implements TUI { if (this.previousLines.length > 0 && !isTermuxSession()) { if (this.resizeTimer) clearTimeout(this.resizeTimer); this.historyReplayPending = true; + this.lastResizeAt = performance.now(); this.resizeTimer = setTimeout(() => { this.resizeTimer = undefined; this.requestRender(); @@ -150,6 +168,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; @@ -160,8 +201,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); } @@ -183,6 +232,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; @@ -200,6 +250,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; @@ -297,6 +348,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; @@ -349,8 +402,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) @@ -365,6 +421,7 @@ export class TuiMainScreen extends TuiBase implements TUI { if (unchangedHistory) { const padding = Array(prevViewportTop + height - newLines.length).fill(""); newLines = [...newLines.slice(0, prevViewportTop), ...padding, ...newLines.slice(prevViewportTop)]; + if (!stableLayout) this.deferHistoryReplay(); } } @@ -452,9 +509,28 @@ export class TuiMainScreen extends TuiBase implements TUI { }; if (this.historyReplayPending) { - const viewportOnly = this.resizeTimer !== undefined; - fullRender(true, viewportOnly); - if (!viewportOnly) this.historyReplayPending = false; + const settling = this.resizeTimer !== undefined; + if (!settling && this.userInputSince(this.lastResizeAt)) { + this.historyReplayPending = false; + this.historyReplayDeferred = false; + fullRender(true); + return; + } + // Keep showing the resized tail. Once the resize settled without user + // input, finish it as the current frame and replay history later (L047). + fullRender(true, true); + if (!settling) { + logRedraw("resize history replay deferred until user input"); + this.historyReplayPending = false; + this.deferHistoryReplay(); + } + return; + } + + if (runDeferredReplay) { + logRedraw("deferred history replay after user input"); + this.historyReplayDeferred = false; + fullRender(true); return; } diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index a7bf70fb..f54f8a22 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -398,6 +398,13 @@ export abstract class TuiBase extends Container implements TUI { this.requestRender(); } + /** + * Called for input the host terminal attributes to the user (keys and paste). + * Terminal reports such as focus, size and mode replies are excluded. Hosts + * normally scroll their viewport back to the bottom on such input (L047). + */ + protected onUserInput(): void {} + protected beforeTerminalStart(): void {} protected afterTerminalStart(): void {} @@ -877,6 +884,7 @@ export abstract class TuiBase extends Container implements TUI { return; } data = remaining; + if (isUserOriginatedInput(data)) this.onUserInput(); if (this.inputListeners.size > 0) { let current = data; @@ -1323,3 +1331,11 @@ export abstract class TuiBase extends Container implements TUI { }); } } + +/** Excludes terminal-generated reports, which do not make hosts scroll to the bottom (L047). */ +function isUserOriginatedInput(data: string): boolean { + if (data === "\x1b[I" || data === "\x1b[O") return false; + if (/^\x1b\[\d+(?:;\d+)*t$/.test(data)) return false; + if (/^\x1b\[\?[\d;]*(?:c|n|u|\$y)$/.test(data)) return false; + return !isKeyRelease(data); +} diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index b12a4eea..cfcbdb1a 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -52,6 +52,16 @@ class ClearToScrollbackTerminal extends RecordingVirtualTerminal { } } +/** + * A key reaches the TUI through the host terminal, which scrolls back to the + * bottom before delivering it. Tests that call component handlers directly + * model that delivery explicitly (L047). + */ +function deliverUserKey(terminal: VirtualTerminal, tui: TuiMainScreen): void { + terminal.scrollLines(Number.MAX_SAFE_INTEGER); + (tui as unknown as { onUserInput(): void }).onUserInput(); +} + class MutableLines implements Component { lines: string[] = []; viewportLayoutKey: string | undefined; @@ -143,6 +153,7 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); terminal.scrollLines(-5); terminal.takeWrites(); + deliverUserKey(terminal, tui); picker.handleInput(key); tui.renderNow(); await terminal.flush(); @@ -349,6 +360,7 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); parts[part].lines = [...baseline]; + deliverUserKey(terminal, tui); tui.renderNow(); await terminal.flush(); @@ -449,6 +461,7 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); component.lines = [...history, 'composer', 'status']; + deliverUserKey(terminal, tui); tui.renderNow(); await terminal.flush(); @@ -477,6 +490,7 @@ describe('MCode Pi Engine local deltas', () => { await terminal.flush(); component.viewportLayoutKey = 'closed'; component.lines = [...history, 'composer', 'status']; + deliverUserKey(terminal, tui); tui.renderNow(); await terminal.flush(); @@ -792,9 +806,20 @@ describe('MCode Pi Engine local deltas', () => { // A redundant notification must not discard the pending genuine resize replay. terminal.resize(60, 30); + // Without user input the settled resize keeps native scrollback (L047). await new Promise((resolve) => setTimeout(resolve, 200)); tui.renderNow(); await terminal.flush(); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getViewport()).toEqual([ + ...Array.from({ length: 29 }, (_, index) => `Answer line ${index + 51}`), + 'composer', + ]); + + // The next key, delivered after the host returns to the bottom, replays history. + deliverUserKey(terminal, tui); + tui.renderNow(); + await terminal.flush(); expect(terminal.takeWrites()).toContain('\x1b[3J'); expect(terminal.getScrollBuffer()).toEqual([ ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), @@ -806,6 +831,219 @@ describe('MCode Pi Engine local deltas', () => { } }); + // #426: ED 3 + replay leaves a scrolled-up xterm.js host at the top of the + // rebuilt history (it keeps its scrolled state), so output-driven + // reconstructions wait for user input, when hosts return to the bottom (L047). + describe('scrolled-up readers during output-driven reconstruction (#426)', () => { + const settleResize = async (terminal: VirtualTerminal, columns: number, rows: number) => { + terminal.resize(columns, rows); + await new Promise((resolve) => setTimeout(resolve, 200)); + await terminal.flush(); + }; + const topRow = (terminal: VirtualTerminal) => Number(/\d+$/.exec(terminal.getViewport()[0]?.trim() ?? '')?.[0]); + + it.each([ + ['width', 59, 30], + ['height', 60, 26], + ] as const)('keeps the viewport when a %s resize settles mid-stream', async (_kind, columns, rows) => { + const terminal = new RecordingVirtualTerminal(60, 30); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 120 }, (_, index) => `Answer line ${index}`); + component.lines = [...answer, `composer${CURSOR_MARKER}`, 'status']; + tui.addChild(component); + try { + tui.start(); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-40); + const before = terminal.getScrollPosition(); + const readingRow = topRow(terminal); + expect(before.viewport).toBeGreaterThan(0); + terminal.takeWrites(); + + await settleResize(terminal, columns, rows); + // Streaming continues after the resize settled. + for (let index = 120; index < 140; index++) { + component.lines.splice(-2, 0, `Answer line ${index}`); + tui.renderNow(); + await terminal.flush(); + } + + const during = terminal.getScrollPosition(); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(during.viewport).not.toBe(0); + expect(during.viewport).toBeLessThan(during.bottom); + // The host may shift by the rows it moved itself while resizing, never to the top. + expect(Math.abs(topRow(terminal) - readingRow)).toBeLessThanOrEqual(Math.abs(30 - rows)); + + // The next key replays exact, unique history and leaves the host at the bottom. + deliverUserKey(terminal, tui); + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + const expected = [...Array.from({ length: 140 }, (_, index) => `Answer line ${index}`), 'composer', 'status']; + expect(terminal.getScrollBuffer()).toEqual(expected); + const after = terminal.getScrollPosition(); + expect(after.viewport).toBe(after.bottom); + expect(terminal.getViewport().slice(-2)).toEqual(['composer', 'status']); + } finally { + tui.stop(); + } + }); + + it('does not replay for a key sent before the reader scrolled up and resized', async () => { + const terminal = new RecordingVirtualTerminal(60, 30); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + component.lines = [...Array.from({ length: 120 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`, 'status']; + tui.addChild(component); + try { + tui.start(); + tui.renderNow(); + await terminal.flush(); + // Submit, then scroll up to read while the reply streams. + deliverUserKey(terminal, tui); + terminal.scrollLines(-40); + terminal.takeWrites(); + + await settleResize(terminal, 59, 30); + for (let index = 120; index < 130; index++) { + component.lines.splice(-2, 0, `Answer line ${index}`); + tui.renderNow(); + await terminal.flush(); + } + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollPosition().viewport).not.toBe(0); + } finally { + tui.stop(); + } + }); + + it('keeps a tailing reader at the bottom when a resize settles mid-stream', async () => { + const terminal = new RecordingVirtualTerminal(60, 30); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + component.lines = [...Array.from({ length: 120 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`, 'status']; + tui.addChild(component); + try { + tui.start(); + tui.renderNow(); + await terminal.flush(); + await settleResize(terminal, 59, 28); + for (let index = 120; index < 140; index++) { + component.lines.splice(-2, 0, `Answer line ${index}`); + tui.renderNow(); + await terminal.flush(); + const position = terminal.getScrollPosition(); + expect(position.viewport).toBe(position.bottom); + } + expect(terminal.getViewport().slice(-3)).toEqual(['Answer line 139', 'composer', 'status']); + } finally { + tui.stop(); + } + }); + + it('pads an output-driven layout shrink and reconstructs after the next key', async () => { + const terminal = new RecordingVirtualTerminal(60, 16); + const tui = new TuiMainScreen(terminal); + const parts = createMutableChatParts('conversation'); + const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); + parts.transcript.lines = history; + parts.tasks.lines = Array.from({ length: 6 }, (_, index) => `Task ${index}`); + const layout = new TuiChatLayout(terminal, parts); + tui.addChild(layout); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-12); + const before = terminal.getScrollPosition(); + terminal.takeWrites(); + + // Background tasks finish without user input while the reader is scrolled up. + parts.tasks.lines = []; + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollPosition()).toEqual(before); + for (const line of history) { + expect(terminal.getScrollBuffer().filter((row) => row.trim() === line)).toHaveLength(1); + } + + deliverUserKey(terminal, tui); + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + const logicalDocument = layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); + expect(terminal.getViewport()).toEqual(logicalDocument.slice(-terminal.rows)); + expect(terminal.getScrollBuffer()).toEqual(logicalDocument); + }); + + it('ignores terminal reports and replays after real key input', async () => { + const terminal = new RecordingVirtualTerminal(60, 30); + const tui = new TuiMainScreen(terminal); + const component: Component & { lines: string[] } = { + lines: [...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`], + render() { + return [...this.lines]; + }, + invalidate: () => undefined, + handleInput: () => undefined, + }; + tui.addChild(component); + tui.setFocus(component); + try { + tui.start(); + tui.renderNow(); + await terminal.flush(); + await settleResize(terminal, 59, 30); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + for (const report of ['\x1b[I', '\x1b[O', '\x1b[8;30;59t', '\x1b[?1u', '\x1b[?62;22c']) { + terminal.sendInput(report); + tui.renderNow(); + await terminal.flush(); + } + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + + terminal.sendInput('x'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([ + ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), + 'composer', + ]); + } finally { + tui.stop(); + } + }); + + it('replays deferred history before stopping', async () => { + const terminal = new RecordingVirtualTerminal(60, 30); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + component.lines = [...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`]; + tui.addChild(component); + tui.start(); + tui.renderNow(); + await terminal.flush(); + await settleResize(terminal, 59, 30); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + tui.stop(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer().slice(0, 81).map((line) => line.trim())).toEqual([ + ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), + 'composer', + ]); + }); + }); + it('renders an urgent product interaction without resetting Main diff state', async () => { const terminal = new RecordingVirtualTerminal(40, 5); const tui = new TuiMainScreen(terminal); From 931c4706474448d98d3cd74f330161dc0ec77904 Mon Sep 17 00:00:00 2001 From: Tao He Date: Sun, 4 Oct 2026 19:21:29 +0800 Subject: [PATCH 2/2] fix(tui): limit scroll preservation to output-driven layout shrink (#426) Review follow-up: - Resize again replays history as soon as it settles, as on main. The resize deferral is split out until the reporter's fit/resize behaviour is known; only the output-driven layout-shrink deferral remains (pad with blank rows, reconstruct on the next user input, immediately within one second of input). - User input detection splits each chunk into control sequences instead of matching the whole chunk. Cursor position reports, OSC/DCS/APC strings and SGR mouse reports join focus, window, device and mode reports and key releases as non-input, and a key or paste that shares a chunk with reports still counts. - Tests cover a tailing reader when output ends and the task list collapses, recent input reconstructing immediately, resize keeping its replay, each report kind, report-only and mixed chunks, and the replay before stop. L047 is updated accordingly. Refs #426 --- .../tui/src/tui/engine/LOCAL_CHANGES.json | 10 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 12 +- .../tui/src/tui/engine/tui-main-screen.ts | 24 +- packages/tui/src/tui/engine/tui.ts | 78 ++++- .../test/unit/tui-engine-local-deltas.test.ts | 297 ++++++++---------- 5 files changed, 221 insertions(+), 200 deletions(-) diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 26db2170..59e5dc3b 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,7 +163,7 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "1aceb55a3bda1514feb4a3f332d194774f3d7e1ca8ae7b807b523ab13e84c1b6", + "currentSha256": "b8f93a0b2e93f7295b3330d9af3d98a577d2c4a5d4023b1833c77d5226780ffb", "changeIds": [ "L005", "L017", @@ -179,12 +179,12 @@ "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. Defer output-driven history reconstruction 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 resize settles or a layout shrinks during output; the reconstruction runs after the next key or at stop." + "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": "ae08b009e066cf1668acde2df0cb03e9781775055c8196a0a0c3f41fd779c009", + "currentSha256": "d5e21c53b782084104023744030926e9c0ad179b4422ebab47b7ab95366783c2", "changeIds": [ "L005", "L015", @@ -199,7 +199,7 @@ "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. 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, excluding terminal reports, lets the regular renderer run deferred history reconstruction." + "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", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index c300a00b..9fc9c826 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -123,7 +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: reconstructions caused by output or resize rather than input are deferred until the next user input (see L047); input-driven reconstructions and changed historical text remain immediate. +- 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 @@ -212,11 +212,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - 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 during output-driven reconstruction +## 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 by a reconstruction they did not cause, such as a resize settling or a layout shrink while a reply streams. Tailing readers and input-driven reconstructions keep the L034 complete-viewport and unique-history behavior (#426). +- 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 for key and paste input, excluding focus, size, mode and kitty-flag reports and key releases. In `tui-main-screen.ts`, a settled resize replays history only when user input arrived after the resize notification; otherwise it finishes as an in-place viewport redraw and marks a deferred replay. 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, or `stop()`, runs the deferred L034 reconstruction. Changed historical text and size changes without a resize notification still reconstruct immediately. -- Tradeoff: until the next key after a resize, native history keeps rows at the previous width and has a seam at the previous screen boundary. The terminal's rows before the seam are not rewritten, and rows after it are positioned for the new geometry, so a height change or rewrapping above the screen can omit or repeat rows at the seam: in an xterm.js PTY replay, narrowing by one column omitted 7-10 rows. This also applies to a tailing reader who resizes without typing. After an output-driven layout shrink, blank rows can separate history from the screen until the next key. A host that does not scroll to the bottom on input can still be moved to the top by the deferred replay. -- Evidence: `tui-engine-local-deltas.test.ts` scrolls xterm up during streaming, settles width and height resizes and an output-driven Tasks shrink, and asserts no ED 3, a viewport that stays near the reading row, no replay for a key sent before the reader scrolled up, exact unique history after the next key, a tailing reader that stays at the bottom, terminal reports that do not trigger the replay, and a replay before stop. The new cases emit ED 3 and fail without the deferral. Native Windows Terminal and ConPTY acceptance remain separate. +- 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. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 725904da..f9fc895b 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -140,12 +140,11 @@ export class TuiMainScreen extends TuiBase implements TUI { 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. Output-driven reconstructions - // are therefore deferred until the next user input, which makes hosts return - // to the bottom first. + // 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 lastResizeAt = 0; private lastUserInputAt = Number.NEGATIVE_INFINITY; private forceHistoryReplay = false; private viewportLayouts: TuiMainScreenRenderState['viewportLayouts'] = []; @@ -159,7 +158,6 @@ export class TuiMainScreen extends TuiBase implements TUI { if (this.previousLines.length > 0 && !isTermuxSession()) { if (this.resizeTimer) clearTimeout(this.resizeTimer); this.historyReplayPending = true; - this.lastResizeAt = performance.now(); this.resizeTimer = setTimeout(() => { this.resizeTimer = undefined; this.requestRender(); @@ -509,20 +507,12 @@ export class TuiMainScreen extends TuiBase implements TUI { }; if (this.historyReplayPending) { - const settling = this.resizeTimer !== undefined; - if (!settling && this.userInputSince(this.lastResizeAt)) { + const viewportOnly = this.resizeTimer !== undefined; + fullRender(true, viewportOnly); + if (!viewportOnly) { this.historyReplayPending = false; + // The resize replay rebuilt history, including any deferred shrink. this.historyReplayDeferred = false; - fullRender(true); - return; - } - // Keep showing the resized tail. Once the resize settled without user - // input, finish it as the current frame and replay history later (L047). - fullRender(true, true); - if (!settling) { - logRedraw("resize history replay deferred until user input"); - this.historyReplayPending = false; - this.deferHistoryReplay(); } return; } diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index f54f8a22..aea83d3e 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -884,7 +884,7 @@ export abstract class TuiBase extends Container implements TUI { return; } data = remaining; - if (isUserOriginatedInput(data)) this.onUserInput(); + if (containsUserInput(data)) this.onUserInput(); if (this.inputListeners.size > 0) { let current = data; @@ -1332,10 +1332,74 @@ export abstract class TuiBase extends Container implements TUI { } } -/** Excludes terminal-generated reports, which do not make hosts scroll to the bottom (L047). */ -function isUserOriginatedInput(data: string): boolean { - if (data === "\x1b[I" || data === "\x1b[O") return false; - if (/^\x1b\[\d+(?:;\d+)*t$/.test(data)) return false; - if (/^\x1b\[\?[\d;]*(?:c|n|u|\$y)$/.test(data)) return false; - return !isKeyRelease(data); +/** + * Whether a chunk contains input the user typed or pasted (L047). The chunk is + * split into control sequences so a key that shares a chunk with terminal + * reports still counts, while chunks made only of reports, key releases or + * mouse reports do not; hosts do not scroll to the bottom for those. + */ +function containsUserInput(data: string): boolean { + let index = 0; + while (index < data.length) { + // Printable text and C0 control keys (Enter, Tab, Ctrl+letter) are user input. + if (data[index] !== "\x1b") return true; + const end = escapeSequenceEnd(data, index); + const sequence = data.slice(index, end); + if (!isTerminalReport(sequence) && !isKeyRelease(sequence)) return true; + index = end; + } + return false; +} + +/** End index of the escape sequence starting at `start`; an unterminated sequence runs to the end. */ +function escapeSequenceEnd(data: string, start: number): number { + const introducer = data[start + 1]; + // A lone or doubled ESC is the Escape key. + if (introducer === undefined || introducer === "\x1b") return start + 1; + if (introducer === "[") { + let index = start + 2; + while (index < data.length && isInRange(data, index, 0x30, 0x3f)) index++; + while (index < data.length && isInRange(data, index, 0x20, 0x2f)) index++; + return index < data.length && isInRange(data, index, 0x40, 0x7e) ? index + 1 : data.length; + } + if (introducer === "]" || introducer === "P" || introducer === "_" || introducer === "^" || introducer === "X") { + for (let index = start + 2; index < data.length; index++) { + if (introducer === "]" && data[index] === "\x07") return index + 1; + if (data[index] === "\x1b" && data[index + 1] === "\\") return index + 2; + } + return data.length; + } + if (introducer === "O") return Math.min(start + 3, data.length); + // Alt+key. + return start + 2; +} + +function isInRange(data: string, index: number, low: number, high: number): boolean { + const code = data.charCodeAt(index); + return code >= low && code <= high; +} + +/** Replies and reports a terminal sends on its own or in answer to a query. */ +function isTerminalReport(sequence: string): boolean { + const introducer = sequence[1]; + // OSC, DCS, APC, PM and SOS strings are terminal replies, never keys. + if (introducer === "]" || introducer === "P" || introducer === "_" || introducer === "^" || introducer === "X") return true; + const csi = /^\x1b\[([\x30-\x3f]*)([\x20-\x2f]*)([\x40-\x7e])$/.exec(sequence); + if (!csi) return false; + const [, params = "", intermediates = "", final] = csi; + // Focus in/out. + if ((final === "I" || final === "O") && params === "" && intermediates === "") return true; + // Window size and state reports. + if (final === "t" && /^\d+(?:;\d+)*$/.test(params)) return true; + // Cursor position reports (CPR and DECXCPR). + if (final === "R" && /^\??\d+;\d+(?:;\d+)?$/.test(params)) return true; + // Device attributes, device status and kitty keyboard flag replies. + if (final === "c" && /^[?>=]/.test(params)) return true; + if (final === "n") return true; + if (final === "u" && params.startsWith("?")) return true; + // Mode reports (DECRPM). + if (final === "y" && intermediates === "$") return true; + // SGR mouse reports. + if ((final === "M" || final === "m") && params.startsWith("<")) return true; + return false; } diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index cfcbdb1a..6bbfe23a 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -1,5 +1,5 @@ import { stripVTControlCharacters } from 'node:util'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { CURSOR_MARKER, @@ -806,20 +806,9 @@ describe('MCode Pi Engine local deltas', () => { // A redundant notification must not discard the pending genuine resize replay. terminal.resize(60, 30); - // Without user input the settled resize keeps native scrollback (L047). await new Promise((resolve) => setTimeout(resolve, 200)); tui.renderNow(); await terminal.flush(); - expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - expect(terminal.getViewport()).toEqual([ - ...Array.from({ length: 29 }, (_, index) => `Answer line ${index + 51}`), - 'composer', - ]); - - // The next key, delivered after the host returns to the bottom, replays history. - deliverUserKey(terminal, tui); - tui.renderNow(); - await terminal.flush(); expect(terminal.takeWrites()).toContain('\x1b[3J'); expect(terminal.getScrollBuffer()).toEqual([ ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), @@ -832,121 +821,18 @@ describe('MCode Pi Engine local deltas', () => { }); // #426: ED 3 + replay leaves a scrolled-up xterm.js host at the top of the - // rebuilt history (it keeps its scrolled state), so output-driven - // reconstructions wait for user input, when hosts return to the bottom (L047). - describe('scrolled-up readers during output-driven reconstruction (#426)', () => { - const settleResize = async (terminal: VirtualTerminal, columns: number, rows: number) => { - terminal.resize(columns, rows); - await new Promise((resolve) => setTimeout(resolve, 200)); - await terminal.flush(); - }; - const topRow = (terminal: VirtualTerminal) => Number(/\d+$/.exec(terminal.getViewport()[0]?.trim() ?? '')?.[0]); - - it.each([ - ['width', 59, 30], - ['height', 60, 26], - ] as const)('keeps the viewport when a %s resize settles mid-stream', async (_kind, columns, rows) => { - const terminal = new RecordingVirtualTerminal(60, 30); - const tui = new TuiMainScreen(terminal); - const component = new MutableLines(); - const answer = Array.from({ length: 120 }, (_, index) => `Answer line ${index}`); - component.lines = [...answer, `composer${CURSOR_MARKER}`, 'status']; - tui.addChild(component); - try { - tui.start(); - tui.renderNow(); - await terminal.flush(); - terminal.scrollLines(-40); - const before = terminal.getScrollPosition(); - const readingRow = topRow(terminal); - expect(before.viewport).toBeGreaterThan(0); - terminal.takeWrites(); - - await settleResize(terminal, columns, rows); - // Streaming continues after the resize settled. - for (let index = 120; index < 140; index++) { - component.lines.splice(-2, 0, `Answer line ${index}`); - tui.renderNow(); - await terminal.flush(); - } - - const during = terminal.getScrollPosition(); - expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - expect(during.viewport).not.toBe(0); - expect(during.viewport).toBeLessThan(during.bottom); - // The host may shift by the rows it moved itself while resizing, never to the top. - expect(Math.abs(topRow(terminal) - readingRow)).toBeLessThanOrEqual(Math.abs(30 - rows)); - - // The next key replays exact, unique history and leaves the host at the bottom. - deliverUserKey(terminal, tui); - tui.renderNow(); - await terminal.flush(); - expect(terminal.takeWrites()).toContain('\x1b[3J'); - const expected = [...Array.from({ length: 140 }, (_, index) => `Answer line ${index}`), 'composer', 'status']; - expect(terminal.getScrollBuffer()).toEqual(expected); - const after = terminal.getScrollPosition(); - expect(after.viewport).toBe(after.bottom); - expect(terminal.getViewport().slice(-2)).toEqual(['composer', 'status']); - } finally { - tui.stop(); - } + // rebuilt history (it keeps its scrolled state), so reconstruction after an + // output-driven layout shrink waits for user input, when hosts return to the + // bottom (L047). Resize keeps its immediate replay after settling. + describe('scrolled-up readers during output-driven layout shrink (#426)', () => { + const started: TuiMainScreen[] = []; + afterEach(() => { + for (const tui of started.splice(0)) tui.stop(); }); - - it('does not replay for a key sent before the reader scrolled up and resized', async () => { - const terminal = new RecordingVirtualTerminal(60, 30); - const tui = new TuiMainScreen(terminal); - const component = new MutableLines(); - component.lines = [...Array.from({ length: 120 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`, 'status']; - tui.addChild(component); - try { - tui.start(); - tui.renderNow(); - await terminal.flush(); - // Submit, then scroll up to read while the reply streams. - deliverUserKey(terminal, tui); - terminal.scrollLines(-40); - terminal.takeWrites(); - - await settleResize(terminal, 59, 30); - for (let index = 120; index < 130; index++) { - component.lines.splice(-2, 0, `Answer line ${index}`); - tui.renderNow(); - await terminal.flush(); - } - expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - expect(terminal.getScrollPosition().viewport).not.toBe(0); - } finally { - tui.stop(); - } - }); - - it('keeps a tailing reader at the bottom when a resize settles mid-stream', async () => { - const terminal = new RecordingVirtualTerminal(60, 30); - const tui = new TuiMainScreen(terminal); - const component = new MutableLines(); - component.lines = [...Array.from({ length: 120 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`, 'status']; - tui.addChild(component); - try { - tui.start(); - tui.renderNow(); - await terminal.flush(); - await settleResize(terminal, 59, 28); - for (let index = 120; index < 140; index++) { - component.lines.splice(-2, 0, `Answer line ${index}`); - tui.renderNow(); - await terminal.flush(); - const position = terminal.getScrollPosition(); - expect(position.viewport).toBe(position.bottom); - } - expect(terminal.getViewport().slice(-3)).toEqual(['Answer line 139', 'composer', 'status']); - } finally { - tui.stop(); - } - }); - - it('pads an output-driven layout shrink and reconstructs after the next key', async () => { - const terminal = new RecordingVirtualTerminal(60, 16); + const renderShrinkingTasks = async (terminal: RecordingVirtualTerminal) => { const tui = new TuiMainScreen(terminal); + tui.start(); + started.push(tui); const parts = createMutableChatParts('conversation'); const history = Array.from({ length: 40 }, (_, index) => `History ${index}`); parts.transcript.lines = history; @@ -955,14 +841,30 @@ describe('MCode Pi Engine local deltas', () => { tui.addChild(layout); tui.renderNow(); await terminal.flush(); + return { tui, parts, history, layout }; + }; + const collapseTasks = async ( + terminal: RecordingVirtualTerminal, + tui: TuiMainScreen, + parts: ReturnType, + ) => { + parts.tasks.lines = []; + tui.renderNow(); + await terminal.flush(); + }; + const logicalDocument = (layout: TuiChatLayout, terminal: RecordingVirtualTerminal) => + layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); + + it('pads an output-driven layout shrink and reconstructs after the next key', async () => { + const terminal = new RecordingVirtualTerminal(60, 16); + const { tui, parts, history, layout } = await renderShrinkingTasks(terminal); terminal.scrollLines(-12); const before = terminal.getScrollPosition(); + expect(before.viewport).toBeGreaterThan(0); terminal.takeWrites(); // Background tasks finish without user input while the reader is scrolled up. - parts.tasks.lines = []; - tui.renderNow(); - await terminal.flush(); + await collapseTasks(terminal, tui, parts); expect(terminal.takeWrites()).not.toContain('\x1b[3J'); expect(terminal.getScrollPosition()).toEqual(before); for (const line of history) { @@ -973,41 +875,64 @@ describe('MCode Pi Engine local deltas', () => { tui.renderNow(); await terminal.flush(); expect(terminal.takeWrites()).toContain('\x1b[3J'); - const logicalDocument = layout.render(terminal.columns).map((line) => line.replace(CURSOR_MARKER, '')); - expect(terminal.getViewport()).toEqual(logicalDocument.slice(-terminal.rows)); - expect(terminal.getScrollBuffer()).toEqual(logicalDocument); + expect(terminal.getViewport()).toEqual(logicalDocument(layout, terminal).slice(-terminal.rows)); + expect(terminal.getScrollBuffer()).toEqual(logicalDocument(layout, terminal)); + }); + + it('keeps a tailing reader at the bottom when output ends and the task list collapses', async () => { + const terminal = new RecordingVirtualTerminal(60, 16); + const { tui, parts, history, layout } = await renderShrinkingTasks(terminal); + expect(terminal.getScrollPosition().viewport).toBe(terminal.getScrollPosition().bottom); + terminal.takeWrites(); + + // The reply finishes streaming, then the task list collapses, with no input. + parts.transcript.lines = [...history, 'Final answer line']; + tui.renderNow(); + await terminal.flush(); + await collapseTasks(terminal, tui, parts); + + const position = terminal.getScrollPosition(); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(position.viewport).not.toBe(0); + expect(position.viewport).toBe(position.bottom); + expect(terminal.getViewport().slice(-2).map((row) => row.trim())).toEqual(['composer', 'status']); + expect(terminal.getViewport().some((row) => row.trim() === 'Final answer line')).toBe(true); + for (const line of [...history, 'Final answer line']) { + expect(terminal.getScrollBuffer().filter((row) => row.trim() === line)).toHaveLength(1); + } + + // The next key restores the complete viewport without leaving the bottom. + deliverUserKey(terminal, tui); + tui.renderNow(); + await terminal.flush(); + const after = terminal.getScrollPosition(); + expect(after.viewport).toBe(after.bottom); + expect(terminal.getScrollBuffer()).toEqual(logicalDocument(layout, terminal)); + }); + + it('reconstructs immediately when the shrink follows recent user input', async () => { + const terminal = new RecordingVirtualTerminal(60, 16); + const { tui, parts, layout } = await renderShrinkingTasks(terminal); + terminal.takeWrites(); + deliverUserKey(terminal, tui); + await collapseTasks(terminal, tui, parts); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual(logicalDocument(layout, terminal)); }); - it('ignores terminal reports and replays after real key input', async () => { + it('keeps the immediate history replay after a resize settles', async () => { const terminal = new RecordingVirtualTerminal(60, 30); const tui = new TuiMainScreen(terminal); - const component: Component & { lines: string[] } = { - lines: [...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`], - render() { - return [...this.lines]; - }, - invalidate: () => undefined, - handleInput: () => undefined, - }; + const component = new MutableLines(); + component.lines = [...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`]; tui.addChild(component); - tui.setFocus(component); try { tui.start(); tui.renderNow(); await terminal.flush(); - await settleResize(terminal, 59, 30); - tui.renderNow(); - await terminal.flush(); terminal.takeWrites(); - - for (const report of ['\x1b[I', '\x1b[O', '\x1b[8;30;59t', '\x1b[?1u', '\x1b[?62;22c']) { - terminal.sendInput(report); - tui.renderNow(); - await terminal.flush(); - } - expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - - terminal.sendInput('x'); + terminal.resize(59, 30); + await new Promise((resolve) => setTimeout(resolve, 200)); tui.renderNow(); await terminal.flush(); expect(terminal.takeWrites()).toContain('\x1b[3J'); @@ -1020,27 +945,69 @@ describe('MCode Pi Engine local deltas', () => { } }); - it('replays deferred history before stopping', async () => { - const terminal = new RecordingVirtualTerminal(60, 30); - const tui = new TuiMainScreen(terminal); - const component = new MutableLines(); - component.lines = [...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), `composer${CURSOR_MARKER}`]; - tui.addChild(component); - tui.start(); + it.each([ + ['focus in', '\x1b[I'], + ['focus out', '\x1b[O'], + ['window size report', '\x1b[8;16;60t'], + ['cursor position report', '\x1b[12;40R'], + ['extended cursor position report', '\x1b[?12;40;1R'], + ['kitty keyboard flags', '\x1b[?1u'], + ['device attributes', '\x1b[?62;22c'], + ['device status', '\x1b[?997;1n'], + ['mode report', '\x1b[?2026;2$y'], + ['kitty key release', '\x1b[97;1:3u'], + ['mixed reports in one chunk', '\x1b[I\x1b[12;40R\x1b[?62;22c\x1bP>|xterm(1)\x1b\\\x1b[O'], + ])('does not treat a %s as user input', async (_name, report) => { + const terminal = new RecordingVirtualTerminal(60, 16); + const { tui, parts } = await renderShrinkingTasks(terminal); + terminal.scrollLines(-12); + await collapseTasks(terminal, tui, parts); + terminal.takeWrites(); + const before = terminal.getScrollPosition(); + + terminal.sendInput(report); tui.renderNow(); await terminal.flush(); - await settleResize(terminal, 59, 30); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollPosition()).toEqual(before); + }); + + it.each([ + ['a key after a cursor position report', '\x1b[12;40Rx'], + ['a key between reports', '\x1b[I\x1b[?62;22cx\x1b[12;40R'], + ['an arrow key after a focus report', '\x1b[I\x1b[A'], + ['a paste after a report', '\x1b[12;40R\x1b[200~pasted\x1b[201~'], + ['Escape', '\x1b'], + ])('treats %s as user input', async (_name, chunk) => { + const terminal = new RecordingVirtualTerminal(60, 16); + const { tui, parts, layout } = await renderShrinkingTasks(terminal); + terminal.scrollLines(-12); + await collapseTasks(terminal, tui, parts); + terminal.takeWrites(); + + terminal.scrollLines(Number.MAX_SAFE_INTEGER); + terminal.sendInput(chunk); tui.renderNow(); await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual(logicalDocument(layout, terminal)); + }); + + it('replays deferred history before stopping', async () => { + const terminal = new RecordingVirtualTerminal(60, 16); + const { tui, parts, layout } = await renderShrinkingTasks(terminal); + started.splice(started.indexOf(tui), 1); + terminal.scrollLines(-12); + await collapseTasks(terminal, tui, parts); terminal.takeWrites(); + const expected = logicalDocument(layout, terminal); tui.stop(); await terminal.flush(); expect(terminal.takeWrites()).toContain('\x1b[3J'); - expect(terminal.getScrollBuffer().slice(0, 81).map((line) => line.trim())).toEqual([ - ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), - 'composer', - ]); + expect(terminal.getScrollBuffer().slice(0, expected.length).map((line) => line.trimEnd())).toEqual( + expected.map((line) => line.trimEnd()), + ); }); });