mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 05:31:02 +00:00
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:
parent
bdd6731950
commit
418acc548b
3 changed files with 204 additions and 0 deletions
|
|
@ -70,6 +70,7 @@ import { DualOutputBridge } from './dualOutput/DualOutputBridge.js';
|
||||||
import { DualOutputContext } from './dualOutput/DualOutputContext.js';
|
import { DualOutputContext } from './dualOutput/DualOutputContext.js';
|
||||||
import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js';
|
import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js';
|
||||||
import { RemoteInputContext } from './remoteInput/RemoteInputContext.js';
|
import { RemoteInputContext } from './remoteInput/RemoteInputContext.js';
|
||||||
|
import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js';
|
||||||
|
|
||||||
const debugLogger = createDebugLogger('STARTUP');
|
const debugLogger = createDebugLogger('STARTUP');
|
||||||
|
|
||||||
|
|
@ -155,6 +156,10 @@ export async function startInteractiveUI(
|
||||||
) {
|
) {
|
||||||
const version = await getCliVersion();
|
const version = await getCliVersion();
|
||||||
setWindowTitle(basename(workspaceRoot), settings);
|
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.
|
// Create dual output bridge if --json-fd or --json-file is specified.
|
||||||
// Errors are caught so a bad fd/path degrades gracefully instead of
|
// Errors are caught so a bad fd/path degrades gracefully instead of
|
||||||
|
|
@ -268,6 +273,7 @@ export async function startInteractiveUI(
|
||||||
remoteInputWatcher?.shutdown();
|
remoteInputWatcher?.shutdown();
|
||||||
dualOutputBridge?.shutdown();
|
dualOutputBridge?.shutdown();
|
||||||
instance.unmount();
|
instance.unmount();
|
||||||
|
restoreTerminalRedrawOptimizer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
100
packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts
Normal file
100
packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
98
packages/cli/src/ui/utils/terminalRedrawOptimizer.ts
Normal file
98
packages/cli/src/ui/utils/terminalRedrawOptimizer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue