mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +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 { 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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
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