mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-12 22:31:12 -07:00
fix(cli): resolve paste issue on Windows terminals. (#15932)
This commit is contained in:
committed by
GitHub
parent
fd7b6bf40a
commit
8f0324d868
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user