From d69f7aac3681e215cc67b81459cd76790ad9b091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 11:49:31 +0800 Subject: [PATCH 01/13] fix(cli): clear the VP viewport on wake/SIGCONT repaints useWakeRepaint (#7265) repaints via refreshStatic after sleep/wake or SIGCONT, but in VP mode (default) refreshStatic neither cleared the screen nor repainted anything ( is not rendered in VP), so Ink's next relative erase ran against a stale/rearranged terminal buffer: frame-top residue (banner), frame-height jumps and high-frequency flicker on every terminal. Blank the alternate-screen viewport (2J+H, no 3J so scrollback / Warp block history survives) before the remount-driven repaint; static mode keeps its existing clearTerminal. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/AppContainer.test.tsx | 9 ++++++--- packages/cli/src/ui/AppContainer.tsx | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 6a66052271a..2471be1ee78 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -937,7 +937,7 @@ describe('AppContainer State Management', () => { expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal); }); - it('refreshStatic skips the physical clear in VP mode (#4891)', () => { + it('refreshStatic uses a viewport-only clear in VP mode (#4891, #8557)', () => { const vpSettings = { merged: { hideTips: false, @@ -963,8 +963,11 @@ describe('AppContainer State Management', () => { capturedUIActions.refreshStatic(); - // VP mode owns the viewport via the React tree, so refreshStatic must not - // emit a physical clear — the resize-settle path (#4891) strands nothing. + // After wake/SIGCONT the terminal buffer may be rearranged; without a + // clear, Ink's relative erase strands frame-top residue and flickers. + // The clear must be viewport-only: clearTerminal's 3J would destroy + // scrollback history. + expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearViewport); expect(mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fe870e0e9c8..24e9e77e18f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1306,6 +1306,15 @@ export const AppContainer = (props: AppContainerProps) => { const refreshStatic = useCallback(() => { if (!useTerminalBuffer) { stdout.write(ansiEscapes.clearTerminal); + } else { + // VP never renders , so the remount alone repaints nothing; + // after wake/SIGCONT the terminal buffer may be stale or rearranged and + // Ink's relative erase (internal previous-frame height) lands wrong, + // stranding frame-top residue and jumping the frame height (flicker). + // Blank the alternate-screen viewport so the remount-driven repaint + // starts clean. Viewport-only: clearTerminal's 3J would destroy + // scrollback / Warp block history. + stdout.write(ansiEscapes.clearViewport); } remountStaticHistory(); }, [useTerminalBuffer, remountStaticHistory, stdout]); From 6bea8b706ca2fd40460ebf9c5f37a4e7247569d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 15:39:53 +0800 Subject: [PATCH 02/13] fix(cli): repaint VP from a clean viewport after width shrinks On shrink the terminal reflows the printed frame into more physical rows than Ink's stale eraseLines count, so every subsequent redraw under-erases and strands the frame top (banner) as stacked duplicates on all terminals (issue #8557). For a short window after a shrink, start each VP redraw from a clean viewport (2J+H); Static mode keeps a conservative reflow-aware amplification so committed scrollback is never touched. --- packages/cli/src/ui/startInteractiveUI.tsx | 11 + .../ui/utils/terminal-resize-reflow.test.ts | 121 ++++++++++ .../src/ui/utils/terminal-resize-reflow.ts | 206 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 packages/cli/src/ui/utils/terminal-resize-reflow.test.ts create mode 100644 packages/cli/src/ui/utils/terminal-resize-reflow.ts diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index d27b93374bd..77188683f64 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -33,6 +33,7 @@ import { pushKittyProtocolFlags, } from './utils/kittyProtocolDetector.js'; import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js'; +import { installTerminalResizeReflow } from './utils/terminal-resize-reflow.js'; import { installSynchronizedOutput } from './utils/synchronizedOutput.js'; import { isInteractiveTerminal, @@ -161,6 +162,15 @@ export async function startInteractiveUI( isInteractiveTerminal(), ); + // On width shrink the terminal reflows the printed frame into more physical + // rows than Ink's stale erase count (issue #8557); amplify the clear to the + // reflowed height. Installed before render() so the resize listener runs + // ahead of Ink's resized(). + const restoreResizeReflow = + process.stdout.isTTY && !config.getScreenReader() + ? installTerminalResizeReflow(process.stdout, { virtualViewport: useVP }) + : () => {}; + // Create wrapper component to use hooks inside render const AppWrapper = () => { const kittyProtocolStatus = useKittyKeyboardProtocol(); @@ -311,6 +321,7 @@ export async function startInteractiveUI( process.stdout.setMaxListeners(stdoutMaxListeners); } restoreSynchronizedOutput(); + restoreResizeReflow(); restoreTerminalRedrawOptimizer(); // If the ErrorBoundary caught a rendering error, echo it to stderr // now that we are back on the main screen buffer. In VP mode the diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts new file mode 100644 index 00000000000..3ab2dc63068 --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { describe, expect, it } from 'vitest'; +import { installTerminalResizeReflow } from './terminal-resize-reflow.js'; + +const ESC = '\u001B['; + +function eraseLines(count: number): string { + let clear = ''; + for (let i = 0; i < count; i++) { + clear += `${ESC}2K` + (i < count - 1 ? `${ESC}1A` : ''); + } + if (count) clear += `${ESC}G`; + return clear; +} + +function frame(width: number, rows: number): string { + return Array.from({ length: rows }, () => 'x'.repeat(width)).join('\n'); +} + +class FakeStdout extends EventEmitter { + columns = 120; + rows = 40; + isTTY = true; + written: string[] = []; + write(chunk: string | Uint8Array, cb?: unknown): boolean { + this.written.push( + typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(), + ); + if (typeof cb === 'function') (cb as () => void)(); + return true; + } +} + +describe('installTerminalResizeReflow', () => { + it('amplifies the post-shrink erase to the reflowed frame height', () => { + const stdout = new FakeStdout(); + const restore = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + // A frame that reaches the terminal shapes the model (10 rows x 60). + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; // 60-wide rows reflow to 2 rows each + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(30, 20)); + expect(stdout.written.at(-1)).toBe(eraseLines(20) + frame(30, 20)); + } finally { + restore(); + } + }); + + it('VP mode replaces the stale clear with a viewport clear', () => { + const stdout = new FakeStdout(); + const restore = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + { virtualViewport: true }, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(30, 20)); + expect(stdout.written.at(-1)).toBe(`${ESC}2J${ESC}H` + frame(30, 20)); + } finally { + restore(); + } + }); + + it('leaves grows and pre-shrink writes untouched', () => { + const stdout = new FakeStdout(); + const restore = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 200; + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(60, 10)); + expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(60, 10)); + } finally { + restore(); + } + }); + + it('does not amplify Static-style appends (no erase prefix)', () => { + const stdout = new FakeStdout(); + const restore = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(frame(60, 10) + '\nappended history line'); + expect(stdout.written.at(-1)).toBe( + frame(60, 10) + '\nappended history line', + ); + } finally { + restore(); + } + }); + + it('passes writes through untouched after restore', () => { + const stdout = new FakeStdout(); + const restore = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + restore(); + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(30, 20)); + expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(30, 20)); + }); +}); diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts new file mode 100644 index 00000000000..5c98a6db049 --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import stringWidth from 'string-width'; +import stripAnsi from 'strip-ansi'; + +const debugLogger = createDebugLogger('RESIZE_REFLOW'); + +const ESC = '\u001B['; +const ERASE_LINE = `${ESC}2K`; +const CURSOR_UP_ONE = `${ESC}1A`; +const CURSOR_LEFT = `${ESC}G`; +const CLEAR_VIEWPORT = `${ESC}2J${ESC}H`; + +// How long after a shrink every VP redraw starts from a clean viewport. +const CLEAR_WINDOW_MS = 600; + +const ERASE_LINES_PATTERN = new RegExp( + `(?:${escapeRegExp(ERASE_LINE + CURSOR_UP_ONE)})+${escapeRegExp( + ERASE_LINE + CURSOR_LEFT, + )}`, +); + +// Live frames are >= 8 rows; shorter printable bursts (console output, Static +// history appends) must not be mistaken for a redraw. +const MIN_FRAME_LINES = 8; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function eraseLines(count: number): string { + let clear = ''; + for (let i = 0; i < count; i++) { + clear += ERASE_LINE + (i < count - 1 ? CURSOR_UP_ONE : ''); + } + if (count) { + clear += CURSOR_LEFT; + } + return clear; +} + +function countEraseLines(sequence: string): number { + let count = 0; + let index = 0; + while ((index = sequence.indexOf(ERASE_LINE, index)) !== -1) { + count++; + index += ERASE_LINE.length; + } + return count; +} + +function wrappedLineCount(width: number, columns: number): number { + if (columns <= 0) return 1; + return Math.max(1, Math.ceil(width / columns)); +} + +function reflowedHeight(lineWidths: number[], columns: number): number { + let total = 0; + for (const width of lineWidths) { + total += wrappedLineCount(width, columns); + } + return total; +} + +function reflowWidths(lineWidths: number[], columns: number): number[] { + const next: number[] = []; + for (const width of lineWidths) { + let remaining = width; + while (remaining > columns) { + next.push(columns); + remaining -= columns; + } + next.push(remaining); + } + return next; +} + +function frameLineWidths(content: string): number[] | undefined { + const lines = content.split('\n'); + if (content.endsWith('\n')) lines.pop(); + if (lines.length < MIN_FRAME_LINES) return undefined; + return lines.map((line) => stringWidth(stripAnsi(line))); +} + +export interface ResizeReflowOptions { + /** VP / alternate-screen mode: the shrink clear may blank the viewport. */ + virtualViewport?: boolean; +} + +/** + * Corrects Ink's shrink-time clear on reflow-capable terminals (issue #8557). + * + * Ink's `resized()` clears with `eraseLines(previousLineCount)` computed at + * the OLD width; after the terminal reflows the printed frame into more + * physical rows at the new width, that erase under-erases and the frame top + * (banner) is stranded as duplicate copies on every terminal. + * + * - VP (alternate screen): the whole viewport is ours, so the stale clear is + * replaced with a viewport-wide clear (2J+H) — exact row counts are + * uncomputable anyway (full-width wrap boundaries add rows no width model + * predicts), and over-erasing clamps harmlessly on the alt screen. + * - Static: the live region is amplified to the reflowed height of the last + * frame that actually reached the terminal; walking further up would eat + * committed scrollback, so the count stays conservative there. + */ +export function installTerminalResizeReflow( + stdout: NodeJS.WriteStream, + options: ResizeReflowOptions = {}, +): () => void { + if (process.env['QWEN_CODE_LEGACY_RESIZE_ERASE'] === '1') { + return () => {}; + } + const isVP = options.virtualViewport ?? false; + let lastWidth = stdout.columns ?? 0; + let lineWidths: number[] = []; + let pendingAmplify = 0; + // After a shrink, every redraw (not just Ink's clear) erases with a stale + // row count against the reflowed on-screen frame, re-stranding the frame + // top each time. For this window, start every VP redraw from a clean + // viewport instead. + let clearUntil = 0; + debugLogger.debug('installed', { width: lastWidth, isVP }); + + const onResize = () => { + const width = stdout.columns ?? lastWidth; + debugLogger.debug('resize-event', { + width, + lastWidth, + model: lineWidths.length, + }); + if (width > 0 && width < lastWidth && lineWidths.length > 0) { + if (isVP) { + clearUntil = Date.now() + CLEAR_WINDOW_MS; + } else { + pendingAmplify = reflowedHeight(lineWidths, width); + } + debugLogger.debug('shrink', { + from: lastWidth, + to: width, + modelLines: lineWidths.length, + pendingAmplify, + clearUntil, + }); + lineWidths = reflowWidths(lineWidths, width); + } + lastWidth = width; + }; + stdout.on('resize', onResize); + + const originalWrite = stdout.write; + const reflowWrite = function ( + this: NodeJS.WriteStream, + chunk: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) { + if (typeof chunk === 'string') { + ERASE_LINES_PATTERN.lastIndex = 0; + const match = ERASE_LINES_PATTERN.exec(chunk); + if (match) { + const widths = frameLineWidths( + chunk.slice(match.index + match[0].length), + ); + debugLogger.debug('match', { modelLines: widths?.length ?? 0 }); + if (widths) lineWidths = widths; + if (isVP && Date.now() < clearUntil) { + debugLogger.debug('clear-viewport'); + chunk = + chunk.slice(0, match.index) + + CLEAR_VIEWPORT + + chunk.slice(match.index + match[0].length); + } else if (pendingAmplify > 0) { + const count = countEraseLines(match[0]); + const target = pendingAmplify; + pendingAmplify = 0; + if (count < target) { + debugLogger.debug('amplify', { original: count, target }); + chunk = + chunk.slice(0, match.index) + + eraseLines(target) + + chunk.slice(match.index + match[0].length); + } + } + } + } + return originalWrite.call( + this, + chunk as string | Uint8Array, + encodingOrCallback as BufferEncoding, + callback, + ); + } as typeof stdout.write; + stdout.write = reflowWrite; + + return () => { + if (stdout.write === reflowWrite) { + stdout.write = originalWrite; + } + stdout.off('resize', onResize); + }; +} From 0ba19b4fe48f1e742374ae8e443912f667e96e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 10:19:48 +0800 Subject: [PATCH 03/13] fix(cli): enable DEC synchronized output on Warp to reduce redraw flicker Warp answers the DECRQM 2026 probe with status 2 (recognized, reset), so synchronized updates are available there. Without them Warp renders ink's erase-then-rewrite frame pattern as visible flicker (issue #8557). Add WarpTerminal to the synchronized-output allowlist; the existing QWEN_CODE_DISABLE_SYNCHRONIZED_OUTPUT escape hatch covers regressions. --- packages/cli/src/ui/utils/synchronizedOutput.test.ts | 3 +++ packages/cli/src/ui/utils/synchronizedOutput.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/synchronizedOutput.test.ts b/packages/cli/src/ui/utils/synchronizedOutput.test.ts index f9c9da0e1c0..22c26ef65cc 100644 --- a/packages/cli/src/ui/utils/synchronizedOutput.test.ts +++ b/packages/cli/src/ui/utils/synchronizedOutput.test.ts @@ -31,7 +31,10 @@ function createStdout(write: NodeJS.WriteStream['write']): NodeJS.WriteStream { describe('terminalSupportsSynchronizedOutput', () => { it.each([ [{ TERM_PROGRAM: 'WezTerm' }, true], + [{ TERM_PROGRAM: 'WarpTerminal' }, true], + [{ TERM_PROGRAM: 'ghostty' }, true], [{ TERM_PROGRAM: 'iTerm.app' }, true], + [{ TERM_PROGRAM: 'WarpTerminal' }, true], [{ TERM: 'xterm-kitty' }, true], [{ KITTY_WINDOW_ID: '1' }, true], [{ TERM_PROGRAM: 'Apple_Terminal' }, false], diff --git a/packages/cli/src/ui/utils/synchronizedOutput.ts b/packages/cli/src/ui/utils/synchronizedOutput.ts index fb75786ad27..af8425c4eff 100644 --- a/packages/cli/src/ui/utils/synchronizedOutput.ts +++ b/packages/cli/src/ui/utils/synchronizedOutput.ts @@ -53,7 +53,16 @@ export function terminalSupportsSynchronizedOutput( } const termProgram = env['TERM_PROGRAM']; - if (termProgram === 'WezTerm' || termProgram === 'iTerm.app') { + // Warp's DECRQM 2026 probe answers status 2 (recognized, reset), so + // synchronized updates are available there; without them Warp renders the + // erase-then-rewrite pattern as flicker (issue #8557). Ghostty implements + // synchronized output natively. + if ( + termProgram === 'WezTerm' || + termProgram === 'iTerm.app' || + termProgram === 'WarpTerminal' || + termProgram === 'ghostty' + ) { return true; } From 575ad269276545ee9c3c82b7fca62939affa804c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 17:24:26 +0800 Subject: [PATCH 04/13] fix(cli): guarantee VP wake repaint by replaying the last frame Ink skips redraws whose output is unchanged, so the VP wake/SIGCONT path's viewport clear could leave the screen blank until the next state change (review #8831). The resize-reflow wrapper now caches the last frame that reached the terminal and repaint() replays it over a clean viewport; the stale design-rationale comment is rewritten to match. --- packages/cli/src/ui/AppContainer.tsx | 46 +++++++++++-------- packages/cli/src/ui/startInteractiveUI.tsx | 7 +-- .../ui/utils/terminal-resize-reflow.test.ts | 26 +++++++++-- .../src/ui/utils/terminal-resize-reflow.ts | 40 +++++++++++----- 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 39865d5f8a6..18669b20fa2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -624,6 +624,12 @@ interface AppContainerProps { initializationResult: InitializationResult; initialUseVirtualViewport?: boolean; extensionRefreshState?: ExtensionRefreshState; + /** + * VP wake/SIGCONT repaint: clear the viewport and replay the last frame + * (Ink skips unchanged-output redraws, so a bare clear would blank the + * screen). Falls back to a viewport clear when absent. + */ + repaintViewport?: () => void; } /** @@ -639,8 +645,13 @@ const SHELL_WIDTH_FRACTION = 0.89; const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { - const { settings, config, initializationResult, initialUseVirtualViewport } = - props; + const { + settings, + config, + initializationResult, + initialUseVirtualViewport, + repaintViewport, + } = props; const extensionRefreshState = useMemo( () => props.extensionRefreshState ?? new ExtensionRefreshState(), [props.extensionRefreshState], @@ -1287,15 +1298,17 @@ export const AppContainer = (props: AppContainerProps) => { }, []); // In VP mode (ui.useTerminalBuffer) the React tree fully owns the visible - // region via ink 7 native overflow clipping. Writing clearTerminal / - // cursorTo+eraseDown would be a wasted flash and would also corrupt the - // in-app scroll position. The remount-key bump is also a near-no-op for - // VP: nothing in the VP render path is keyed by historyRemountKey, so - // keeping the bump is harmless because the startup-scoped VP decision - // is intentionally restart-only to match Ink's alternateScreen lifetime. - // The visible refresh in VP mode comes for free from the React tree - // re-reading `mergedHistory` / `allVirtualItems` on whatever state - // change triggered refreshStatic (Ctrl+O, model change, etc.). + // region via ink 7 native overflow clipping, and the remount-key bump is a + // near-no-op for VP (nothing in the VP render path is keyed by + // historyRemountKey; the startup-scoped VP decision is intentionally + // restart-only to match Ink's alternateScreen lifetime). Ordinary + // refreshStatic callers (Ctrl+O, model change, ...) get their visible + // refresh for free from the state change that triggered them. The wake / + // SIGCONT path is different: the terminal buffer may be stale or + // rearranged, and Ink both erases with a stale relative count and skips + // redraws whose output is unchanged — so VP repaints there by replaying + // the last frame over a clean viewport (repaintViewport), viewport-only + // because clearTerminal's 3J would destroy scrollback / Warp history. const [useTerminalBuffer] = useState( () => initialUseVirtualViewport ?? @@ -1310,17 +1323,10 @@ export const AppContainer = (props: AppContainerProps) => { if (!useTerminalBuffer) { stdout.write(ansiEscapes.clearTerminal); } else { - // VP never renders , so the remount alone repaints nothing; - // after wake/SIGCONT the terminal buffer may be stale or rearranged and - // Ink's relative erase (internal previous-frame height) lands wrong, - // stranding frame-top residue and jumping the frame height (flicker). - // Blank the alternate-screen viewport so the remount-driven repaint - // starts clean. Viewport-only: clearTerminal's 3J would destroy - // scrollback / Warp block history. - stdout.write(ansiEscapes.clearViewport); + (repaintViewport ?? (() => stdout.write(ansiEscapes.clearViewport)))(); } remountStaticHistory(); - }, [useTerminalBuffer, remountStaticHistory, stdout]); + }, [useTerminalBuffer, remountStaticHistory, repaintViewport, stdout]); // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 1098d17c16b..b93a80cc949 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -169,10 +169,10 @@ export async function startInteractiveUI( // rows than Ink's stale erase count (issue #8557); amplify the clear to the // reflowed height. Installed before render() so the resize listener runs // ahead of Ink's resized(). - const restoreResizeReflow = + const resizeReflow = process.stdout.isTTY && !config.getScreenReader() ? installTerminalResizeReflow(process.stdout, { virtualViewport: useVP }) - : () => {}; + : { restore: () => {}, repaint: () => {} }; // Create wrapper component to use hooks inside render const AppWrapper = () => { @@ -205,6 +205,7 @@ export async function startInteractiveUI( initializationResult={initializationResult} initialUseVirtualViewport={useVP} extensionRefreshState={options.extensionRefreshState} + repaintViewport={resizeReflow.repaint} /> @@ -324,7 +325,7 @@ export async function startInteractiveUI( process.stdout.setMaxListeners(stdoutMaxListeners); } restoreSynchronizedOutput(); - restoreResizeReflow(); + resizeReflow.restore(); restoreTerminalRedrawOptimizer(); // If the ErrorBoundary caught a rendering error, echo it to stderr // now that we are back on the main screen buffer. In VP mode the diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index 3ab2dc63068..cdf531af8f9 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -40,7 +40,7 @@ class FakeStdout extends EventEmitter { describe('installTerminalResizeReflow', () => { it('amplifies the post-shrink erase to the reflowed frame height', () => { const stdout = new FakeStdout(); - const restore = installTerminalResizeReflow( + const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, ); try { @@ -57,7 +57,7 @@ describe('installTerminalResizeReflow', () => { it('VP mode replaces the stale clear with a viewport clear', () => { const stdout = new FakeStdout(); - const restore = installTerminalResizeReflow( + const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, { virtualViewport: true }, ); @@ -74,7 +74,7 @@ describe('installTerminalResizeReflow', () => { it('leaves grows and pre-shrink writes untouched', () => { const stdout = new FakeStdout(); - const restore = installTerminalResizeReflow( + const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, ); try { @@ -90,7 +90,7 @@ describe('installTerminalResizeReflow', () => { it('does not amplify Static-style appends (no erase prefix)', () => { const stdout = new FakeStdout(); - const restore = installTerminalResizeReflow( + const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, ); try { @@ -106,9 +106,25 @@ describe('installTerminalResizeReflow', () => { } }); + it('repaint replays the last frame over a clean viewport', () => { + const stdout = new FakeStdout(); + const { restore, repaint } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + { virtualViewport: true }, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.written.length = 0; + repaint(); + expect(stdout.written).toEqual([`${ESC}2J${ESC}H` + frame(60, 10)]); + } finally { + restore(); + } + }); + it('passes writes through untouched after restore', () => { const stdout = new FakeStdout(); - const restore = installTerminalResizeReflow( + const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, ); restore(); diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts index 5c98a6db049..aa33401a149 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -92,6 +92,16 @@ export interface ResizeReflowOptions { virtualViewport?: boolean; } +export interface TerminalResizeReflowHandle { + restore: () => void; + /** + * Clear the viewport and replay the last frame that reached the terminal. + * Ink skips redraws whose output is unchanged, so a wake/SIGCONT repaint + * cannot rely on React alone after an external clear (review #8831). + */ + repaint: () => void; +} + /** * Corrects Ink's shrink-time clear on reflow-capable terminals (issue #8557). * @@ -111,13 +121,14 @@ export interface ResizeReflowOptions { export function installTerminalResizeReflow( stdout: NodeJS.WriteStream, options: ResizeReflowOptions = {}, -): () => void { +): TerminalResizeReflowHandle { if (process.env['QWEN_CODE_LEGACY_RESIZE_ERASE'] === '1') { - return () => {}; + return { restore: () => {}, repaint: () => {} }; } const isVP = options.virtualViewport ?? false; let lastWidth = stdout.columns ?? 0; let lineWidths: number[] = []; + let lastFrameContent = ''; let pendingAmplify = 0; // After a shrink, every redraw (not just Ink's clear) erases with a stale // row count against the reflowed on-screen frame, re-stranding the frame @@ -163,11 +174,13 @@ export function installTerminalResizeReflow( ERASE_LINES_PATTERN.lastIndex = 0; const match = ERASE_LINES_PATTERN.exec(chunk); if (match) { - const widths = frameLineWidths( - chunk.slice(match.index + match[0].length), - ); + const content = chunk.slice(match.index + match[0].length); + const widths = frameLineWidths(content); debugLogger.debug('match', { modelLines: widths?.length ?? 0 }); - if (widths) lineWidths = widths; + if (widths) { + lineWidths = widths; + lastFrameContent = content; + } if (isVP && Date.now() < clearUntil) { debugLogger.debug('clear-viewport'); chunk = @@ -197,10 +210,15 @@ export function installTerminalResizeReflow( } as typeof stdout.write; stdout.write = reflowWrite; - return () => { - if (stdout.write === reflowWrite) { - stdout.write = originalWrite; - } - stdout.off('resize', onResize); + return { + restore: () => { + if (stdout.write === reflowWrite) { + stdout.write = originalWrite; + } + stdout.off('resize', onResize); + }, + repaint: () => { + originalWrite.call(stdout, CLEAR_VIEWPORT + lastFrameContent); + }, }; } From 50d755b3f5f823ec90cb24cb61865cbc45435896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 19:26:59 +0800 Subject: [PATCH 05/13] =?UTF-8?q?fix(cli):=20address=20#8831=20review=20?= =?UTF-8?q?=E2=80=94=20LIFO=20teardown,=20grow=20reset,=20bare-redraw=20mo?= =?UTF-8?q?del=20handoff,=20wake-only=20repaint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unwind the stdout.write wrapper stack in LIFO order so the identity- guarded restores do not leak wrappers (Critical). - Reset a pending static-mode amplification on grow so a stale count can never over-erase into committed scrollback (Critical). - Hand the frame model over to Ink's bare post-shrink redraw (log.clear resets its counter, so the redraw carries no erase prefix); consecutive shrinks now amplify from the actual post-shrink frame (Critical). - repaint() skips the replay when the cached frame's width differs from the current viewport (Critical). - Route the clear-and-replay through useWakeRepaint only; refreshStatic's VP branch stays write-free for ordinary callers (/clear, model change, ...) so stale frames never flash back (Critical). - Use ansi-escapes exports instead of hand-rolled ANSI constants; drop the duplicated WarpTerminal test row; add tests for the grow reset, the bare- redraw handoff, the MIN_FRAME_LINES guard and the clear-window expiry. --- packages/cli/src/ui/AppContainer.test.tsx | 19 +++-- packages/cli/src/ui/AppContainer.tsx | 39 ++++++--- packages/cli/src/ui/startInteractiveUI.tsx | 5 +- .../src/ui/utils/synchronizedOutput.test.ts | 1 - .../ui/utils/terminal-resize-reflow.test.ts | 80 +++++++++++++++++- .../src/ui/utils/terminal-resize-reflow.ts | 81 ++++++++++++------- 6 files changed, 174 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index f4048c33ddf..73119a5a6e1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -954,7 +954,7 @@ describe('AppContainer State Management', () => { expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal); }); - it('refreshStatic uses a viewport-only clear in VP mode (#4891, #8557)', () => { + it('refreshStatic stays write-free in VP mode for ordinary callers (#8557)', () => { const vpSettings = { merged: { hideTips: false, @@ -980,16 +980,23 @@ describe('AppContainer State Management', () => { capturedUIActions.refreshStatic(); - // After wake/SIGCONT the terminal buffer may be rearranged; without a - // clear, Ink's relative erase strands frame-top residue and flickers. - // The clear must be viewport-only: clearTerminal's 3J would destroy - // scrollback history. - expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearViewport); + // Ordinary callers (/clear, model change, Ctrl+O, ...) must not + // trigger a physical clear-and-replay in VP: replaying the pre-change + // frame would flash stale content. Their refresh comes from the state + // change that triggered them; only the wake path repaints physically. + expect(mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearViewport, + ); expect(mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); }); + // The wake/SIGCONT trigger itself is covered by use-wake-repaint.test.ts + // (SIGCONT/heartbeat-gap -> repaint callback); the VP/static selection in + // wakeRepaint is exercised manually because ink-testing-library does not + // flush AppContainer effects, so the listener never arms in this harness. + it('defaults to VP mode when useTerminalBuffer is unset', () => { const defaultSettings = { merged: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 18669b20fa2..db3bd417b4a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1301,14 +1301,14 @@ export const AppContainer = (props: AppContainerProps) => { // region via ink 7 native overflow clipping, and the remount-key bump is a // near-no-op for VP (nothing in the VP render path is keyed by // historyRemountKey; the startup-scoped VP decision is intentionally - // restart-only to match Ink's alternateScreen lifetime). Ordinary - // refreshStatic callers (Ctrl+O, model change, ...) get their visible - // refresh for free from the state change that triggered them. The wake / - // SIGCONT path is different: the terminal buffer may be stale or - // rearranged, and Ink both erases with a stale relative count and skips - // redraws whose output is unchanged — so VP repaints there by replaying - // the last frame over a clean viewport (repaintViewport), viewport-only - // because clearTerminal's 3J would destroy scrollback / Warp history. + // restart-only to match Ink's alternateScreen lifetime). refreshStatic must + // stay write-free in VP: ordinary callers (Ctrl+O, model change, /clear, + // ...) get their visible refresh for free from the state change that + // triggered them, and replaying the pre-change frame would flash stale + // content. Only the wake/SIGCONT path (wakeRepaint below) does a physical + // clear-and-replay, because there the terminal buffer may be stale or + // rearranged while Ink both erases with a stale relative count and skips + // redraws whose output is unchanged. const [useTerminalBuffer] = useState( () => initialUseVirtualViewport ?? @@ -1322,11 +1322,26 @@ export const AppContainer = (props: AppContainerProps) => { const refreshStatic = useCallback(() => { if (!useTerminalBuffer) { stdout.write(ansiEscapes.clearTerminal); - } else { - (repaintViewport ?? (() => stdout.write(ansiEscapes.clearViewport)))(); } + // VP stays write-free for ordinary callers (/clear, model change, Ctrl+O, + // ...): replaying the pre-change frame would flash stale content. Their + // visible refresh comes from the state change that triggered them. The + // wake/SIGCONT path repaints separately via useWakeRepaint below. remountStaticHistory(); - }, [useTerminalBuffer, remountStaticHistory, repaintViewport, stdout]); + }, [useTerminalBuffer, remountStaticHistory, stdout]); + + // Wake/SIGCONT: the terminal buffer may be stale or rearranged, and Ink + // both erases with a stale relative count and skips redraws whose output + // is unchanged — so VP repaints by replaying the last frame over a clean + // viewport (viewport-only: clearTerminal's 3J would destroy scrollback / + // Warp history). Static mode uses the ordinary refreshStatic. + const wakeRepaint = useCallback(() => { + if (useTerminalBuffer) { + (repaintViewport ?? (() => stdout.write(ansiEscapes.clearViewport)))(); + } else { + refreshStatic(); + } + }, [useTerminalBuffer, repaintViewport, refreshStatic, stdout]); // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly @@ -3417,7 +3432,7 @@ export const AppContainer = (props: AppContainerProps) => { // display sleep, Ctrl+Z → fg). The terminal's screen buffer is stale but // Ink's frame-diff state still reflects the pre-sleep output, so the next // render strands border characters on screen. - useWakeRepaint(refreshStatic); + useWakeRepaint(wakeRepaint); useEffect(() => { if (ideNeedsRestart) { diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index b93a80cc949..c3c26022c7d 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -324,8 +324,11 @@ export async function startInteractiveUI( if (useVP) { process.stdout.setMaxListeners(stdoutMaxListeners); } - restoreSynchronizedOutput(); + // Unwind the stdout.write wrapper stack in LIFO order (resizeReflow is + // installed last / outermost); the identity-guarded restores silently + // no-op and leak wrappers otherwise. resizeReflow.restore(); + restoreSynchronizedOutput(); restoreTerminalRedrawOptimizer(); // If the ErrorBoundary caught a rendering error, echo it to stderr // now that we are back on the main screen buffer. In VP mode the diff --git a/packages/cli/src/ui/utils/synchronizedOutput.test.ts b/packages/cli/src/ui/utils/synchronizedOutput.test.ts index 22c26ef65cc..144526e111d 100644 --- a/packages/cli/src/ui/utils/synchronizedOutput.test.ts +++ b/packages/cli/src/ui/utils/synchronizedOutput.test.ts @@ -34,7 +34,6 @@ describe('terminalSupportsSynchronizedOutput', () => { [{ TERM_PROGRAM: 'WarpTerminal' }, true], [{ TERM_PROGRAM: 'ghostty' }, true], [{ TERM_PROGRAM: 'iTerm.app' }, true], - [{ TERM_PROGRAM: 'WarpTerminal' }, true], [{ TERM: 'xterm-kitty' }, true], [{ KITTY_WINDOW_ID: '1' }, true], [{ TERM_PROGRAM: 'Apple_Terminal' }, false], diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index cdf531af8f9..ec12df4177c 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -5,7 +5,7 @@ */ import { EventEmitter } from 'node:events'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { installTerminalResizeReflow } from './terminal-resize-reflow.js'; const ESC = '\u001B['; @@ -106,6 +106,84 @@ describe('installTerminalResizeReflow', () => { } }); + it('a grow before the next erase resets a pending amplification', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.columns = 120; + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(60, 10)); + expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(60, 10)); + } finally { + restore(); + } + }); + + it('models the bare post-shrink redraw so consecutive shrinks amplify correctly', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); // clear, amplified 10 -> 20 + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + stdout.write(frame(30, 20)); // bare redraw re-models: 20 rows x 30 + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(20)); + expect(stdout.written.at(-1)).toBe(eraseLines(40)); + } finally { + restore(); + } + }); + + it('short erase-prefixed bursts do not clobber the frame model', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.write(eraseLines(3) + frame(60, 3)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + } finally { + restore(); + } + }); + + it('the VP clear window expires', () => { + vi.useFakeTimers(); + try { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + { virtualViewport: true }, + ); + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(30, 20)); + expect(stdout.written.at(-1)).toBe(`${ESC}2J${ESC}H` + frame(30, 20)); + vi.advanceTimersByTime(601); + stdout.write(eraseLines(20) + frame(30, 20)); + expect(stdout.written.at(-1)).toBe(eraseLines(20) + frame(30, 20)); + restore(); + } finally { + vi.useRealTimers(); + } + }); + it('repaint replays the last frame over a clean viewport', () => { const stdout = new FakeStdout(); const { restore, repaint } = installTerminalResizeReflow( diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts index aa33401a149..396610762ea 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -4,17 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import ansiEscapes from 'ansi-escapes'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import stringWidth from 'string-width'; import stripAnsi from 'strip-ansi'; const debugLogger = createDebugLogger('RESIZE_REFLOW'); -const ESC = '\u001B['; -const ERASE_LINE = `${ESC}2K`; -const CURSOR_UP_ONE = `${ESC}1A`; -const CURSOR_LEFT = `${ESC}G`; -const CLEAR_VIEWPORT = `${ESC}2J${ESC}H`; +const ERASE_LINE = ansiEscapes.eraseLine; +const CURSOR_UP_ONE = ansiEscapes.cursorUp(); +const CURSOR_LEFT = ansiEscapes.cursorLeft; +const CLEAR_VIEWPORT = ansiEscapes.clearViewport; // How long after a shrink every VP redraw starts from a clean viewport. const CLEAR_WINDOW_MS = 600; @@ -25,25 +25,14 @@ const ERASE_LINES_PATTERN = new RegExp( )}`, ); -// Live frames are >= 8 rows; shorter printable bursts (console output, Static -// history appends) must not be mistaken for a redraw. +// Live frames are >= 8 rows; shorter printable bursts (console output, small +// redraws) must not be mistaken for a frame and clobber the model. const MIN_FRAME_LINES = 8; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function eraseLines(count: number): string { - let clear = ''; - for (let i = 0; i < count; i++) { - clear += ERASE_LINE + (i < count - 1 ? CURSOR_UP_ONE : ''); - } - if (count) { - clear += CURSOR_LEFT; - } - return clear; -} - function countEraseLines(sequence: string): number { let count = 0; let index = 0; @@ -97,7 +86,9 @@ export interface TerminalResizeReflowHandle { /** * Clear the viewport and replay the last frame that reached the terminal. * Ink skips redraws whose output is unchanged, so a wake/SIGCONT repaint - * cannot rely on React alone after an external clear (review #8831). + * cannot rely on React alone after an external clear. Only the wake path + * may call this — ordinary refreshStatic callers must stay write-free in + * VP (replaying the pre-change frame would flash stale content). */ repaint: () => void; } @@ -110,10 +101,11 @@ export interface TerminalResizeReflowHandle { * physical rows at the new width, that erase under-erases and the frame top * (banner) is stranded as duplicate copies on every terminal. * - * - VP (alternate screen): the whole viewport is ours, so the stale clear is - * replaced with a viewport-wide clear (2J+H) — exact row counts are - * uncomputable anyway (full-width wrap boundaries add rows no width model - * predicts), and over-erasing clamps harmlessly on the alt screen. + * - VP (alternate screen): the whole viewport is ours, so for a short window + * after a shrink every redraw starts from a viewport-wide clear (2J+H) — + * exact row counts are uncomputable anyway (full-width wrap boundaries add + * rows no width model predicts), and over-erasing clamps harmlessly on the + * alt screen. * - Static: the live region is amplified to the reflowed height of the last * frame that actually reached the terminal; walking further up would eat * committed scrollback, so the count stays conservative there. @@ -129,7 +121,12 @@ export function installTerminalResizeReflow( let lastWidth = stdout.columns ?? 0; let lineWidths: number[] = []; let lastFrameContent = ''; + let cacheColumns = 0; let pendingAmplify = 0; + // Ink's post-shrink redraw arrives bare (log.clear() resets its counter to + // 0), so the erase-prefixed model update never sees it; this flag hands + // the modeling over to the next write. + let expectFrame = false; // After a shrink, every redraw (not just Ink's clear) erases with a stale // row count against the reflowed on-screen frame, re-stranding the frame // top each time. For this window, start every VP redraw from a clean @@ -137,6 +134,16 @@ export function installTerminalResizeReflow( let clearUntil = 0; debugLogger.debug('installed', { width: lastWidth, isVP }); + const modelFrame = (content: string) => { + const widths = frameLineWidths(content); + if (widths) { + lineWidths = widths; + lastFrameContent = content; + cacheColumns = stdout.columns ?? lastWidth; + } + return widths; + }; + const onResize = () => { const width = stdout.columns ?? lastWidth; debugLogger.debug('resize-event', { @@ -158,6 +165,11 @@ export function installTerminalResizeReflow( clearUntil, }); lineWidths = reflowWidths(lineWidths, width); + } else if (width > lastWidth) { + // A grow invalidates a pending shrink amplification: the stale count + // was computed for a narrower width and would over-erase past the live + // frame into committed scrollback. + pendingAmplify = 0; } lastWidth = width; }; @@ -175,11 +187,11 @@ export function installTerminalResizeReflow( const match = ERASE_LINES_PATTERN.exec(chunk); if (match) { const content = chunk.slice(match.index + match[0].length); - const widths = frameLineWidths(content); - debugLogger.debug('match', { modelLines: widths?.length ?? 0 }); - if (widths) { - lineWidths = widths; - lastFrameContent = content; + modelFrame(content); + debugLogger.debug('match', { modelLines: lineWidths.length }); + if (stripAnsi(content).trim() === '') { + // Clear-only write (Ink's log.clear): the redraw follows bare. + expectFrame = true; } if (isVP && Date.now() < clearUntil) { debugLogger.debug('clear-viewport'); @@ -195,10 +207,13 @@ export function installTerminalResizeReflow( debugLogger.debug('amplify', { original: count, target }); chunk = chunk.slice(0, match.index) + - eraseLines(target) + + ansiEscapes.eraseLines(target) + chunk.slice(match.index + match[0].length); } } + } else if (expectFrame) { + expectFrame = false; + modelFrame(chunk); } } return originalWrite.call( @@ -218,7 +233,13 @@ export function installTerminalResizeReflow( stdout.off('resize', onResize); }, repaint: () => { - originalWrite.call(stdout, CLEAR_VIEWPORT + lastFrameContent); + const columns = stdout.columns ?? lastWidth; + originalWrite.call( + stdout, + cacheColumns === columns && lastFrameContent + ? CLEAR_VIEWPORT + lastFrameContent + : CLEAR_VIEWPORT, + ); }, }; } From c3229b26157e88c4f20bc0297b25c0510935ad63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 22:20:45 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix(cli):=20address=20#8831=20R3=20review?= =?UTF-8?q?=20=E2=80=94=20model=20fidelity=20and=20wake=20remount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frame model now lazy and terminal-faithful: per-character greedy packing (wide chars waste a row-tail cell), physical-row segmentation on shrink (terminals re-wrap rows without re-joining), and Ink's cursor-below line included for frames ending with a newline (R3-3, R3-4, R3-5, R3-9). - expectFrame handoff survives Ink's real write sequence: standalone synchronized-output control writes no longer consume it, and consecutive bare writes re-model with last-wins so static commits model the live frame, not the transcript (R3-1, R3-14). - VP wake path bumps historyRemountKey again so one-shot history (agent tabs) is re-emitted over the clear; selection extracted into buildWakeRepaint for unit coverage (R3-2, R2-8). - Shared erase grammar helpers exported from terminalRedrawOptimizer (R3-13); tests added for the escape hatch, repaint fallbacks, BSU sequences, static commits, trailing newlines and CJK packing (R3-10, R3-11, R3-12). --- packages/cli/src/ui/AppContainer.tsx | 51 +++-- .../ui/utils/terminal-resize-reflow.test.ts | 178 +++++++++++++++-- .../src/ui/utils/terminal-resize-reflow.ts | 188 +++++++++++------- .../src/ui/utils/terminalRedrawOptimizer.ts | 26 ++- 4 files changed, 316 insertions(+), 127 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index db3bd417b4a..54d60c55208 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -78,6 +78,7 @@ import { buildResumedHistoryItems, expandCollapsedHistory, } from './utils/resumeHistoryUtils.js'; +import { buildWakeRepaint } from './utils/terminal-resize-reflow.js'; import { loadLowlight } from './utils/lowlightLoader.js'; import { getStickyTodos, @@ -1298,17 +1299,16 @@ export const AppContainer = (props: AppContainerProps) => { }, []); // In VP mode (ui.useTerminalBuffer) the React tree fully owns the visible - // region via ink 7 native overflow clipping, and the remount-key bump is a - // near-no-op for VP (nothing in the VP render path is keyed by - // historyRemountKey; the startup-scoped VP decision is intentionally - // restart-only to match Ink's alternateScreen lifetime). refreshStatic must - // stay write-free in VP: ordinary callers (Ctrl+O, model change, /clear, - // ...) get their visible refresh for free from the state change that - // triggered them, and replaying the pre-change frame would flash stale - // content. Only the wake/SIGCONT path (wakeRepaint below) does a physical - // clear-and-replay, because there the terminal buffer may be stale or - // rearranged while Ink both erases with a stale relative count and skips - // redraws whose output is unchanged. + // region via ink 7 native overflow clipping. The remount-key bump is + // write-free but not inert: one-shot output keyed by it (agent + // tab history in AgentChatContent) is only re-emitted on a bump. + // refreshStatic must stay write-free in VP: ordinary callers (Ctrl+O, + // model change, /clear, ...) get their visible refresh from the state + // change that triggered them, and replaying the pre-change frame would + // flash stale content. Only the wake/SIGCONT path (wakeRepaint below) does + // a physical clear-and-replay, because there the terminal buffer may be + // stale or rearranged while Ink both erases with a stale relative count + // and skips redraws whose output is unchanged. const [useTerminalBuffer] = useState( () => initialUseVirtualViewport ?? @@ -1334,14 +1334,27 @@ export const AppContainer = (props: AppContainerProps) => { // both erases with a stale relative count and skips redraws whose output // is unchanged — so VP repaints by replaying the last frame over a clean // viewport (viewport-only: clearTerminal's 3J would destroy scrollback / - // Warp history). Static mode uses the ordinary refreshStatic. - const wakeRepaint = useCallback(() => { - if (useTerminalBuffer) { - (repaintViewport ?? (() => stdout.write(ansiEscapes.clearViewport)))(); - } else { - refreshStatic(); - } - }, [useTerminalBuffer, repaintViewport, refreshStatic, stdout]); + // Warp history) and bumps the static remount key so one-shot + // history (agent tabs) is re-emitted over the clear. Static mode uses the + // ordinary refreshStatic. Selection extracted (buildWakeRepaint) for unit + // coverage. + const wakeRepaint = useMemo( + () => + buildWakeRepaint({ + isVP: useTerminalBuffer, + repaintViewport, + clearViewportFallback: () => stdout.write(ansiEscapes.clearViewport), + refreshStatic, + remountStaticHistory, + }), + [ + useTerminalBuffer, + repaintViewport, + stdout, + refreshStatic, + remountStaticHistory, + ], + ); // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index ec12df4177c..842595b8949 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -6,9 +6,13 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; -import { installTerminalResizeReflow } from './terminal-resize-reflow.js'; +import { + buildWakeRepaint, + installTerminalResizeReflow, +} from './terminal-resize-reflow.js'; const ESC = '\u001B['; +const BSU = `${ESC}?2026h`; function eraseLines(count: number): string { let clear = ''; @@ -19,8 +23,9 @@ function eraseLines(count: number): string { return clear; } -function frame(width: number, rows: number): string { - return Array.from({ length: rows }, () => 'x'.repeat(width)).join('\n'); +function frame(width: number, rows: number, trailingNewline = false): string { + const s = Array.from({ length: rows }, () => 'x'.repeat(width)).join('\n'); + return trailingNewline ? s + '\n' : s; } class FakeStdout extends EventEmitter { @@ -44,12 +49,11 @@ describe('installTerminalResizeReflow', () => { stdout as unknown as NodeJS.WriteStream, ); try { - // A frame that reaches the terminal shapes the model (10 rows x 60). stdout.write(eraseLines(10) + frame(60, 10)); - stdout.columns = 30; // 60-wide rows reflow to 2 rows each + stdout.columns = 30; stdout.emit('resize'); - stdout.write(eraseLines(10) + frame(30, 20)); - expect(stdout.written.at(-1)).toBe(eraseLines(20) + frame(30, 20)); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); } finally { restore(); } @@ -72,14 +76,16 @@ describe('installTerminalResizeReflow', () => { } }); - it('leaves grows and pre-shrink writes untouched', () => { + it('a grow before the next erase resets a pending amplification', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, ); try { stdout.write(eraseLines(10) + frame(60, 10)); - stdout.columns = 200; + stdout.columns = 30; + stdout.emit('resize'); + stdout.columns = 120; stdout.emit('resize'); stdout.write(eraseLines(10) + frame(60, 10)); expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(60, 10)); @@ -88,7 +94,7 @@ describe('installTerminalResizeReflow', () => { } }); - it('does not amplify Static-style appends (no erase prefix)', () => { + it('models the bare post-shrink redraw (divergent geometry)', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, @@ -97,16 +103,22 @@ describe('installTerminalResizeReflow', () => { stdout.write(eraseLines(10) + frame(60, 10)); stdout.columns = 30; stdout.emit('resize'); - stdout.write(frame(60, 10) + '\nappended history line'); - expect(stdout.written.at(-1)).toBe( - frame(60, 10) + '\nappended history line', - ); + stdout.write(eraseLines(10)); // clear, amplified 10 -> 20 + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + // Bare redraw re-models with a row count the width model did not + // predict; deleting the expectFrame branch would keep the stale 20-row + // model and amplify to 40 instead of 44 below. + stdout.write(frame(30, 22)); + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(22)); + expect(stdout.written.at(-1)).toBe(eraseLines(44)); } finally { restore(); } }); - it('a grow before the next erase resets a pending amplification', () => { + it('ignores standalone synchronized-output writes between clear and frame', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, @@ -115,16 +127,19 @@ describe('installTerminalResizeReflow', () => { stdout.write(eraseLines(10) + frame(60, 10)); stdout.columns = 30; stdout.emit('resize'); - stdout.columns = 120; + stdout.write(eraseLines(10)); // clear arms the handoff + stdout.write(BSU); // control write must not consume it + stdout.write(frame(30, 22)); // live frame models (last wins) + stdout.columns = 15; stdout.emit('resize'); - stdout.write(eraseLines(10) + frame(60, 10)); - expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(60, 10)); + stdout.write(eraseLines(22)); + expect(stdout.written.at(-1)).toBe(eraseLines(44)); } finally { restore(); } }); - it('models the bare post-shrink redraw so consecutive shrinks amplify correctly', () => { + it('static-commit sequences model the live frame, not the transcript', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, @@ -133,18 +148,52 @@ describe('installTerminalResizeReflow', () => { stdout.write(eraseLines(10) + frame(60, 10)); stdout.columns = 30; stdout.emit('resize'); - stdout.write(eraseLines(10)); // clear, amplified 10 -> 20 - expect(stdout.written.at(-1)).toBe(eraseLines(20)); - stdout.write(frame(30, 20)); // bare redraw re-models: 20 rows x 30 + stdout.write(eraseLines(10)); // clear arms the handoff + stdout.write(frame(60, 12)); // static append (>= 8 rows) models first... + stdout.write(frame(30, 20)); // ...live frame wins (last bare write) stdout.columns = 15; stdout.emit('resize'); stdout.write(eraseLines(20)); + // From the 20-row live frame (30-wide rows -> 2 rows each at 15). expect(stdout.written.at(-1)).toBe(eraseLines(40)); } finally { restore(); } }); + it('includes Ink cursor-below line for frames ending with a newline', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(11) + frame(60, 10, true)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(11)); + expect(stdout.written.at(-1)).toBe(eraseLines(21)); + } finally { + restore(); + } + }); + + it('greedy-packs wide characters like the terminal reflow', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + const cjk = Array.from({ length: 10 }, () => '中'.repeat(3)).join('\n'); + stdout.write(eraseLines(10) + cjk); // 3 wide chars (6 cells) per row + stdout.columns = 3; // greedy: one wide char per row -> 3 rows each + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(30)); + } finally { + restore(); + } + }); + it('short erase-prefixed bursts do not clobber the frame model', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( @@ -200,6 +249,57 @@ describe('installTerminalResizeReflow', () => { } }); + it('repaint falls back to a bare clear when the width changed', () => { + const stdout = new FakeStdout(); + const { restore, repaint } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + { virtualViewport: true }, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 80; + stdout.written.length = 0; + repaint(); + expect(stdout.written).toEqual([`${ESC}2J${ESC}H`]); + } finally { + restore(); + } + }); + + it('repaint before any frame is a bare clear', () => { + const stdout = new FakeStdout(); + const { restore, repaint } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + { virtualViewport: true }, + ); + try { + repaint(); + expect(stdout.written).toEqual([`${ESC}2J${ESC}H`]); + } finally { + restore(); + } + }); + + it('QWEN_CODE_LEGACY_RESIZE_ERASE disables the wrapper', () => { + vi.stubEnv('QWEN_CODE_LEGACY_RESIZE_ERASE', '1'); + try { + const stdout = new FakeStdout(); + const handle = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(10)); + handle.repaint(); + expect(stdout.written).toHaveLength(2); // repaint is a no-op + handle.restore(); + } finally { + vi.unstubAllEnvs(); + } + }); + it('passes writes through untouched after restore', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( @@ -213,3 +313,37 @@ describe('installTerminalResizeReflow', () => { expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(30, 20)); }); }); + +describe('buildWakeRepaint', () => { + const deps = () => ({ + isVP: true, + repaintViewport: vi.fn(), + clearViewportFallback: vi.fn(), + refreshStatic: vi.fn(), + remountStaticHistory: vi.fn(), + }); + + it('VP with prop: calls it and bumps the static remount key', () => { + const d = deps(); + buildWakeRepaint(d)(); + expect(d.repaintViewport).toHaveBeenCalledTimes(1); + expect(d.remountStaticHistory).toHaveBeenCalledTimes(1); + expect(d.clearViewportFallback).not.toHaveBeenCalled(); + expect(d.refreshStatic).not.toHaveBeenCalled(); + }); + + it('VP without prop: falls back to the viewport clear and bumps', () => { + const d = deps(); + buildWakeRepaint({ ...d, repaintViewport: undefined })(); + expect(d.clearViewportFallback).toHaveBeenCalledTimes(1); + expect(d.remountStaticHistory).toHaveBeenCalledTimes(1); + }); + + it('static mode: uses refreshStatic (which clears and bumps)', () => { + const d = deps(); + buildWakeRepaint({ ...d, isVP: false })(); + expect(d.refreshStatic).toHaveBeenCalledTimes(1); + expect(d.repaintViewport).not.toHaveBeenCalled(); + expect(d.remountStaticHistory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts index 396610762ea..a97ae28d53a 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -8,72 +8,78 @@ import ansiEscapes from 'ansi-escapes'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import stringWidth from 'string-width'; import stripAnsi from 'strip-ansi'; +import { + countOccurrences, + createEraseLinesPattern, + ERASE_LINE, +} from './terminalRedrawOptimizer.js'; const debugLogger = createDebugLogger('RESIZE_REFLOW'); -const ERASE_LINE = ansiEscapes.eraseLine; -const CURSOR_UP_ONE = ansiEscapes.cursorUp(); -const CURSOR_LEFT = ansiEscapes.cursorLeft; const CLEAR_VIEWPORT = ansiEscapes.clearViewport; // How long after a shrink every VP redraw starts from a clean viewport. const CLEAR_WINDOW_MS = 600; -const ERASE_LINES_PATTERN = new RegExp( - `(?:${escapeRegExp(ERASE_LINE + CURSOR_UP_ONE)})+${escapeRegExp( - ERASE_LINE + CURSOR_LEFT, - )}`, -); +const ERASE_LINES_PATTERN = createEraseLinesPattern(); // Live frames are >= 8 rows; shorter printable bursts (console output, small // redraws) must not be mistaken for a frame and clobber the model. const MIN_FRAME_LINES = 8; -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +// Physical rows a logical line occupies once the terminal soft-wraps it at +// `columns`. Wide (2-cell) characters that do not fit a row's remaining +// cells wrap and waste a cell, so rows are greedy-packed per character +// rather than dividing total width. +function greedyRows(charWidths: number[], columns: number): number[][] { + const rows: number[][] = []; + let current: number[] = []; + let used = 0; + const flush = () => { + rows.push(current); + current = []; + used = 0; + }; + for (const width of charWidths) { + if (width <= 0) continue; + if (used > 0 && used + width > columns) flush(); + current.push(width); + used += width; + } + if (current.length > 0 || rows.length === 0) flush(); + return rows; } -function countEraseLines(sequence: string): number { - let count = 0; - let index = 0; - while ((index = sequence.indexOf(ERASE_LINE, index)) !== -1) { - count++; - index += ERASE_LINE.length; +function lineCharWidths(line: string): number[] { + const widths: number[] = []; + for (const ch of line) { + widths.push(stringWidth(ch)); } - return count; + return widths; } -function wrappedLineCount(width: number, columns: number): number { - if (columns <= 0) return 1; - return Math.max(1, Math.ceil(width / columns)); +interface FrameModel { + // Raw content of the last frame that reached the terminal; rows are + // packed lazily (the model is only consumed on shrink/wake). + content: string; + columns: number; + // Ink counts the cursor-below line for non-fullscreen frames (the trailing + // '\n' it appends); the amplification target must include it. + trailingNewline: boolean; } -function reflowedHeight(lineWidths: number[], columns: number): number { +function reflowModel(model: FrameModel, columns: number): number { + // Re-pack from the raw frame in one step on every shrink: reflow terminals + // track logical lines, so segmenting an already-segmented model compounds + // (sum-of-ceils >= ceil-of-sum) and consecutive shrinks would over-erase + // into committed scrollback. + const lines = model.content.split('\n'); + if (model.trailingNewline && lines[lines.length - 1] === '') lines.pop(); let total = 0; - for (const width of lineWidths) { - total += wrappedLineCount(width, columns); + for (const line of lines) { + total += greedyRows(lineCharWidths(line), columns).length; } - return total; -} - -function reflowWidths(lineWidths: number[], columns: number): number[] { - const next: number[] = []; - for (const width of lineWidths) { - let remaining = width; - while (remaining > columns) { - next.push(columns); - remaining -= columns; - } - next.push(remaining); - } - return next; -} - -function frameLineWidths(content: string): number[] | undefined { - const lines = content.split('\n'); - if (content.endsWith('\n')) lines.pop(); - if (lines.length < MIN_FRAME_LINES) return undefined; - return lines.map((line) => stringWidth(stripAnsi(line))); + return total + (model.trailingNewline ? 1 : 0); } export interface ResizeReflowOptions { @@ -88,9 +94,37 @@ export interface TerminalResizeReflowHandle { * Ink skips redraws whose output is unchanged, so a wake/SIGCONT repaint * cannot rely on React alone after an external clear. Only the wake path * may call this — ordinary refreshStatic callers must stay write-free in - * VP (replaying the pre-change frame would flash stale content). + * VP (replaying the pre-change frame would flash stale content). Absent + * under QWEN_CODE_LEGACY_RESIZE_ERASE: the wake path then falls back to a + * bare viewport clear plus the static remount bump. */ - repaint: () => void; + repaint?: () => void; +} + +export interface WakeRepaintDeps { + isVP: boolean; + repaintViewport?: () => void; + clearViewportFallback: () => void; + refreshStatic: () => void; + remountStaticHistory: () => void; +} + +/** + * Wake/SIGCONT selection, extracted for unit coverage: VP repaints by + * clearing the viewport and replaying the last frame (Ink skips + * unchanged-output redraws), and must bump the static remount key so + * one-shot history (agent tabs) is re-emitted over the clear; + * static mode uses the ordinary refreshStatic. + */ +export function buildWakeRepaint(deps: WakeRepaintDeps): () => void { + return () => { + if (deps.isVP) { + (deps.repaintViewport ?? deps.clearViewportFallback)(); + deps.remountStaticHistory(); + } else { + deps.refreshStatic(); + } + }; } /** @@ -107,25 +141,30 @@ export interface TerminalResizeReflowHandle { * rows no width model predicts), and over-erasing clamps harmlessly on the * alt screen. * - Static: the live region is amplified to the reflowed height of the last - * frame that actually reached the terminal; walking further up would eat - * committed scrollback, so the count stays conservative there. + * frame that actually reached the terminal (greedy-packed per character, + * plus Ink's cursor-below line); walking further up would eat committed + * scrollback, so the count stays conservative there. */ export function installTerminalResizeReflow( stdout: NodeJS.WriteStream, options: ResizeReflowOptions = {}, ): TerminalResizeReflowHandle { if (process.env['QWEN_CODE_LEGACY_RESIZE_ERASE'] === '1') { - return { restore: () => {}, repaint: () => {} }; + return { restore: () => {} }; } const isVP = options.virtualViewport ?? false; let lastWidth = stdout.columns ?? 0; - let lineWidths: number[] = []; - let lastFrameContent = ''; - let cacheColumns = 0; + const model: FrameModel = { + content: '', + columns: lastWidth, + trailingNewline: false, + }; let pendingAmplify = 0; // Ink's post-shrink redraw arrives bare (log.clear() resets its counter to - // 0), so the erase-prefixed model update never sees it; this flag hands - // the modeling over to the next write. + // 0). A clear-only write arms the handoff; consecutive bare writes then + // each re-model (last wins: the static append precedes the live frame), + // and only printable writes consume it — Ink's standalone synchronized- + // output control writes must not. let expectFrame = false; // After a shrink, every redraw (not just Ink's clear) erases with a stale // row count against the reflowed on-screen frame, re-stranding the frame @@ -135,13 +174,10 @@ export function installTerminalResizeReflow( debugLogger.debug('installed', { width: lastWidth, isVP }); const modelFrame = (content: string) => { - const widths = frameLineWidths(content); - if (widths) { - lineWidths = widths; - lastFrameContent = content; - cacheColumns = stdout.columns ?? lastWidth; - } - return widths; + if (content.split('\n').length < MIN_FRAME_LINES) return; + model.content = content; + model.columns = stdout.columns ?? lastWidth; + model.trailingNewline = content.endsWith('\n'); }; const onResize = () => { @@ -149,22 +185,20 @@ export function installTerminalResizeReflow( debugLogger.debug('resize-event', { width, lastWidth, - model: lineWidths.length, + modeled: model.content.length > 0, }); - if (width > 0 && width < lastWidth && lineWidths.length > 0) { + if (width > 0 && width < lastWidth && model.content.length > 0) { if (isVP) { clearUntil = Date.now() + CLEAR_WINDOW_MS; } else { - pendingAmplify = reflowedHeight(lineWidths, width); + pendingAmplify = reflowModel(model, width); } debugLogger.debug('shrink', { from: lastWidth, to: width, - modelLines: lineWidths.length, pendingAmplify, clearUntil, }); - lineWidths = reflowWidths(lineWidths, width); } else if (width > lastWidth) { // A grow invalidates a pending shrink amplification: the stale count // was computed for a narrower width and would over-erase past the live @@ -183,16 +217,18 @@ export function installTerminalResizeReflow( callback?: (error?: Error | null) => void, ) { if (typeof chunk === 'string') { - ERASE_LINES_PATTERN.lastIndex = 0; const match = ERASE_LINES_PATTERN.exec(chunk); if (match) { const content = chunk.slice(match.index + match[0].length); - modelFrame(content); - debugLogger.debug('match', { modelLines: lineWidths.length }); - if (stripAnsi(content).trim() === '') { + const printable = stripAnsi(content).trim() !== ''; + if (printable) { + modelFrame(content); + expectFrame = false; + } else { // Clear-only write (Ink's log.clear): the redraw follows bare. expectFrame = true; } + debugLogger.debug('match', { printable }); if (isVP && Date.now() < clearUntil) { debugLogger.debug('clear-viewport'); chunk = @@ -200,7 +236,7 @@ export function installTerminalResizeReflow( CLEAR_VIEWPORT + chunk.slice(match.index + match[0].length); } else if (pendingAmplify > 0) { - const count = countEraseLines(match[0]); + const count = countOccurrences(match[0], ERASE_LINE); const target = pendingAmplify; pendingAmplify = 0; if (count < target) { @@ -211,8 +247,10 @@ export function installTerminalResizeReflow( chunk.slice(match.index + match[0].length); } } - } else if (expectFrame) { - expectFrame = false; + } else if (expectFrame && stripAnsi(chunk).trim() !== '') { + // Bare redraw (or static append preceding it): model each printable + // bare write, last one wins; stay armed until the next erase-prefixed + // or clear-only write closes the commit. modelFrame(chunk); } } @@ -236,8 +274,8 @@ export function installTerminalResizeReflow( const columns = stdout.columns ?? lastWidth; originalWrite.call( stdout, - cacheColumns === columns && lastFrameContent - ? CLEAR_VIEWPORT + lastFrameContent + model.columns === columns && model.content + ? CLEAR_VIEWPORT + model.content : CLEAR_VIEWPORT, ); }, diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts index b82186a6d88..776320d2ebe 100644 --- a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts +++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts @@ -7,23 +7,27 @@ import ansiEscapes from 'ansi-escapes'; const ESC = '\u001B['; -const ERASE_LINE = `${ESC}2K`; -const CURSOR_UP_ONE = `${ESC}1A`; +export const ERASE_LINE = `${ESC}2K`; +export const CURSOR_UP_ONE = `${ESC}1A`; const CURSOR_DOWN_ONE = `${ESC}1B`; -const CURSOR_LEFT = `${ESC}G`; +export const CURSOR_LEFT = `${ESC}G`; + +export function createEraseLinesPattern(flags?: string): RegExp { + return new RegExp( + `(?:${escapeRegExp(ERASE_LINE + CURSOR_UP_ONE)})+${escapeRegExp( + ERASE_LINE + CURSOR_LEFT, + )}`, + flags, + ); +} -const MULTILINE_ERASE_LINES_PATTERN = new RegExp( - `(?:${escapeRegExp(ERASE_LINE + CURSOR_UP_ONE)})+${escapeRegExp( - ERASE_LINE + CURSOR_LEFT, - )}`, - 'g', -); +const MULTILINE_ERASE_LINES_PATTERN = createEraseLinesPattern('g'); -function escapeRegExp(value: string): string { +export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function countOccurrences(value: string, search: string): number { +export function countOccurrences(value: string, search: string): number { let count = 0; let index = 0; From dad05dcc0e444ab57fdb263175d47c7ae42e79fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 10 Aug 2026 22:26:11 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix(cli):=20address=20#8831=20R3=20review?= =?UTF-8?q?=20=E2=80=94=20raw-repack=20reflow=20model,=20wake=20remount=20?= =?UTF-8?q?bump,=20hardened=20frame=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/src/ui/utils/terminal-resize-reflow.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index 842595b8949..dda0a1a1af6 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -242,7 +242,7 @@ describe('installTerminalResizeReflow', () => { try { stdout.write(eraseLines(10) + frame(60, 10)); stdout.written.length = 0; - repaint(); + repaint!(); expect(stdout.written).toEqual([`${ESC}2J${ESC}H` + frame(60, 10)]); } finally { restore(); @@ -259,7 +259,7 @@ describe('installTerminalResizeReflow', () => { stdout.write(eraseLines(10) + frame(60, 10)); stdout.columns = 80; stdout.written.length = 0; - repaint(); + repaint!(); expect(stdout.written).toEqual([`${ESC}2J${ESC}H`]); } finally { restore(); @@ -273,7 +273,7 @@ describe('installTerminalResizeReflow', () => { { virtualViewport: true }, ); try { - repaint(); + repaint!(); expect(stdout.written).toEqual([`${ESC}2J${ESC}H`]); } finally { restore(); @@ -292,8 +292,9 @@ describe('installTerminalResizeReflow', () => { stdout.emit('resize'); stdout.write(eraseLines(10)); expect(stdout.written.at(-1)).toBe(eraseLines(10)); - handle.repaint(); - expect(stdout.written).toHaveLength(2); // repaint is a no-op + // No repaint: the VP wake path falls back to its bare viewport clear + // plus the static remount bump instead of a silent no-op. + expect(handle.repaint).toBeUndefined(); handle.restore(); } finally { vi.unstubAllEnvs(); From c4b54681434e0e73e8d0bc7158a02e3f04424958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 11 Aug 2026 02:28:34 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix(cli):=20address=20#8831=20R4=20review?= =?UTF-8?q?=20=E2=80=94=20stripped-width=20model,=20legacy=20wake=20write-?= =?UTF-8?q?free,=20untrusted=20anchors,=20full-reset=20resets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model widths from ANSI-stripped content (SGR bytes are not cells) while repaint replays the raw styled frame. - QWEN_CODE_LEGACY_RESIZE_ERASE VP wake stays write-free (remount bump only) instead of blanking via a bare viewport clear. - Erase-prefixed printable writes re-model unconditionally (live region can legitimately shrink below MIN_FRAME_LINES); bare full-reset redraws (clearTerminal + full static history) reset the model instead of poisoning it; second printable bare write (live frame after static append) bypasses the line-count guard. - Skip amplification when the return-to-bottom prefix carries cursorDown computed from pre-reflow geometry (untrusted anchor). - Tests for all R4 scenarios plus wrapper-stack contracts (stacked install order, LIFO teardown) and the AppContainer wake wiring. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/AppContainer.test.tsx | 55 +++- packages/cli/src/ui/AppContainer.tsx | 9 +- .../ui/utils/terminal-resize-reflow.test.ts | 297 +++++++++++++++++- .../src/ui/utils/terminal-resize-reflow.ts | 81 +++-- 4 files changed, 405 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 73119a5a6e1..18ee38ead2d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4,8 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { writeTerminalTitleSpy } = vi.hoisted(() => ({ +const { writeTerminalTitleSpy, useWakeRepaintMock } = vi.hoisted(() => ({ writeTerminalTitleSpy: vi.fn(), + useWakeRepaintMock: vi.fn(), +})); + +vi.mock('./hooks/use-wake-repaint.js', () => ({ + useWakeRepaint: useWakeRepaintMock, })); vi.mock('../utils/windowTitle.js', async (importOriginal) => { @@ -993,9 +998,51 @@ describe('AppContainer State Management', () => { }); // The wake/SIGCONT trigger itself is covered by use-wake-repaint.test.ts - // (SIGCONT/heartbeat-gap -> repaint callback); the VP/static selection in - // wakeRepaint is exercised manually because ink-testing-library does not - // flush AppContainer effects, so the listener never arms in this harness. + // (SIGCONT/heartbeat-gap -> repaint callback); the VP/static selection is + // unit-covered by buildWakeRepaint tests. This test locks the AppContainer + // call site: the callback handed to the hook must be the wake repaint + // (repaintViewport + remount), not refreshStatic or a mis-wired memo. + it('wires the wake repaint (not refreshStatic) into useWakeRepaint', async () => { + useWakeRepaintMock.mockClear(); + const repaintSpy = vi.fn(); + const vpSettings = { + merged: { + hideTips: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useTerminalBuffer: true, + }, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + + render( + , + ); + + const wakeMod = await import('./hooks/use-wake-repaint.js'); + const wakeCallback = useWakeRepaintMock.mock.calls.at(-1)?.[0]; + expect( + typeof wakeCallback, + `same=${wakeMod.useWakeRepaint === useWakeRepaintMock} calls=${ + useWakeRepaintMock.mock.calls.length + }`, + ).toBe('function'); + act(() => { + wakeCallback(); + }); + // A revert to useWakeRepaint(refreshStatic) would leave the spy + // uncalled (VP refreshStatic is write-free) and fail here. + expect(repaintSpy).toHaveBeenCalledTimes(1); + }); it('defaults to VP mode when useTerminalBuffer is unset', () => { const defaultSettings = { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 54d60c55208..ed08202c39c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1343,17 +1343,10 @@ export const AppContainer = (props: AppContainerProps) => { buildWakeRepaint({ isVP: useTerminalBuffer, repaintViewport, - clearViewportFallback: () => stdout.write(ansiEscapes.clearViewport), refreshStatic, remountStaticHistory, }), - [ - useTerminalBuffer, - repaintViewport, - stdout, - refreshStatic, - remountStaticHistory, - ], + [useTerminalBuffer, repaintViewport, refreshStatic, remountStaticHistory], ); // Keep the static header in sync with model changes without polling. diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index dda0a1a1af6..b8934808f0d 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -6,10 +6,13 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; +import ansiEscapes from 'ansi-escapes'; import { buildWakeRepaint, installTerminalResizeReflow, } from './terminal-resize-reflow.js'; +import { installTerminalRedrawOptimizer } from './terminalRedrawOptimizer.js'; +import { installSynchronizedOutput } from './synchronizedOutput.js'; const ESC = '\u001B['; const BSU = `${ESC}?2026h`; @@ -194,16 +197,35 @@ describe('installTerminalResizeReflow', () => { } }); - it('short erase-prefixed bursts do not clobber the frame model', () => { + it('erase-prefixed printable writes authoritatively re-model small frames', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, ); try { stdout.write(eraseLines(10) + frame(60, 10)); + // Ink replacing its live region with a <8-row render is authoritative; + // rejecting it would freeze the target on the stale 10-row frame. stdout.write(eraseLines(3) + frame(60, 3)); stdout.columns = 30; stdout.emit('resize'); + stdout.write(eraseLines(3)); + expect(stdout.written.at(-1)).toBe(eraseLines(6)); + } finally { + restore(); + } + }); + + it('bare console-style bursts below MIN_FRAME_LINES do not clobber the model', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.write('short console noise'); + stdout.columns = 30; + stdout.emit('resize'); stdout.write(eraseLines(10)); expect(stdout.written.at(-1)).toBe(eraseLines(20)); } finally { @@ -313,13 +335,277 @@ describe('installTerminalResizeReflow', () => { stdout.write(eraseLines(10) + frame(30, 20)); expect(stdout.written.at(-1)).toBe(eraseLines(10) + frame(30, 20)); }); + + it('models widths from ANSI-stripped content (SGR bytes are not cells)', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + const styled = Array.from( + { length: 10 }, + () => `\x1b[31m${'x'.repeat(60)}\x1b[39m`, + ).join('\n'); + stdout.write(eraseLines(10) + styled); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + } finally { + restore(); + } + }); + + it('cursor-suffixed frames pack to visible rows plus the cursor-below line', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(11) + frame(20, 10) + '\n' + '\x1b[?25l'); + stdout.columns = 10; + stdout.emit('resize'); + stdout.write(eraseLines(11)); + expect(stdout.written.at(-1)).toBe(eraseLines(21)); + } finally { + restore(); + } + }); + + it('overflow full-reset redraws reset the model instead of poisoning it', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); // amplified, arms the handoff + // Ink's overflow path: clearTerminal + full static history + live + // frame as one bare write. Must not become the frame model. + stdout.write(ansiEscapes.clearTerminal + frame(60, 30)); + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(5)); + expect(stdout.written.at(-1)).toBe(eraseLines(5)); + } finally { + restore(); + } + }); + + it('the live frame replaces a static append even below MIN_FRAME_LINES', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); // arms the handoff + stdout.write(frame(60, 25)); // static append models first + stdout.write(frame(30, 6)); // <8-row live frame still wins + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(6)); + expect(stdout.written.at(-1)).toBe(eraseLines(12)); + } finally { + restore(); + } + }); + + it('does not amplify on a stale return-to-bottom anchor', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + const prefix = '\x1b[?25l\x1b[2B\x1b[0G'; + stdout.write(prefix + eraseLines(10)); + expect(stdout.written.at(-1)).toBe(prefix + eraseLines(10)); + } finally { + restore(); + } + }); + + it('return-prefixed renders still match and re-model', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + const prefix = '\x1b[?25l\x1b[2B\x1b[0G'; + stdout.write(prefix + eraseLines(10) + frame(30, 20)); + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(20)); + expect(stdout.written.at(-1)).toBe(eraseLines(40)); + } finally { + restore(); + } + }); + + it('consecutive shrinks without a redraw between them stay exact', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 50; + stdout.emit('resize'); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + } finally { + restore(); + } + }); + + it('the model survives a grow and re-amplifies the next shrink', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.columns = 120; + stdout.emit('resize'); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + } finally { + restore(); + } + }); + + it('amplification is one-shot per shrink', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(10)); + } finally { + restore(); + } + }); + + it('VP replaces erase-only post-shrink clears inside the window', () => { + vi.useFakeTimers(); + try { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + { virtualViewport: true }, + ); + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(`${ESC}2J${ESC}H`); + restore(); + } finally { + vi.useRealTimers(); + } + }); + + it('never clamps an erase that already exceeds the target', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(5, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(12)); + expect(stdout.written.at(-1)).toBe(eraseLines(12)); + } finally { + restore(); + } + }); + + it('amplifies end-to-end when stacked inside the redraw optimizer', () => { + const stdout = new FakeStdout(); + // Production install order: optimizer innermost, reflow outermost. + const optimizer = installTerminalRedrawOptimizer( + stdout as unknown as NodeJS.WriteStream, + ); + const reflow = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10) + frame(30, 20)); + // Reflow amplified to 20 before the optimizer compressed the prefix. + expect(stdout.written.at(-1)).toContain(`${ESC}19A`); + expect(stdout.written.at(-1)).toContain(frame(30, 20)); + } finally { + reflow.restore(); + optimizer(); + } + }); + + it('wrapper restores unwind in LIFO order only', () => { + const stdout = new FakeStdout(); + const original = stdout.write; + const optimizer = installTerminalRedrawOptimizer( + stdout as unknown as NodeJS.WriteStream, + ); + const sync = installSynchronizedOutput( + stdout as unknown as NodeJS.WriteStream, + ); + const reflow = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + reflow.restore(); + sync(); + optimizer(); + expect(stdout.write).toBe(original); + + // Out-of-order restore leaks wrappers silently (identity guards no-op). + const stdout2 = new FakeStdout(); + const original2 = stdout2.write; + const optimizer2 = installTerminalRedrawOptimizer( + stdout2 as unknown as NodeJS.WriteStream, + ); + const sync2 = installSynchronizedOutput( + stdout2 as unknown as NodeJS.WriteStream, + ); + const reflow2 = installTerminalResizeReflow( + stdout2 as unknown as NodeJS.WriteStream, + ); + sync2(); // wrong order: middle layer restored before the outer one + reflow2.restore(); // re-installs syncWrapper as "original" + optimizer2(); + expect(stdout2.write).not.toBe(original2); + }); }); describe('buildWakeRepaint', () => { const deps = () => ({ isVP: true, repaintViewport: vi.fn(), - clearViewportFallback: vi.fn(), refreshStatic: vi.fn(), remountStaticHistory: vi.fn(), }); @@ -329,15 +615,16 @@ describe('buildWakeRepaint', () => { buildWakeRepaint(d)(); expect(d.repaintViewport).toHaveBeenCalledTimes(1); expect(d.remountStaticHistory).toHaveBeenCalledTimes(1); - expect(d.clearViewportFallback).not.toHaveBeenCalled(); expect(d.refreshStatic).not.toHaveBeenCalled(); }); - it('VP without prop: falls back to the viewport clear and bumps', () => { + it('VP without prop (legacy hatch): write-free, bump only', () => { const d = deps(); buildWakeRepaint({ ...d, repaintViewport: undefined })(); - expect(d.clearViewportFallback).toHaveBeenCalledTimes(1); + // A bare viewport clear would blank the screen (Ink writes zero bytes + // for unchanged output); pre-PR behavior was stale-but-visible. expect(d.remountStaticHistory).toHaveBeenCalledTimes(1); + expect(d.refreshStatic).not.toHaveBeenCalled(); }); it('static mode: uses refreshStatic (which clears and bumps)', () => { diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts index a97ae28d53a..3e42dad3556 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -17,6 +17,12 @@ import { const debugLogger = createDebugLogger('RESIZE_REFLOW'); const CLEAR_VIEWPORT = ansiEscapes.clearViewport; +const ESC = '\u001B['; +const CLEAR_TERMINAL = ansiEscapes.clearTerminal; + +// Return-to-bottom prefixes carry cursorDown computed from pre-reflow +// geometry; amplifying on such an anchor shifts the erase window up. +const CURSOR_DOWN_PATTERN = new RegExp(`${ESC}[0-9;]*B`); // How long after a shrink every VP redraw starts from a clean viewport. const CLEAR_WINDOW_MS = 600; @@ -72,8 +78,9 @@ function reflowModel(model: FrameModel, columns: number): number { // Re-pack from the raw frame in one step on every shrink: reflow terminals // track logical lines, so segmenting an already-segmented model compounds // (sum-of-ceils >= ceil-of-sum) and consecutive shrinks would over-erase - // into committed scrollback. - const lines = model.content.split('\n'); + // into committed scrollback. Widths come from ANSI-stripped lines — SGR + // parameter bytes are invisible and would pack as phantom cells otherwise. + const lines = stripAnsi(model.content).split('\n'); if (model.trailingNewline && lines[lines.length - 1] === '') lines.pop(); let total = 0; for (const line of lines) { @@ -95,8 +102,8 @@ export interface TerminalResizeReflowHandle { * cannot rely on React alone after an external clear. Only the wake path * may call this — ordinary refreshStatic callers must stay write-free in * VP (replaying the pre-change frame would flash stale content). Absent - * under QWEN_CODE_LEGACY_RESIZE_ERASE: the wake path then falls back to a - * bare viewport clear plus the static remount bump. + * under QWEN_CODE_LEGACY_RESIZE_ERASE: the VP wake path then stays + * write-free (static remount bump only), matching pre-PR behavior. */ repaint?: () => void; } @@ -104,22 +111,24 @@ export interface TerminalResizeReflowHandle { export interface WakeRepaintDeps { isVP: boolean; repaintViewport?: () => void; - clearViewportFallback: () => void; refreshStatic: () => void; remountStaticHistory: () => void; } /** * Wake/SIGCONT selection, extracted for unit coverage: VP repaints by - * clearing the viewport and replaying the last frame (Ink skips - * unchanged-output redraws), and must bump the static remount key so - * one-shot history (agent tabs) is re-emitted over the clear; - * static mode uses the ordinary refreshStatic. + * replaying the last frame over a clean viewport (Ink skips unchanged-output + * redraws) and bumps the static remount key so one-shot history + * (agent tabs) is re-emitted over the clear. Without a repaint (the legacy + * escape hatch) VP wake stays write-free — a bare viewport clear would blank + * the screen, since Ink then writes zero bytes for byte-identical output — + * matching pre-PR behavior (stale but visible). Static mode uses the + * ordinary refreshStatic. */ export function buildWakeRepaint(deps: WakeRepaintDeps): () => void { return () => { if (deps.isVP) { - (deps.repaintViewport ?? deps.clearViewportFallback)(); + deps.repaintViewport?.(); deps.remountStaticHistory(); } else { deps.refreshStatic(); @@ -166,6 +175,9 @@ export function installTerminalResizeReflow( // and only printable writes consume it — Ink's standalone synchronized- // output control writes must not. let expectFrame = false; + // Printable bare writes seen in the current armed burst; the second one is + // the live frame following a static append and bypasses MIN_FRAME_LINES. + let barePrintableCount = 0; // After a shrink, every redraw (not just Ink's clear) erases with a stale // row count against the reflowed on-screen frame, re-stranding the frame // top each time. For this window, start every VP redraw from a clean @@ -173,11 +185,14 @@ export function installTerminalResizeReflow( let clearUntil = 0; debugLogger.debug('installed', { width: lastWidth, isVP }); - const modelFrame = (content: string) => { - if (content.split('\n').length < MIN_FRAME_LINES) return; + const modelFrame = (content: string, bypassMin = false) => { + if (!bypassMin && content.split('\n').length < MIN_FRAME_LINES) return; model.content = content; model.columns = stdout.columns ?? lastWidth; - model.trailingNewline = content.endsWith('\n'); + // Ink appends the cursor suffix AFTER the frame's trailing newline, so + // detect the newline on the ANSI-stripped content (the suffix is either + // pure control bytes or a one-cell cursor block, never a '\n'). + model.trailingNewline = stripAnsi(content).endsWith('\n'); }; const onResize = () => { @@ -222,11 +237,18 @@ export function installTerminalResizeReflow( const content = chunk.slice(match.index + match[0].length); const printable = stripAnsi(content).trim() !== ''; if (printable) { - modelFrame(content); + // Erase-prefixed printable writes are authoritative Ink renders of + // the new live region (console interleaving arrives as clear-only + + // bare), so they update the model even below MIN_FRAME_LINES — + // rejecting them would freeze the amplification target on a stale + // larger frame after every turn commit. + modelFrame(content, true); expectFrame = false; + barePrintableCount = 0; } else { // Clear-only write (Ink's log.clear): the redraw follows bare. expectFrame = true; + barePrintableCount = 0; } debugLogger.debug('match', { printable }); if (isVP && Date.now() < clearUntil) { @@ -239,7 +261,14 @@ export function installTerminalResizeReflow( const count = countOccurrences(match[0], ERASE_LINE); const target = pendingAmplify; pendingAmplify = 0; - if (count < target) { + // A return-to-bottom prefix (cursorDown computed from PRE-reflow + // geometry) shifts the amplified erase window up into scrollback + // after the terminal reflows; keep Ink's stale-but-anchor-consistent + // clear instead of amplifying on an untrusted anchor. + const untrustedAnchor = CURSOR_DOWN_PATTERN.test( + chunk.slice(0, match.index), + ); + if (count < target && !untrustedAnchor) { debugLogger.debug('amplify', { original: count, target }); chunk = chunk.slice(0, match.index) + @@ -247,11 +276,23 @@ export function installTerminalResizeReflow( chunk.slice(match.index + match[0].length); } } - } else if (expectFrame && stripAnsi(chunk).trim() !== '') { - // Bare redraw (or static append preceding it): model each printable - // bare write, last one wins; stay armed until the next erase-prefixed - // or clear-only write closes the commit. - modelFrame(chunk); + } else if (expectFrame) { + if (chunk.includes(CLEAR_TERMINAL)) { + // Overflow-path full reset (clearTerminal + full static history + + // live frame as one bare write): the chunk is not a frame, so drop + // the model until a clean erase-prefixed write re-anchors it. + expectFrame = false; + barePrintableCount = 0; + model.content = ''; + } else if (stripAnsi(chunk).trim() !== '') { + // Bare redraw (or static append preceding it): model each printable + // bare write, last one wins; the second printable bare write of a + // commit is the live frame and replaces the model even below + // MIN_FRAME_LINES. Stay armed until an erase-prefixed write closes + // the commit. + barePrintableCount++; + modelFrame(chunk, barePrintableCount > 1); + } } } return originalWrite.call( From 4f7b4ba877c1d3b68fddbf21ad1a0e64d6f16ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 11 Aug 2026 02:29:35 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix(cli):=20address=20#8831=20R4=20review?= =?UTF-8?q?=20=E2=80=94=20stripped-width=20modeling,=20trusted-anchor=20am?= =?UTF-8?q?plification,=20wake/legacy=20hardening,=20close=20test=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/AppContainer.test.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 18ee38ead2d..feaa2a87b10 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1028,14 +1028,10 @@ describe('AppContainer State Management', () => { />, ); - const wakeMod = await import('./hooks/use-wake-repaint.js'); + // Let ink-testing-library's scheduled initial render flush. + await Promise.resolve(); const wakeCallback = useWakeRepaintMock.mock.calls.at(-1)?.[0]; - expect( - typeof wakeCallback, - `same=${wakeMod.useWakeRepaint === useWakeRepaintMock} calls=${ - useWakeRepaintMock.mock.calls.length - }`, - ).toBe('function'); + expect(typeof wakeCallback).toBe('function'); act(() => { wakeCallback(); }); From 15d4be60330f628e180be518ea27d516e4ffbed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 11 Aug 2026 03:12:04 +0800 Subject: [PATCH 10/13] test(cli): force the sync wrapper in the LIFO teardown test for CI determinism --- packages/cli/src/ui/utils/terminal-resize-reflow.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index b8934808f0d..946f847f04c 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -572,8 +572,11 @@ describe('installTerminalResizeReflow', () => { const optimizer = installTerminalRedrawOptimizer( stdout as unknown as NodeJS.WriteStream, ); + // Force the sync wrapper on regardless of the host terminal's + // TERM_PROGRAM so the LIFO contract is tested identically in CI. const sync = installSynchronizedOutput( stdout as unknown as NodeJS.WriteStream, + { QWEN_CODE_FORCE_SYNCHRONIZED_OUTPUT: '1' }, ); const reflow = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, @@ -591,6 +594,7 @@ describe('installTerminalResizeReflow', () => { ); const sync2 = installSynchronizedOutput( stdout2 as unknown as NodeJS.WriteStream, + { QWEN_CODE_FORCE_SYNCHRONIZED_OUTPUT: '1' }, ); const reflow2 = installTerminalResizeReflow( stdout2 as unknown as NodeJS.WriteStream, From d2e01e8619a208d24619ad09107e297ab260433b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 11 Aug 2026 04:40:55 +0800 Subject: [PATCH 11/13] refactor(cli): drop unused exports from terminalRedrawOptimizer --- packages/cli/src/ui/utils/terminalRedrawOptimizer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts index 776320d2ebe..05745c672e2 100644 --- a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts +++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts @@ -8,9 +8,9 @@ import ansiEscapes from 'ansi-escapes'; const ESC = '\u001B['; export const ERASE_LINE = `${ESC}2K`; -export const CURSOR_UP_ONE = `${ESC}1A`; +const CURSOR_UP_ONE = `${ESC}1A`; const CURSOR_DOWN_ONE = `${ESC}1B`; -export const CURSOR_LEFT = `${ESC}G`; +const CURSOR_LEFT = `${ESC}G`; export function createEraseLinesPattern(flags?: string): RegExp { return new RegExp( @@ -23,7 +23,7 @@ export function createEraseLinesPattern(flags?: string): RegExp { const MULTILINE_ERASE_LINES_PATTERN = createEraseLinesPattern('g'); -export function escapeRegExp(value: string): string { +function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } From 7584697b166cd888fccce62fa9f9722bf3d8bd63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 11 Aug 2026 07:56:40 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(cli):=20address=20#8831=20R6=20review?= =?UTF-8?q?=20=E2=80=94=20trusted-anchor=20amplify=20with=20prefix=20delta?= =?UTF-8?q?,=20ungated=20full-reset,=20grapheme/tab=20packing,=20VP=20shri?= =?UTF-8?q?nk=20remount,=20test=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/AppContainer.test.tsx | 3 + packages/cli/src/ui/AppContainer.tsx | 16 ++- .../ui/utils/terminal-resize-reflow.test.ts | 105 +++++++++++++++++- .../src/ui/utils/terminal-resize-reflow.ts | 59 ++++++---- .../src/ui/utils/terminalRedrawOptimizer.ts | 5 +- 5 files changed, 160 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index feaa2a87b10..375eb03f082 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1038,6 +1038,9 @@ describe('AppContainer State Management', () => { // A revert to useWakeRepaint(refreshStatic) would leave the spy // uncalled (VP refreshStatic is write-free) and fail here. expect(repaintSpy).toHaveBeenCalledTimes(1); + // A repaint-only wiring (dropping the static remount bump) must not + // masquerade as the wake repaint. + expect(wakeCallback).not.toBe(repaintSpy); }); it('defaults to VP mode when useTerminalBuffer is unset', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ed08202c39c..b852ce25d73 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -628,7 +628,8 @@ interface AppContainerProps { /** * VP wake/SIGCONT repaint: clear the viewport and replay the last frame * (Ink skips unchanged-output redraws, so a bare clear would blank the - * screen). Falls back to a viewport clear when absent. + * screen). Absent under QWEN_CODE_LEGACY_RESIZE_ERASE: the VP wake path + * stays write-free (static remount bump only), matching pre-PR behavior. */ repaintViewport?: () => void; } @@ -1318,6 +1319,19 @@ export const AppContainer = (props: AppContainerProps) => { isInteractiveTerminal(), ), ); + + // The VP post-shrink clear window (terminal-resize-reflow) wipes one-shot + // content from the viewport just like the wake path; pair it with + // the same remount bump so keyed statics (agent tab history) re-emit. + const prevTerminalWidthRef = useRef(terminalWidth); + useEffect(() => { + const prev = prevTerminalWidthRef.current; + prevTerminalWidthRef.current = terminalWidth; + if (useTerminalBuffer && terminalWidth < prev) { + remountStaticHistory(); + } + }, [terminalWidth, useTerminalBuffer, remountStaticHistory]); + const showScrollbar = settings.merged.ui?.showScrollbar ?? true; const refreshStatic = useCallback(() => { if (!useTerminalBuffer) { diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index 946f847f04c..454d05e499d 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -5,7 +5,7 @@ */ import { EventEmitter } from 'node:events'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ansiEscapes from 'ansi-escapes'; import { buildWakeRepaint, @@ -46,6 +46,16 @@ class FakeStdout extends EventEmitter { } describe('installTerminalResizeReflow', () => { + // The PR's legacy escape hatches short-circuit the wrappers; keep the + // suite deterministic when a developer runs it with a hatch exported. + beforeEach(() => { + vi.stubEnv('QWEN_CODE_LEGACY_RESIZE_ERASE', ''); + vi.stubEnv('QWEN_CODE_LEGACY_ERASE_LINES', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('amplifies the post-shrink erase to the reflowed frame height', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( @@ -233,6 +243,27 @@ describe('installTerminalResizeReflow', () => { } }); + it('short console noise captured mid-handoff does not become the model', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.write(eraseLines(10)); // clear-only: arms the handoff + stdout.write('short console noise'); // <8 rows: MIN gate rejects + stdout.columns = 30; + stdout.emit('resize'); + stdout.write(eraseLines(10)); + // Amplification still targets the real frame; with the MIN gate + // removed the noise (1 row) would become the model and no amplification + // would fire. + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + } finally { + restore(); + } + }); + it('the VP clear window expires', () => { vi.useFakeTimers(); try { @@ -314,8 +345,8 @@ describe('installTerminalResizeReflow', () => { stdout.emit('resize'); stdout.write(eraseLines(10)); expect(stdout.written.at(-1)).toBe(eraseLines(10)); - // No repaint: the VP wake path falls back to its bare viewport clear - // plus the static remount bump instead of a silent no-op. + // No repaint: the VP wake path stays write-free (static remount bump + // only) — a bare viewport clear would blank the screen. expect(handle.repaint).toBeUndefined(); handle.restore(); } finally { @@ -372,6 +403,46 @@ describe('installTerminalResizeReflow', () => { } }); + it('expands tabs to 8-column stops when packing', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + const tabbed = Array.from( + { length: 10 }, + () => '\t'.repeat(3) + 'x'.repeat(70), + ).join('\n'); + stdout.write(eraseLines(10) + tabbed); + stdout.columns = 80; // 24 tab cells + 70 = 94 -> 2 rows per line + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(20)); + } finally { + restore(); + } + }); + + it('packs grapheme clusters as one block (ZWJ emoji)', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'; + const emoji = Array.from({ length: 10 }, () => family.repeat(6)).join( + '\n', + ); + stdout.write(eraseLines(10) + emoji); + stdout.columns = 30; // 12 cells per line -> 1 row; per-code-point gives 2 + stdout.emit('resize'); + stdout.write(eraseLines(10)); + expect(stdout.written.at(-1)).toBe(eraseLines(10)); + } finally { + restore(); + } + }); + it('overflow full-reset redraws reset the model instead of poisoning it', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( @@ -394,6 +465,25 @@ describe('installTerminalResizeReflow', () => { } }); + it('drops the model on unarmed full-reset writes (the common state)', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + // Ink's shouldClearTerminal path writes clearTerminal + full static + + // live frame with NO preceding log.clear(), i.e. while unarmed. + stdout.write(ansiEscapes.clearTerminal + frame(60, 30)); + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(5)); + expect(stdout.written.at(-1)).toBe(eraseLines(5)); + } finally { + restore(); + } + }); + it('the live frame replaces a static append even below MIN_FRAME_LINES', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( @@ -415,7 +505,7 @@ describe('installTerminalResizeReflow', () => { } }); - it('does not amplify on a stale return-to-bottom anchor', () => { + it('adjusts the return-to-bottom prefix when amplifying', () => { const stdout = new FakeStdout(); const { restore } = installTerminalResizeReflow( stdout as unknown as NodeJS.WriteStream, @@ -424,9 +514,14 @@ describe('installTerminalResizeReflow', () => { stdout.write(eraseLines(10) + frame(60, 10)); stdout.columns = 30; stdout.emit('resize'); + // The prefix cursorDown was computed pre-reflow; the screen grew by + // delta = target - count rows, so the amplified write must advance the + // cursor by count+delta or the erase window shifts into scrollback. const prefix = '\x1b[?25l\x1b[2B\x1b[0G'; stdout.write(prefix + eraseLines(10)); - expect(stdout.written.at(-1)).toBe(prefix + eraseLines(10)); + expect(stdout.written.at(-1)).toBe( + '\x1b[?25l\x1b[12B\x1b[0G' + eraseLines(20), + ); } finally { restore(); } diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts index 3e42dad3556..565126da3ff 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -21,8 +21,10 @@ const ESC = '\u001B['; const CLEAR_TERMINAL = ansiEscapes.clearTerminal; // Return-to-bottom prefixes carry cursorDown computed from pre-reflow -// geometry; amplifying on such an anchor shifts the erase window up. -const CURSOR_DOWN_PATTERN = new RegExp(`${ESC}[0-9;]*B`); +// geometry; the amplified erase needs the cursor advanced by the reflow +// delta too, or the erase window shifts up into scrollback. +// eslint-disable-next-line no-control-regex +const CURSOR_DOWN_PATTERN = /\x1b\[(\d+)B/; // How long after a shrink every VP redraw starts from a clean viewport. const CLEAR_WINDOW_MS = 600; @@ -57,9 +59,17 @@ function greedyRows(charWidths: number[], columns: number): number[][] { } function lineCharWidths(line: string): number[] { + // Grapheme-cluster widths: multi-code-point clusters (ZWJ emoji, skin-tone + // modifiers) occupy one cell block, so per-code-point sums over-count and + // would over-erase into committed scrollback. Tabs advance to the next + // 8-column stop (stringWidth('\t') is 0) or tab-indented frames under-count. + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); const widths: number[] = []; - for (const ch of line) { - widths.push(stringWidth(ch)); + let col = 0; + for (const { segment } of segmenter.segment(line)) { + const width = segment === '\t' ? 8 - (col % 8) : stringWidth(segment); + widths.push(width); + col += width; } return widths; } @@ -261,30 +271,37 @@ export function installTerminalResizeReflow( const count = countOccurrences(match[0], ERASE_LINE); const target = pendingAmplify; pendingAmplify = 0; - // A return-to-bottom prefix (cursorDown computed from PRE-reflow - // geometry) shifts the amplified erase window up into scrollback - // after the terminal reflows; keep Ink's stale-but-anchor-consistent - // clear instead of amplifying on an untrusted anchor. - const untrustedAnchor = CURSOR_DOWN_PATTERN.test( - chunk.slice(0, match.index), - ); - if (count < target && !untrustedAnchor) { + if (count < target) { + // A return-to-bottom prefix's cursorDown was computed from + // PRE-reflow geometry; the screen grew by (target - count) rows, + // so advance the cursor by that delta too or the amplified erase + // window shifts up into scrollback. Terminals clamp cursor moves + // at the bottom row, keeping this safe. + const delta = target - count; + const prefix = chunk + .slice(0, match.index) + .replace( + CURSOR_DOWN_PATTERN, + (_m, n: string) => `${ESC}${Number(n) + delta}B`, + ); debugLogger.debug('amplify', { original: count, target }); chunk = - chunk.slice(0, match.index) + + prefix + ansiEscapes.eraseLines(target) + chunk.slice(match.index + match[0].length); } } + } else if (chunk.includes(CLEAR_TERMINAL)) { + // Overflow-path full reset (clearTerminal + full static history + + // live frame as one write, with NO preceding log.clear()): the chunk + // is not a frame, so drop the model until a clean erase-prefixed + // write re-anchors it. Not gated on expectFrame — the reset write + // arrives unarmed in the normal interactive state. + expectFrame = false; + barePrintableCount = 0; + model.content = ''; } else if (expectFrame) { - if (chunk.includes(CLEAR_TERMINAL)) { - // Overflow-path full reset (clearTerminal + full static history + - // live frame as one bare write): the chunk is not a frame, so drop - // the model until a clean erase-prefixed write re-anchors it. - expectFrame = false; - barePrintableCount = 0; - model.content = ''; - } else if (stripAnsi(chunk).trim() !== '') { + if (stripAnsi(chunk).trim() !== '') { // Bare redraw (or static append preceding it): model each printable // bare write, last one wins; the second printable bare write of a // commit is the live frame and replaces the model even below diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts index 05745c672e2..48f4b292f7e 100644 --- a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts +++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts @@ -23,11 +23,14 @@ export function createEraseLinesPattern(flags?: string): RegExp { const MULTILINE_ERASE_LINES_PATTERN = createEraseLinesPattern('g'); -function escapeRegExp(value: string): string { +export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function countOccurrences(value: string, search: string): number { + // Match core's editHelper.countOccurrences empty-needle semantics; without + // this guard indexOf('', 0) never advances and the loop hangs. + if (search === '') return 0; let count = 0; let index = 0; From 78469b42fc9f492fc2c06d8ba71c73e2d8f6e9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 11 Aug 2026 12:23:55 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix(cli):=20address=20#8831=20R7=20review?= =?UTF-8?q?=20=E2=80=94=20bounded=20bare-write=20handoff,=20window-end=20s?= =?UTF-8?q?tatic=20re-bump,=20deps-capture=20wiring=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/ui/AppContainer.test.tsx | 36 +++++++++------ packages/cli/src/ui/AppContainer.tsx | 21 ++++++++- .../ui/utils/terminal-resize-reflow.test.ts | 44 +++++++++++++++++++ .../src/ui/utils/terminal-resize-reflow.ts | 23 ++++++++-- 4 files changed, 105 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 375eb03f082..2fa2edd51c0 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4,15 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { writeTerminalTitleSpy, useWakeRepaintMock } = vi.hoisted(() => ({ - writeTerminalTitleSpy: vi.fn(), - useWakeRepaintMock: vi.fn(), -})); +const { writeTerminalTitleSpy, useWakeRepaintMock, buildWakeRepaintSpy } = + vi.hoisted(() => ({ + writeTerminalTitleSpy: vi.fn(), + useWakeRepaintMock: vi.fn(), + buildWakeRepaintSpy: vi.fn((deps: Record) => + vi.fn(() => deps), + ), + })); vi.mock('./hooks/use-wake-repaint.js', () => ({ useWakeRepaint: useWakeRepaintMock, })); +vi.mock('./utils/terminal-resize-reflow.js', () => ({ + buildWakeRepaint: buildWakeRepaintSpy, +})); + vi.mock('../utils/windowTitle.js', async (importOriginal) => { const actual = await importOriginal(); @@ -1004,6 +1012,7 @@ describe('AppContainer State Management', () => { // (repaintViewport + remount), not refreshStatic or a mis-wired memo. it('wires the wake repaint (not refreshStatic) into useWakeRepaint', async () => { useWakeRepaintMock.mockClear(); + buildWakeRepaintSpy.mockClear(); const repaintSpy = vi.fn(); const vpSettings = { merged: { @@ -1030,17 +1039,16 @@ describe('AppContainer State Management', () => { // Let ink-testing-library's scheduled initial render flush. await Promise.resolve(); + // The call site must build the wake callback via buildWakeRepaint with + // the repaint prop AND the static remount bump in its deps; inline + // repaint-only wrappers (the shape that drops the agent-tab + // re-emit) fail these. + const deps = buildWakeRepaintSpy.mock.calls.at(-1)?.[0]; + expect(deps?.['isVP']).toBe(true); + expect(deps?.['repaintViewport']).toBe(repaintSpy); + expect(typeof deps?.['remountStaticHistory']).toBe('function'); const wakeCallback = useWakeRepaintMock.mock.calls.at(-1)?.[0]; - expect(typeof wakeCallback).toBe('function'); - act(() => { - wakeCallback(); - }); - // A revert to useWakeRepaint(refreshStatic) would leave the spy - // uncalled (VP refreshStatic is write-free) and fail here. - expect(repaintSpy).toHaveBeenCalledTimes(1); - // A repaint-only wiring (dropping the static remount bump) must not - // masquerade as the wake repaint. - expect(wakeCallback).not.toBe(repaintSpy); + expect(wakeCallback).toBe(buildWakeRepaintSpy.mock.results.at(-1)?.value); }); it('defaults to VP mode when useTerminalBuffer is unset', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b852ce25d73..a29d0cf82aa 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1322,15 +1322,34 @@ export const AppContainer = (props: AppContainerProps) => { // The VP post-shrink clear window (terminal-resize-reflow) wipes one-shot // content from the viewport just like the wake path; pair it with - // the same remount bump so keyed statics (agent tab history) re-emit. + // the same remount bump so keyed statics (agent tab history) re-emit. The + // window's CLEAR_VIEWPORT substitutes wipe the just-re-emitted statics on + // every in-window redraw, so bump again once the window closes. const prevTerminalWidthRef = useRef(terminalWidth); + const shrinkRemountTimerRef = useRef | null>( + null, + ); useEffect(() => { const prev = prevTerminalWidthRef.current; prevTerminalWidthRef.current = terminalWidth; if (useTerminalBuffer && terminalWidth < prev) { remountStaticHistory(); + if (shrinkRemountTimerRef.current) { + clearTimeout(shrinkRemountTimerRef.current); + } + // Slightly past CLEAR_WINDOW_MS (600) so the last window clear lands + // before the re-emit. + shrinkRemountTimerRef.current = setTimeout(remountStaticHistory, 650); } }, [terminalWidth, useTerminalBuffer, remountStaticHistory]); + useEffect( + () => () => { + if (shrinkRemountTimerRef.current) { + clearTimeout(shrinkRemountTimerRef.current); + } + }, + [], + ); const showScrollbar = settings.merged.ui?.showScrollbar ?? true; const refreshStatic = useCallback(() => { diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts index 454d05e499d..8be6cb745d7 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -264,6 +264,50 @@ describe('installTerminalResizeReflow', () => { } }); + it('stray bare writes after the live frame cannot clobber the model', () => { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + try { + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.write(eraseLines(10)); // arms the handoff + stdout.write(frame(60, 12)); // static append + stdout.write(frame(30, 20)); // live frame: consumed, handoff disarms + stdout.write('\x07'); // notification bell during idle: ignored + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(6)); + expect(stdout.written.at(-1)).toBe(eraseLines(40)); + } finally { + restore(); + } + }); + + it('bare writes arriving after the handoff window are ignored', () => { + vi.useFakeTimers(); + try { + const stdout = new FakeStdout(); + const { restore } = installTerminalResizeReflow( + stdout as unknown as NodeJS.WriteStream, + ); + stdout.write(eraseLines(10) + frame(60, 10)); + stdout.write(eraseLines(10)); // arms the handoff + vi.advanceTimersByTime(60); // past HANDOFF_WINDOW_MS + stdout.write('\x07'); // stray bell: disarms, not modeled + stdout.write(frame(20, 10)); // late bare write: also ignored + stdout.columns = 15; + stdout.emit('resize'); + stdout.write(eraseLines(6)); + // Model is still the original frame (40 rows at 15 cols); a wrongly + // modeled late write would target 20. + expect(stdout.written.at(-1)).toBe(eraseLines(40)); + restore(); + } finally { + vi.useRealTimers(); + } + }); + it('the VP clear window expires', () => { vi.useFakeTimers(); try { diff --git a/packages/cli/src/ui/utils/terminal-resize-reflow.ts b/packages/cli/src/ui/utils/terminal-resize-reflow.ts index 565126da3ff..26176b688db 100644 --- a/packages/cli/src/ui/utils/terminal-resize-reflow.ts +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -27,7 +27,12 @@ const CLEAR_TERMINAL = ansiEscapes.clearTerminal; const CURSOR_DOWN_PATTERN = /\x1b\[(\d+)B/; // How long after a shrink every VP redraw starts from a clean viewport. -const CLEAR_WINDOW_MS = 600; +export const CLEAR_WINDOW_MS = 600; + +// The post-clear bare-write handoff (static append + live frame) happens +// within one synchronous Ink render; stray bare writes (notification bell, +// kitty APC images) arrive later and must not reach the model. +const HANDOFF_WINDOW_MS = 50; const ERASE_LINES_PATTERN = createEraseLinesPattern(); @@ -188,6 +193,9 @@ export function installTerminalResizeReflow( // Printable bare writes seen in the current armed burst; the second one is // the live frame following a static append and bypasses MIN_FRAME_LINES. let barePrintableCount = 0; + // The handoff closes shortly after arming: the commit's bare writes land in + // one synchronous render; later stray bare writes are ignored. + let handoffUntil = 0; // After a shrink, every redraw (not just Ink's clear) erases with a stale // row count against the reflowed on-screen frame, re-stranding the frame // top each time. For this window, start every VP redraw from a clean @@ -259,6 +267,7 @@ export function installTerminalResizeReflow( // Clear-only write (Ink's log.clear): the redraw follows bare. expectFrame = true; barePrintableCount = 0; + handoffUntil = Date.now() + HANDOFF_WINDOW_MS; } debugLogger.debug('match', { printable }); if (isVP && Date.now() < clearUntil) { @@ -301,14 +310,20 @@ export function installTerminalResizeReflow( barePrintableCount = 0; model.content = ''; } else if (expectFrame) { - if (stripAnsi(chunk).trim() !== '') { + if (Date.now() >= handoffUntil) { + // The commit's bare writes land in one synchronous render; a bare + // write this late is a stray (notification bell, kitty APC image, + // tmux DCS), not the handoff. + expectFrame = false; + } else if (stripAnsi(chunk).trim() !== '') { // Bare redraw (or static append preceding it): model each printable // bare write, last one wins; the second printable bare write of a // commit is the live frame and replaces the model even below - // MIN_FRAME_LINES. Stay armed until an erase-prefixed write closes - // the commit. + // MIN_FRAME_LINES. Once the live frame is consumed, disarm so later + // strays cannot clobber the model during idle. barePrintableCount++; modelFrame(chunk, barePrintableCount > 1); + if (barePrintableCount > 1) expectFrame = false; } } }