fix(cli): resolve paste issue on Windows terminals. (#15932)

This commit is contained in:
Tommaso Sciortino
2026-01-05 14:46:23 -08:00
committed by GitHub
parent fd7b6bf40a
commit 8f0324d868
15 changed files with 224 additions and 115 deletions

View File

@@ -32,7 +32,7 @@ describe('Interactive Mode', () => {
await run.sendKeys(
'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.',
);
await run.sendKeys('\r');
await run.type('\r');
// Wait for the specific end marker.
await run.expectText('THE_END.', 30000);

View File

@@ -95,7 +95,10 @@ describe('extension reloading', () => {
// Poll for the updated list
await rig.pollCommand(
() => run.sendKeys('\u0015/mcp list\r'),
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
@@ -110,9 +113,9 @@ describe('extension reloading', () => {
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('\u0015/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.sendKeys('\r');
await run.type('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.sendKeys('\r');
await run.type('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
@@ -123,7 +126,10 @@ describe('extension reloading', () => {
// Poll for the updated extension version
await rig.pollCommand(
() => run.sendKeys('\u0015/extensions list\r'),
async () => {
await run.sendText('/extensions list');
await run.type('\r');
},
() =>
stripAnsi(run.output).includes(
'test-extension (v0.0.2) - active (updated)',
@@ -133,7 +139,10 @@ describe('extension reloading', () => {
// Poll for the updated mcp tool
await rig.pollCommand(
() => run.sendKeys('\u0015/mcp list\r'),
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
@@ -146,7 +155,7 @@ describe('extension reloading', () => {
);
await run.sendText('/quit');
await run.sendKeys('\r');
await run.type('\r');
// Clean things up.
await serverA.stop();

View File

@@ -37,7 +37,7 @@ describe('Interactive file system', () => {
// Step 1: Read the file
const readPrompt = `Read the version from ${fileName}`;
await run.type(readPrompt);
await run.sendKeys('\r');
await run.type('\r');
const readCall = await rig.waitForToolCall('read_file', 30000);
expect(readCall, 'Expected to find a read_file tool call').toBe(true);
@@ -45,7 +45,7 @@ describe('Interactive file system', () => {
// Step 2: Write the file
const writePrompt = `now change the version to 1.0.1 in the file`;
await run.type(writePrompt);
await run.sendKeys('\r');
await run.type('\r');
// Check tool calls made with right args
await rig.expectToolCallSuccess(

View File

@@ -1087,7 +1087,7 @@ console.log(JSON.stringify({
// Send a prompt to establish a session and trigger an API call
await run.sendKeys('Hello');
await run.sendKeys('\r');
await run.type('\r');
// Wait for response to ensure API call happened
await run.expectText('Hello', 15000);
@@ -1166,7 +1166,7 @@ console.log(JSON.stringify({
// Send an initial prompt to establish a session
await run.sendKeys('Say hello');
await run.sendKeys('\r');
await run.type('\r');
// Wait for the response
await run.expectText('Hello', 10000);
@@ -1176,14 +1176,14 @@ console.log(JSON.stringify({
const numClears = 3;
for (let i = 0; i < numClears; i++) {
await run.sendKeys('/clear');
await run.sendKeys('\r');
await run.type('\r');
// Wait a bit for clear to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
// Send a prompt to establish an active session before next clear
await run.sendKeys('Say hello');
await run.sendKeys('\r');
await run.type('\r');
// Wait for response
await run.expectText('Hello', 10000);

View File

@@ -200,7 +200,7 @@ describe('mcp server with cyclic tool schema is detected', () => {
const run = await rig.runInteractive();
await run.type('/mcp list');
await run.sendKeys('\r');
await run.type('\r');
await run.expectText('tool_with_cyclic_schema');
});

View File

@@ -209,6 +209,12 @@ export class InteractiveRun {
async type(text: string) {
let typedSoFar = '';
for (const char of text) {
if (char === '\r') {
// wait >30ms before `enter` to avoid fast return conversion
// from bufferFastReturn() in KeypressContent.tsx
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.ptyProcess.write(char);
typedSoFar += char;

View File

@@ -91,7 +91,6 @@ import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
@@ -123,7 +122,6 @@ import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { enableBracketedPaste } from './utils/bracketedPaste.js';
import { useBanner } from './hooks/useBanner.js';
const WARNING_PROMPT_DURATION_MS = 1000;
@@ -424,8 +422,7 @@ export const AppContainer = (props: AppContainerProps) => {
disableLineWrapping();
app.rerender();
}
enableBracketedPaste();
terminalCapabilityManager.enableKittyProtocol();
terminalCapabilityManager.enableSupportedModes();
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]);
@@ -925,7 +922,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
});
const isFocused = useFocus();
useBracketedPaste();
// Context file names computation
const contextFileNames = useMemo(() => {

View File

@@ -31,6 +31,7 @@ import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.
import clipboardy from 'clipboardy';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import stripAnsi from 'strip-ansi';
import chalk from 'chalk';
@@ -121,6 +122,10 @@ describe('InputPrompt', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(
terminalCapabilityManager,
'isBracketedPasteEnabled',
).mockReturnValue(true);
mockCommandContext = createMockCommandContext();

View File

@@ -35,6 +35,7 @@ import {
type SettingDefinition,
type SettingsSchemaType,
} from '../../config/settingsSchema.js';
import { terminalCapabilityManager } from '../../ui/utils/terminalCapabilityManager.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
@@ -253,6 +254,10 @@ const renderDialog = (
describe('SettingsDialog', () => {
beforeEach(() => {
vi.spyOn(
terminalCapabilityManager,
'isBracketedPasteEnabled',
).mockReturnValue(true);
mockToggleVimEnabled.mockResolvedValue(true);
});

View File

@@ -19,10 +19,12 @@ import { ESC } from '../utils/input.js';
import { parseMouseEvent } from '../utils/mouse.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
export const BACKSLASH_ENTER_TIMEOUT = 5;
export const ESC_TIMEOUT = 50;
export const PASTE_TIMEOUT = 30_000;
export const FAST_RETURN_TIMEOUT = 30;
// Parse the key itself
const KEY_INFO_MAP: Record<
@@ -148,7 +150,7 @@ function nonKeyboardEventFilter(
*/
function bufferBackslashEnter(
keypressHandler: KeypressHandler,
): (key: Key | null) => void {
): KeypressHandler {
const bufferer = (function* (): Generator<void, void, Key | null> {
while (true) {
const key = yield;
@@ -184,7 +186,31 @@ function bufferBackslashEnter(
bufferer.next(); // prime the generator so it starts listening.
return (key: Key | null) => bufferer.next(key);
return (key: Key) => bufferer.next(key);
}
/**
* Converts return keys pressed quickly after other keys into plain
* insertable return characters.
*
* This is to accomodate older terminals that paste text without bracketing.
*/
function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler {
let lastKeyTime = 0;
return (key: Key) => {
const now = Date.now();
if (key.name === 'return' && now - lastKeyTime <= FAST_RETURN_TIMEOUT) {
keypressHandler({
...key,
name: '',
sequence: '\r',
insertable: true,
});
} else {
keypressHandler(key);
}
lastKeyTime = now;
};
}
/**
@@ -192,9 +218,7 @@ function bufferBackslashEnter(
* Will flush the buffer if no data is received for PASTE_TIMEOUT ms or
* when a null key is received.
*/
function bufferPaste(
keypressHandler: KeypressHandler,
): (key: Key | null) => void {
function bufferPaste(keypressHandler: KeypressHandler): KeypressHandler {
const bufferer = (function* (): Generator<void, void, Key | null> {
while (true) {
let key = yield;
@@ -238,7 +262,7 @@ function bufferPaste(
})();
bufferer.next(); // prime the generator so it starts listening.
return (key: Key | null) => bufferer.next(key);
return (key: Key) => bufferer.next(key);
}
/**
@@ -592,10 +616,13 @@ export function KeypressProvider({
process.stdin.setEncoding('utf8'); // Make data events emit strings
const mouseFilterer = nonKeyboardEventFilter(broadcast);
const backslashBufferer = bufferBackslashEnter(mouseFilterer);
const pasteBufferer = bufferPaste(backslashBufferer);
let dataListener = createDataListener(pasteBufferer);
let processor = nonKeyboardEventFilter(broadcast);
if (!terminalCapabilityManager.isBracketedPasteEnabled()) {
processor = bufferFastReturn(processor);
}
processor = bufferBackslashEnter(processor);
processor = bufferPaste(processor);
let dataListener = createDataListener(processor);
if (debugKeystrokeLogging) {
const old = dataListener;

View File

@@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect } from 'react';
import {
disableBracketedPaste,
enableBracketedPaste,
} from '../utils/bracketedPaste.js';
/**
* Enables and disables bracketed paste mode in the terminal.
*
* This hook ensures that bracketed paste mode is enabled when the component
* mounts and disabled when it unmounts or when the process exits.
*/
export const useBracketedPaste = () => {
const cleanup = () => {
disableBracketedPaste();
};
useEffect(() => {
enableBracketedPaste();
process.on('exit', cleanup);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
return () => {
cleanup();
process.removeListener('exit', cleanup);
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
};
}, []);
};

View File

@@ -1,18 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { writeToStdout } from '@google/gemini-cli-core';
const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
export const enableBracketedPaste = () => {
writeToStdout(ENABLE_BRACKETED_PASTE);
};
export const disableBracketedPaste = () => {
writeToStdout(DISABLE_BRACKETED_PASTE);
};

View File

@@ -23,6 +23,8 @@ vi.mock('@google/gemini-cli-core', () => ({
disableKittyKeyboardProtocol: vi.fn(),
enableModifyOtherKeys: vi.fn(),
disableModifyOtherKeys: vi.fn(),
enableBracketedPasteMode: vi.fn(),
disableBracketedPasteMode: vi.fn(),
}));
describe('TerminalCapabilityManager', () => {
@@ -264,4 +266,46 @@ describe('TerminalCapabilityManager', () => {
expect(manager.isModifyOtherKeysEnabled()).toBe(true);
});
});
describe('bracketed paste detection', () => {
it('should detect bracketed paste support (mode set)', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate bracketed paste response: \x1b[?2004;1$y
stdin.emit('data', Buffer.from('\x1b[?2004;1$y'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isBracketedPasteSupported()).toBe(true);
expect(manager.isBracketedPasteEnabled()).toBe(true);
});
it('should detect bracketed paste support (mode reset)', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate bracketed paste response: \x1b[?2004;2$y
stdin.emit('data', Buffer.from('\x1b[?2004;2$y'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isBracketedPasteSupported()).toBe(true);
expect(manager.isBracketedPasteEnabled()).toBe(true);
});
it('should not enable bracketed paste if not supported', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Complete detection with DA1 only
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isBracketedPasteSupported()).toBe(false);
expect(manager.isBracketedPasteEnabled()).toBe(false);
});
});
});

View File

@@ -11,6 +11,8 @@ import {
disableKittyKeyboardProtocol,
enableModifyOtherKeys,
disableModifyOtherKeys,
enableBracketedPasteMode,
disableBracketedPasteMode,
} from '@google/gemini-cli-core';
export type TerminalBackgroundColor = string | undefined;
@@ -23,6 +25,7 @@ export class TerminalCapabilityManager {
private static readonly TERMINAL_NAME_QUERY = '\x1b[>q';
private static readonly DEVICE_ATTRIBUTES_QUERY = '\x1b[c';
private static readonly MODIFY_OTHER_KEYS_QUERY = '\x1b[>4;?m';
private static readonly BRACKETED_PASTE_QUERY = '\x1b[?2004$p';
// Kitty keyboard flags: CSI ? flags u
// eslint-disable-next-line no-control-regex
@@ -40,6 +43,10 @@ export class TerminalCapabilityManager {
// modifyOtherKeys response: CSI > 4 ; level m
// eslint-disable-next-line no-control-regex
private static readonly MODIFY_OTHER_KEYS_REGEX = /\x1b\[>4;(\d+)m/;
// DECRQM response for bracketed paste: CSI ? 2004 ; Ps $ y
// Ps = 1 (set), 2 (reset), 3 (permanently set), 4 (permanently reset)
// eslint-disable-next-line no-control-regex
private static readonly BRACKETED_PASTE_REGEX = /\x1b\[\?2004;([1-4])\$y/;
private terminalBackgroundColor: TerminalBackgroundColor;
private kittySupported = false;
@@ -48,6 +55,8 @@ export class TerminalCapabilityManager {
private terminalName: string | undefined;
private modifyOtherKeysSupported = false;
private modifyOtherKeysEnabled = false;
private bracketedPasteSupported = false;
private bracketedPasteEnabled = false;
private constructor() {}
@@ -75,6 +84,21 @@ export class TerminalCapabilityManager {
return;
}
const cleanupOnExit = () => {
if (this.kittySupported) {
this.disableKittyProtocol();
}
if (this.modifyOtherKeysSupported) {
this.disableModifyOtherKeys();
}
if (this.bracketedPasteSupported) {
this.disableBracketedPaste();
}
};
process.on('exit', () => cleanupOnExit);
process.on('SIGTERM', () => cleanupOnExit);
process.on('SIGINT', cleanupOnExit);
return new Promise((resolve) => {
const originalRawMode = process.stdin.isRaw;
if (!originalRawMode) {
@@ -87,6 +111,7 @@ export class TerminalCapabilityManager {
let deviceAttributesReceived = false;
let bgReceived = false;
let modifyOtherKeysReceived = false;
let bracketedPasteReceived = false;
// eslint-disable-next-line prefer-const
let timeoutId: NodeJS.Timeout;
@@ -100,27 +125,14 @@ export class TerminalCapabilityManager {
}
this.detectionComplete = true;
// Auto-enable kitty if supported
if (this.kittySupported) {
this.enableKittyProtocol();
process.on('exit', () => this.disableKittyProtocol());
process.on('SIGTERM', () => this.disableKittyProtocol());
} else if (this.modifyOtherKeysSupported) {
this.enableModifyOtherKeys();
process.on('exit', () => this.disableModifyOtherKeys());
process.on('SIGTERM', () => this.disableModifyOtherKeys());
}
this.enableSupportedModes();
resolve();
};
const onTimeout = () => {
cleanup();
};
// A somewhat long timeout is acceptable as all terminals should respond
// to the device attributes query used as a sentinel.
timeoutId = setTimeout(onTimeout, 1000);
timeoutId = setTimeout(cleanup, 1000);
const onData = (data: Buffer) => {
buffer += data.toString();
@@ -149,6 +161,32 @@ export class TerminalCapabilityManager {
this.kittySupported = true;
}
// check for modifyOtherKeys support
if (!modifyOtherKeysReceived) {
const match = buffer.match(
TerminalCapabilityManager.MODIFY_OTHER_KEYS_REGEX,
);
if (match) {
modifyOtherKeysReceived = true;
const level = parseInt(match[1], 10);
this.modifyOtherKeysSupported = level >= 2;
debugLogger.log(
`Detected modifyOtherKeys support: ${this.modifyOtherKeysSupported} (level ${level})`,
);
}
}
// check for bracketed paste support
if (!bracketedPasteReceived) {
const match = buffer.match(
TerminalCapabilityManager.BRACKETED_PASTE_REGEX,
);
if (match) {
bracketedPasteReceived = true;
this.bracketedPasteSupported = true;
}
}
// Check for Terminal Name/Version response.
if (!terminalNameReceived) {
const match = buffer.match(
@@ -174,21 +212,6 @@ export class TerminalCapabilityManager {
cleanup();
}
}
// check for modifyOtherKeys support
if (!modifyOtherKeysReceived) {
const match = buffer.match(
TerminalCapabilityManager.MODIFY_OTHER_KEYS_REGEX,
);
if (match) {
modifyOtherKeysReceived = true;
const level = parseInt(match[1], 10);
this.modifyOtherKeysSupported = level >= 2;
debugLogger.log(
`Detected modifyOtherKeys support: ${this.modifyOtherKeysSupported} (level ${level})`,
);
}
}
};
process.stdin.on('data', onData);
@@ -200,6 +223,7 @@ export class TerminalCapabilityManager {
TerminalCapabilityManager.OSC_11_QUERY +
TerminalCapabilityManager.TERMINAL_NAME_QUERY +
TerminalCapabilityManager.MODIFY_OTHER_KEYS_QUERY +
TerminalCapabilityManager.BRACKETED_PASTE_QUERY +
TerminalCapabilityManager.DEVICE_ATTRIBUTES_QUERY,
);
} catch (e) {
@@ -209,6 +233,17 @@ export class TerminalCapabilityManager {
});
}
enableSupportedModes() {
if (this.kittySupported) {
this.enableKittyProtocol();
} else if (this.modifyOtherKeysSupported) {
this.enableModifyOtherKeys();
}
if (this.bracketedPasteSupported) {
this.enableBracketedPaste();
}
}
getTerminalBackgroundColor(): TerminalBackgroundColor {
return this.terminalBackgroundColor;
}
@@ -221,6 +256,36 @@ export class TerminalCapabilityManager {
return this.kittyEnabled;
}
isBracketedPasteSupported(): boolean {
return this.bracketedPasteSupported;
}
isBracketedPasteEnabled(): boolean {
return this.bracketedPasteEnabled;
}
enableBracketedPaste(): void {
try {
if (this.bracketedPasteSupported) {
enableBracketedPasteMode();
this.bracketedPasteEnabled = true;
}
} catch (e) {
debugLogger.warn('Failed to enable bracketed paste mode:', e);
}
}
disableBracketedPaste(): void {
try {
if (this.bracketedPasteEnabled) {
disableBracketedPasteMode();
this.bracketedPasteEnabled = false;
}
} catch (e) {
debugLogger.warn('Failed to disable bracketed paste mode:', e);
}
}
enableKittyProtocol(): void {
try {
if (this.kittySupported) {

View File

@@ -34,6 +34,14 @@ export function disableModifyOtherKeys() {
writeToStdout('\x1b[>4;0m');
}
export function enableBracketedPasteMode() {
writeToStdout('\x1b[?2004h');
}
export function disableBracketedPasteMode() {
writeToStdout('\x1b[?2004l');
}
export function enableLineWrapping() {
writeToStdout('\x1b[?7h');
}