diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 51681d1e554..2fa2edd51c0 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4,8 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { writeTerminalTitleSpy } = vi.hoisted(() => ({ - writeTerminalTitleSpy: 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) => { @@ -954,7 +967,7 @@ describe('AppContainer State Management', () => { expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal); }); - it('refreshStatic skips the physical clear in VP mode (#4891)', () => { + it('refreshStatic stays write-free in VP mode for ordinary callers (#8557)', () => { const vpSettings = { merged: { hideTips: false, @@ -980,13 +993,64 @@ 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. + // 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 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(); + buildWakeRepaintSpy.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( + , + ); + + // 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(wakeCallback).toBe(buildWakeRepaintSpy.mock.results.at(-1)?.value); + }); + 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 de5964922cd..a29d0cf82aa 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, @@ -624,6 +625,13 @@ 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). Absent under QWEN_CODE_LEGACY_RESIZE_ERASE: the VP wake path + * stays write-free (static remount bump only), matching pre-PR behavior. + */ + repaintViewport?: () => void; } /** @@ -639,8 +647,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 +1300,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. 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. 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 ?? @@ -1305,14 +1319,69 @@ 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. 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(() => { if (!useTerminalBuffer) { stdout.write(ansiEscapes.clearTerminal); } + // 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, 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) 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, + refreshStatic, + remountStaticHistory, + }), + [useTerminalBuffer, repaintViewport, refreshStatic, remountStaticHistory], + ); + // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly // clear and remount the static region to redraw the banner at the top. @@ -3402,7 +3471,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 d8c476fc357..c3c26022c7d 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -35,6 +35,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, @@ -164,6 +165,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 resizeReflow = + process.stdout.isTTY && !config.getScreenReader() + ? installTerminalResizeReflow(process.stdout, { virtualViewport: useVP }) + : { restore: () => {}, repaint: () => {} }; + // Create wrapper component to use hooks inside render const AppWrapper = () => { const kittyProtocolStatus = useKittyKeyboardProtocol(); @@ -195,6 +205,7 @@ export async function startInteractiveUI( initializationResult={initializationResult} initialUseVirtualViewport={useVP} extensionRefreshState={options.extensionRefreshState} + repaintViewport={resizeReflow.repaint} /> @@ -313,6 +324,10 @@ export async function startInteractiveUI( if (useVP) { process.stdout.setMaxListeners(stdoutMaxListeners); } + // 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 diff --git a/packages/cli/src/ui/utils/synchronizedOutput.test.ts b/packages/cli/src/ui/utils/synchronizedOutput.test.ts index f9c9da0e1c0..144526e111d 100644 --- a/packages/cli/src/ui/utils/synchronizedOutput.test.ts +++ b/packages/cli/src/ui/utils/synchronizedOutput.test.ts @@ -31,6 +31,8 @@ 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: 'xterm-kitty' }, true], [{ KITTY_WINDOW_ID: '1' }, true], 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; } 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..8be6cb745d7 --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.test.ts @@ -0,0 +1,780 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, 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`; + +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, trailingNewline = false): string { + const s = Array.from({ length: rows }, () => 'x'.repeat(width)).join('\n'); + return trailingNewline ? s + '\n' : s; +} + +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', () => { + // 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( + 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)); + } 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('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 (divergent geometry)', () => { + 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)); + // 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('ignores standalone synchronized-output writes between clear and frame', () => { + 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 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(22)); + expect(stdout.written.at(-1)).toBe(eraseLines(44)); + } finally { + restore(); + } + }); + + it('static-commit sequences model the live frame, not the transcript', () => { + 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 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('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 { + restore(); + } + }); + + 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('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 { + 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( + 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('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)); + // 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 { + vi.unstubAllEnvs(); + } + }); + + 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)); + }); + + 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('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( + 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('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( + 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('adjusts the return-to-bottom prefix when amplifying', () => { + 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'); + // 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( + '\x1b[?25l\x1b[12B\x1b[0G' + eraseLines(20), + ); + } 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, + ); + // 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, + ); + 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, + { QWEN_CODE_FORCE_SYNCHRONIZED_OUTPUT: '1' }, + ); + 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(), + 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.refreshStatic).not.toHaveBeenCalled(); + }); + + it('VP without prop (legacy hatch): write-free, bump only', () => { + const d = deps(); + buildWakeRepaint({ ...d, repaintViewport: undefined })(); + // 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)', () => { + 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 new file mode 100644 index 00000000000..26176b688db --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-resize-reflow.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2025 Google LLC + * 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'; +import { + countOccurrences, + createEraseLinesPattern, + ERASE_LINE, +} from './terminalRedrawOptimizer.js'; + +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; 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. +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(); + +// 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; + +// 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 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[] = []; + 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; +} + +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 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. 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) { + total += greedyRows(lineCharWidths(line), columns).length; + } + return total + (model.trailingNewline ? 1 : 0); +} + +export interface ResizeReflowOptions { + /** VP / alternate-screen mode: the shrink clear may blank the viewport. */ + 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. 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 VP wake path then stays + * write-free (static remount bump only), matching pre-PR behavior. + */ + repaint?: () => void; +} + +export interface WakeRepaintDeps { + isVP: boolean; + repaintViewport?: () => void; + refreshStatic: () => void; + remountStaticHistory: () => void; +} + +/** + * Wake/SIGCONT selection, extracted for unit coverage: VP repaints by + * 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.remountStaticHistory(); + } else { + deps.refreshStatic(); + } + }; +} + +/** + * 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 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 (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: () => {} }; + } + const isVP = options.virtualViewport ?? false; + let lastWidth = stdout.columns ?? 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). 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; + // 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 + // viewport instead. + let clearUntil = 0; + debugLogger.debug('installed', { width: lastWidth, isVP }); + + const modelFrame = (content: string, bypassMin = false) => { + if (!bypassMin && content.split('\n').length < MIN_FRAME_LINES) return; + model.content = content; + model.columns = stdout.columns ?? lastWidth; + // 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 = () => { + const width = stdout.columns ?? lastWidth; + debugLogger.debug('resize-event', { + width, + lastWidth, + modeled: model.content.length > 0, + }); + if (width > 0 && width < lastWidth && model.content.length > 0) { + if (isVP) { + clearUntil = Date.now() + CLEAR_WINDOW_MS; + } else { + pendingAmplify = reflowModel(model, width); + } + debugLogger.debug('shrink', { + from: lastWidth, + to: width, + pendingAmplify, + clearUntil, + }); + } 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; + }; + 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') { + const match = ERASE_LINES_PATTERN.exec(chunk); + if (match) { + const content = chunk.slice(match.index + match[0].length); + const printable = stripAnsi(content).trim() !== ''; + if (printable) { + // 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; + handoffUntil = Date.now() + HANDOFF_WINDOW_MS; + } + debugLogger.debug('match', { printable }); + 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 = countOccurrences(match[0], ERASE_LINE); + const target = pendingAmplify; + pendingAmplify = 0; + 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 = + 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 (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. 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; + } + } + } + return originalWrite.call( + this, + chunk as string | Uint8Array, + encodingOrCallback as BufferEncoding, + callback, + ); + } as typeof stdout.write; + stdout.write = reflowWrite; + + return { + restore: () => { + if (stdout.write === reflowWrite) { + stdout.write = originalWrite; + } + stdout.off('resize', onResize); + }, + repaint: () => { + const columns = stdout.columns ?? lastWidth; + originalWrite.call( + stdout, + 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..48f4b292f7e 100644 --- a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts +++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts @@ -7,23 +7,30 @@ import ansiEscapes from 'ansi-escapes'; const ESC = '\u001B['; -const ERASE_LINE = `${ESC}2K`; +export const ERASE_LINE = `${ESC}2K`; const CURSOR_UP_ONE = `${ESC}1A`; const CURSOR_DOWN_ONE = `${ESC}1B`; const CURSOR_LEFT = `${ESC}G`; -const MULTILINE_ERASE_LINES_PATTERN = new RegExp( - `(?:${escapeRegExp(ERASE_LINE + CURSOR_UP_ONE)})+${escapeRegExp( - ERASE_LINE + CURSOR_LEFT, - )}`, - '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 = 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 { + // 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;