diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 93957d8f1..92845216d 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -70,6 +70,7 @@ import { DualOutputBridge } from './dualOutput/DualOutputBridge.js'; import { DualOutputContext } from './dualOutput/DualOutputContext.js'; import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js'; import { RemoteInputContext } from './remoteInput/RemoteInputContext.js'; +import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -155,6 +156,10 @@ export async function startInteractiveUI( ) { const version = await getCliVersion(); setWindowTitle(basename(workspaceRoot), settings); + const restoreTerminalRedrawOptimizer = + process.stdout.isTTY && !config.getScreenReader() + ? installTerminalRedrawOptimizer(process.stdout) + : () => {}; // Create dual output bridge if --json-fd or --json-file is specified. // Errors are caught so a bad fd/path degrades gracefully instead of @@ -268,6 +273,7 @@ export async function startInteractiveUI( remoteInputWatcher?.shutdown(); dualOutputBridge?.shutdown(); instance.unmount(); + restoreTerminalRedrawOptimizer(); }); } diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts new file mode 100644 index 000000000..53d747c14 --- /dev/null +++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + installTerminalRedrawOptimizer, + optimizeMultilineEraseLines, +} from './terminalRedrawOptimizer.js'; + +const ESC = '\u001B['; +const ERASE_LINE = `${ESC}2K`; +const CURSOR_UP_ONE = `${ESC}1A`; +const CURSOR_DOWN_ONE = `${ESC}1B`; +const CURSOR_LEFT = `${ESC}G`; + +describe('optimizeMultilineEraseLines', () => { + it('collapses repeated cursor-up movement without erasing below', () => { + const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}next frame`; + + expect(optimizeMultilineEraseLines(input)).toBe( + `${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}next frame`, + ); + }); + + it('leaves two-line erase sequences unchanged', () => { + const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}next frame`; + + expect(optimizeMultilineEraseLines(input)).toBe(input); + }); + + it('leaves single-line erase sequences unchanged', () => { + const input = `${ERASE_LINE}${CURSOR_LEFT}next frame`; + + expect(optimizeMultilineEraseLines(input)).toBe(input); + }); + + it('optimizes each multiline erase sequence in a chunk', () => { + const first = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`; + const second = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`; + + expect(optimizeMultilineEraseLines(`${first}a${second}b`)).toBe( + `${first}a${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}b`, + ); + }); + + it('does not emit erase-down sequences', () => { + const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`; + + expect(optimizeMultilineEraseLines(input)).not.toContain(`${ESC}J`); + }); +}); + +describe('installTerminalRedrawOptimizer', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('optimizes string writes and restores the original writer', () => { + const write = vi.fn(() => true); + const stdout = { write } as unknown as NodeJS.WriteStream; + const restore = installTerminalRedrawOptimizer(stdout); + const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`; + + stdout.write(input); + + expect(write).toHaveBeenCalledWith( + `${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}`, + undefined, + undefined, + ); + + restore(); + expect(stdout.write).toBe(write); + }); + + it('passes non-string writes through unchanged', () => { + const write = vi.fn(() => true); + const stdout = { write } as unknown as NodeJS.WriteStream; + installTerminalRedrawOptimizer(stdout); + const input = Buffer.from('hello'); + + stdout.write(input); + + expect(write).toHaveBeenCalledWith(input, undefined, undefined); + }); + + it('can be disabled for terminal compatibility fallback', () => { + vi.stubEnv('QWEN_CODE_LEGACY_ERASE_LINES', '1'); + const write = vi.fn(() => true); + const stdout = { write } as unknown as NodeJS.WriteStream; + const restore = installTerminalRedrawOptimizer(stdout); + + expect(stdout.write).toBe(write); + restore(); + expect(stdout.write).toBe(write); + }); +}); diff --git a/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts new file mode 100644 index 000000000..166c687ed --- /dev/null +++ b/packages/cli/src/ui/utils/terminalRedrawOptimizer.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const ESC = '\u001B['; +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', +); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function countOccurrences(value: string, search: string): number { + let count = 0; + let index = 0; + + while ((index = value.indexOf(search, index)) !== -1) { + count++; + index += search.length; + } + + return count; +} + +/** + * Ink clears dynamic output via ansi-escapes.eraseLines(), which emits a + * clear-line + cursor-up pair for every previous line. That can make terminal + * scrollback bounce during frequent streaming renders. Collapse the repeated + * upward cursor movement while still clearing only the same old frame lines. + */ +export function optimizeMultilineEraseLines(output: string): string { + return output.replace(MULTILINE_ERASE_LINES_PATTERN, (sequence) => { + const lineCount = countOccurrences(sequence, ERASE_LINE); + const cursorUpCount = lineCount - 1; + + if (cursorUpCount <= 1) { + return sequence; + } + + let boundedErase = `${ESC}${cursorUpCount}A`; + + for (let line = 0; line < lineCount; line++) { + boundedErase += ERASE_LINE; + + if (line < lineCount - 1) { + boundedErase += CURSOR_DOWN_ONE; + } + } + + return `${boundedErase}${ESC}${cursorUpCount}A${CURSOR_LEFT}`; + }); +} + +export function installTerminalRedrawOptimizer( + stdout: NodeJS.WriteStream, +): () => void { + if (process.env['QWEN_CODE_LEGACY_ERASE_LINES'] === '1') { + return () => {}; + } + + const originalWrite = stdout.write; + + const optimizedWrite = function ( + this: NodeJS.WriteStream, + chunk: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) { + const optimizedChunk = + typeof chunk === 'string' ? optimizeMultilineEraseLines(chunk) : chunk; + + return originalWrite.call( + this, + optimizedChunk as string | Uint8Array, + encodingOrCallback as BufferEncoding, + callback, + ); + } as typeof stdout.write; + + stdout.write = optimizedWrite; + + return () => { + if (stdout.write === optimizedWrite) { + stdout.write = originalWrite; + } + }; +}