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.
This commit is contained in:
Reid 2026-04-18 08:02:40 +08:00 committed by GitHub
parent bdd6731950
commit 418acc548b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 204 additions and 0 deletions

View file

@ -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();
});
}

View file

@ -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);
});
});

View file

@ -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;
}
};
}