Skip to content

Commit 418acc5

Browse files
authored
fix(cli): reduce terminal redraw cursor movement (#3381)
* fix(cli): reduce terminal redraw cursor movement Collapse Ink multiline erase sequences into a single relative cursor move plus erase-down operation. This avoids excessive repeated cursor-up writes during streaming interactive renders while preserving normal TTY behavior. Screen reader mode and non-TTY output are left unchanged, with a legacy env fallback available. * Optimize Ink multiline erase sequences during interactive TTY rendering. Collapse repeated cursor-up movement while preserving bounded line clearing, so redraws avoid excessive upward cursor jumps without erasing unrelated terminal output below the frame. Non-TTY output, screen reader mode, and non-string writes are unchanged.
1 parent bdd6731 commit 418acc5

3 files changed

Lines changed: 204 additions & 0 deletions

File tree

packages/cli/src/gemini.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import { DualOutputBridge } from './dualOutput/DualOutputBridge.js';
7070
import { DualOutputContext } from './dualOutput/DualOutputContext.js';
7171
import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js';
7272
import { RemoteInputContext } from './remoteInput/RemoteInputContext.js';
73+
import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js';
7374

7475
const debugLogger = createDebugLogger('STARTUP');
7576

@@ -155,6 +156,10 @@ export async function startInteractiveUI(
155156
) {
156157
const version = await getCliVersion();
157158
setWindowTitle(basename(workspaceRoot), settings);
159+
const restoreTerminalRedrawOptimizer =
160+
process.stdout.isTTY && !config.getScreenReader()
161+
? installTerminalRedrawOptimizer(process.stdout)
162+
: () => {};
158163

159164
// Create dual output bridge if --json-fd or --json-file is specified.
160165
// Errors are caught so a bad fd/path degrades gracefully instead of
@@ -268,6 +273,7 @@ export async function startInteractiveUI(
268273
remoteInputWatcher?.shutdown();
269274
dualOutputBridge?.shutdown();
270275
instance.unmount();
276+
restoreTerminalRedrawOptimizer();
271277
});
272278
}
273279

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { afterEach, describe, expect, it, vi } from 'vitest';
8+
import {
9+
installTerminalRedrawOptimizer,
10+
optimizeMultilineEraseLines,
11+
} from './terminalRedrawOptimizer.js';
12+
13+
const ESC = '\u001B[';
14+
const ERASE_LINE = `${ESC}2K`;
15+
const CURSOR_UP_ONE = `${ESC}1A`;
16+
const CURSOR_DOWN_ONE = `${ESC}1B`;
17+
const CURSOR_LEFT = `${ESC}G`;
18+
19+
describe('optimizeMultilineEraseLines', () => {
20+
it('collapses repeated cursor-up movement without erasing below', () => {
21+
const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}next frame`;
22+
23+
expect(optimizeMultilineEraseLines(input)).toBe(
24+
`${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}next frame`,
25+
);
26+
});
27+
28+
it('leaves two-line erase sequences unchanged', () => {
29+
const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}next frame`;
30+
31+
expect(optimizeMultilineEraseLines(input)).toBe(input);
32+
});
33+
34+
it('leaves single-line erase sequences unchanged', () => {
35+
const input = `${ERASE_LINE}${CURSOR_LEFT}next frame`;
36+
37+
expect(optimizeMultilineEraseLines(input)).toBe(input);
38+
});
39+
40+
it('optimizes each multiline erase sequence in a chunk', () => {
41+
const first = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`;
42+
const second = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`;
43+
44+
expect(optimizeMultilineEraseLines(`${first}a${second}b`)).toBe(
45+
`${first}a${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}b`,
46+
);
47+
});
48+
49+
it('does not emit erase-down sequences', () => {
50+
const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`;
51+
52+
expect(optimizeMultilineEraseLines(input)).not.toContain(`${ESC}J`);
53+
});
54+
});
55+
56+
describe('installTerminalRedrawOptimizer', () => {
57+
afterEach(() => {
58+
vi.unstubAllEnvs();
59+
});
60+
61+
it('optimizes string writes and restores the original writer', () => {
62+
const write = vi.fn(() => true);
63+
const stdout = { write } as unknown as NodeJS.WriteStream;
64+
const restore = installTerminalRedrawOptimizer(stdout);
65+
const input = `${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_UP_ONE}${ERASE_LINE}${CURSOR_LEFT}`;
66+
67+
stdout.write(input);
68+
69+
expect(write).toHaveBeenCalledWith(
70+
`${ESC}2A${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${CURSOR_DOWN_ONE}${ERASE_LINE}${ESC}2A${CURSOR_LEFT}`,
71+
undefined,
72+
undefined,
73+
);
74+
75+
restore();
76+
expect(stdout.write).toBe(write);
77+
});
78+
79+
it('passes non-string writes through unchanged', () => {
80+
const write = vi.fn(() => true);
81+
const stdout = { write } as unknown as NodeJS.WriteStream;
82+
installTerminalRedrawOptimizer(stdout);
83+
const input = Buffer.from('hello');
84+
85+
stdout.write(input);
86+
87+
expect(write).toHaveBeenCalledWith(input, undefined, undefined);
88+
});
89+
90+
it('can be disabled for terminal compatibility fallback', () => {
91+
vi.stubEnv('QWEN_CODE_LEGACY_ERASE_LINES', '1');
92+
const write = vi.fn(() => true);
93+
const stdout = { write } as unknown as NodeJS.WriteStream;
94+
const restore = installTerminalRedrawOptimizer(stdout);
95+
96+
expect(stdout.write).toBe(write);
97+
restore();
98+
expect(stdout.write).toBe(write);
99+
});
100+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
const ESC = '\u001B[';
8+
const ERASE_LINE = `${ESC}2K`;
9+
const CURSOR_UP_ONE = `${ESC}1A`;
10+
const CURSOR_DOWN_ONE = `${ESC}1B`;
11+
const CURSOR_LEFT = `${ESC}G`;
12+
13+
const MULTILINE_ERASE_LINES_PATTERN = new RegExp(
14+
`(?:${escapeRegExp(ERASE_LINE + CURSOR_UP_ONE)})+${escapeRegExp(
15+
ERASE_LINE + CURSOR_LEFT,
16+
)}`,
17+
'g',
18+
);
19+
20+
function escapeRegExp(value: string): string {
21+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22+
}
23+
24+
function countOccurrences(value: string, search: string): number {
25+
let count = 0;
26+
let index = 0;
27+
28+
while ((index = value.indexOf(search, index)) !== -1) {
29+
count++;
30+
index += search.length;
31+
}
32+
33+
return count;
34+
}
35+
36+
/**
37+
* Ink clears dynamic output via ansi-escapes.eraseLines(), which emits a
38+
* clear-line + cursor-up pair for every previous line. That can make terminal
39+
* scrollback bounce during frequent streaming renders. Collapse the repeated
40+
* upward cursor movement while still clearing only the same old frame lines.
41+
*/
42+
export function optimizeMultilineEraseLines(output: string): string {
43+
return output.replace(MULTILINE_ERASE_LINES_PATTERN, (sequence) => {
44+
const lineCount = countOccurrences(sequence, ERASE_LINE);
45+
const cursorUpCount = lineCount - 1;
46+
47+
if (cursorUpCount <= 1) {
48+
return sequence;
49+
}
50+
51+
let boundedErase = `${ESC}${cursorUpCount}A`;
52+
53+
for (let line = 0; line < lineCount; line++) {
54+
boundedErase += ERASE_LINE;
55+
56+
if (line < lineCount - 1) {
57+
boundedErase += CURSOR_DOWN_ONE;
58+
}
59+
}
60+
61+
return `${boundedErase}${ESC}${cursorUpCount}A${CURSOR_LEFT}`;
62+
});
63+
}
64+
65+
export function installTerminalRedrawOptimizer(
66+
stdout: NodeJS.WriteStream,
67+
): () => void {
68+
if (process.env['QWEN_CODE_LEGACY_ERASE_LINES'] === '1') {
69+
return () => {};
70+
}
71+
72+
const originalWrite = stdout.write;
73+
74+
const optimizedWrite = function (
75+
this: NodeJS.WriteStream,
76+
chunk: unknown,
77+
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
78+
callback?: (error?: Error | null) => void,
79+
) {
80+
const optimizedChunk =
81+
typeof chunk === 'string' ? optimizeMultilineEraseLines(chunk) : chunk;
82+
83+
return originalWrite.call(
84+
this,
85+
optimizedChunk as string | Uint8Array,
86+
encodingOrCallback as BufferEncoding,
87+
callback,
88+
);
89+
} as typeof stdout.write;
90+
91+
stdout.write = optimizedWrite;
92+
93+
return () => {
94+
if (stdout.write === optimizedWrite) {
95+
stdout.write = originalWrite;
96+
}
97+
};
98+
}

0 commit comments

Comments
 (0)