summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/config/keyBindings.ts4
-rw-r--r--packages/cli/src/gemini.tsx3
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.ts2
-rw-r--r--packages/cli/src/ui/App.tsx8
-rw-r--r--packages/cli/src/ui/commands/terminalSetupCommand.test.ts85
-rw-r--r--packages/cli/src/ui/commands/terminalSetupCommand.ts45
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx8
-rw-r--r--packages/cli/src/ui/hooks/useFocus.ts8
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.test.ts11
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.ts246
-rw-r--r--packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts31
-rw-r--r--packages/cli/src/ui/utils/kittyProtocolDetector.ts105
-rw-r--r--packages/cli/src/ui/utils/platformConstants.ts44
-rw-r--r--packages/cli/src/ui/utils/terminalSetup.ts340
14 files changed, 923 insertions, 17 deletions
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 6f4a21a2..640bf9de 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -129,20 +129,24 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Text input
// Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste
+ // Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT]: [
{
key: 'return',
ctrl: false,
command: false,
paste: false,
+ shift: false,
},
],
// Original: key.name === 'return' && (key.ctrl || key.meta || key.paste)
// Split into multiple data-driven bindings
+ // Now also includes shift+enter for multi-line input
[Command.NEWLINE]: [
{ key: 'return', ctrl: true },
{ key: 'return', command: true },
{ key: 'return', paste: true },
+ { key: 'return', shift: true },
],
// External tools
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 68f948da..54e58f72 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -41,6 +41,7 @@ import {
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
+import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
@@ -263,6 +264,8 @@ export async function main() {
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
const version = await getCliVersion();
+ // Detect and enable Kitty keyboard protocol once at startup
+ await detectAndEnableKittyProtocol();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 639bb4d8..7a09cb14 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -33,6 +33,7 @@ import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
+import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
settingsCommand,
vimCommand,
setupGithubCommand,
+ terminalSetupCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 1caabbe0..e8aca549 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -80,6 +80,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
+import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js';
@@ -605,6 +606,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
+ const kittyProtocolStatus = useKittyKeyboardProtocol();
const handleExit = useCallback(
(
@@ -697,7 +699,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
],
);
- useKeypress(handleGlobalKeypress, { isActive: true });
+ useKeypress(handleGlobalKeypress, {
+ isActive: true,
+ kittyProtocolEnabled: kittyProtocolStatus.enabled,
+ config,
+ });
useEffect(() => {
if (config) {
diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.test.ts b/packages/cli/src/ui/commands/terminalSetupCommand.test.ts
new file mode 100644
index 00000000..85f8735e
--- /dev/null
+++ b/packages/cli/src/ui/commands/terminalSetupCommand.test.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { terminalSetupCommand } from './terminalSetupCommand.js';
+import * as terminalSetupModule from '../utils/terminalSetup.js';
+import { CommandContext } from './types.js';
+
+vi.mock('../utils/terminalSetup.js');
+
+describe('terminalSetupCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should have correct metadata', () => {
+ expect(terminalSetupCommand.name).toBe('terminal-setup');
+ expect(terminalSetupCommand.description).toContain('multiline input');
+ expect(terminalSetupCommand.kind).toBe('built-in');
+ });
+
+ it('should return success message when terminal setup succeeds', async () => {
+ vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
+ success: true,
+ message: 'Terminal configured successfully',
+ });
+
+ const result = await terminalSetupCommand.action({} as CommandContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ content: 'Terminal configured successfully',
+ messageType: 'info',
+ });
+ });
+
+ it('should append restart message when terminal setup requires restart', async () => {
+ vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
+ success: true,
+ message: 'Terminal configured successfully',
+ requiresRestart: true,
+ });
+
+ const result = await terminalSetupCommand.action({} as CommandContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ content:
+ 'Terminal configured successfully\n\nPlease restart your terminal for the changes to take effect.',
+ messageType: 'info',
+ });
+ });
+
+ it('should return error message when terminal setup fails', async () => {
+ vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
+ success: false,
+ message: 'Failed to detect terminal',
+ });
+
+ const result = await terminalSetupCommand.action({} as CommandContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ content: 'Failed to detect terminal',
+ messageType: 'error',
+ });
+ });
+
+ it('should handle exceptions from terminal setup', async () => {
+ vi.spyOn(terminalSetupModule, 'terminalSetup').mockRejectedValue(
+ new Error('Unexpected error'),
+ );
+
+ const result = await terminalSetupCommand.action({} as CommandContext, '');
+
+ expect(result).toEqual({
+ type: 'message',
+ content: 'Failed to configure terminal: Error: Unexpected error',
+ messageType: 'error',
+ });
+ });
+});
diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts
new file mode 100644
index 00000000..11520c0e
--- /dev/null
+++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { MessageActionReturn, SlashCommand, CommandKind } from './types.js';
+import { terminalSetup } from '../utils/terminalSetup.js';
+
+/**
+ * Command to configure terminal keybindings for multiline input support.
+ *
+ * This command automatically detects and configures VS Code, Cursor, and Windsurf
+ * to support Shift+Enter and Ctrl+Enter for multiline input.
+ */
+export const terminalSetupCommand: SlashCommand = {
+ name: 'terminal-setup',
+ description:
+ 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
+ kind: CommandKind.BUILT_IN,
+
+ action: async (): Promise<MessageActionReturn> => {
+ try {
+ const result = await terminalSetup();
+
+ let content = result.message;
+ if (result.requiresRestart) {
+ content +=
+ '\n\nPlease restart your terminal for the changes to take effect.';
+ }
+
+ return {
+ type: 'message',
+ content,
+ messageType: result.success ? 'info' : 'error',
+ };
+ } catch (error) {
+ return {
+ type: 'message',
+ content: `Failed to configure terminal: ${error}`,
+ messageType: 'error',
+ };
+ }
+ },
+};
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index f53d255f..94cbcf1b 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
+import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@google/gemini-cli-core';
@@ -66,6 +67,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
+ const kittyProtocolStatus = useKittyKeyboardProtocol();
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -525,7 +527,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
- useKeypress(handleInput, { isActive: true });
+ useKeypress(handleInput, {
+ isActive: true,
+ kittyProtocolEnabled: kittyProtocolStatus.enabled,
+ config,
+ });
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
diff --git a/packages/cli/src/ui/hooks/useFocus.ts b/packages/cli/src/ui/hooks/useFocus.ts
index 6c9a6daa..8a7f9f6c 100644
--- a/packages/cli/src/ui/hooks/useFocus.ts
+++ b/packages/cli/src/ui/hooks/useFocus.ts
@@ -8,12 +8,12 @@ import { useStdin, useStdout } from 'ink';
import { useEffect, useState } from 'react';
// ANSI escape codes to enable/disable terminal focus reporting
-const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
-const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
+export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
+export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
// ANSI escape codes for focus events
-const FOCUS_IN = '\x1b[I';
-const FOCUS_OUT = '\x1b[O';
+export const FOCUS_IN = '\x1b[I';
+export const FOCUS_OUT = '\x1b[O';
export const useFocus = () => {
const { stdin } = useStdin();
diff --git a/packages/cli/src/ui/hooks/useKeypress.test.ts b/packages/cli/src/ui/hooks/useKeypress.test.ts
index a30eabf2..946ee054 100644
--- a/packages/cli/src/ui/hooks/useKeypress.test.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.test.ts
@@ -134,9 +134,14 @@ describe('useKeypress', () => {
expect(onKeypress).not.toHaveBeenCalled();
});
- it('should listen for keypress when active', () => {
+ it.each([
+ { key: { name: 'a', sequence: 'a' } },
+ { key: { name: 'left', sequence: '\x1b[D' } },
+ { key: { name: 'right', sequence: '\x1b[C' } },
+ { key: { name: 'up', sequence: '\x1b[A' } },
+ { key: { name: 'down', sequence: '\x1b[B' } },
+ ])('should listen for keypress when active for key $key.name', ({ key }) => {
renderHook(() => useKeypress(onKeypress, { isActive: true }));
- const key = { name: 'a', sequence: 'a' };
act(() => stdin.pressKey(key));
expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
});
@@ -187,7 +192,7 @@ describe('useKeypress', () => {
},
isLegacy: true,
},
- ])('Paste Handling in $description', ({ setup, isLegacy }) => {
+ ])('in $description', ({ setup, isLegacy }) => {
beforeEach(() => {
setup();
stdin.setLegacy(isLegacy);
diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts
index 6c2b7e8f..920270ee 100644
--- a/packages/cli/src/ui/hooks/useKeypress.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.ts
@@ -8,6 +8,21 @@ import { useEffect, useRef } from 'react';
import { useStdin } from 'ink';
import readline from 'readline';
import { PassThrough } from 'stream';
+import {
+ KITTY_CTRL_C,
+ BACKSLASH_ENTER_DETECTION_WINDOW_MS,
+ MAX_KITTY_SEQUENCE_LENGTH,
+} from '../utils/platformConstants.js';
+import {
+ KittySequenceOverflowEvent,
+ logKittySequenceOverflow,
+ Config,
+} from '@google/gemini-cli-core';
+import { FOCUS_IN, FOCUS_OUT } from './useFocus.js';
+
+const ESC = '\u001B';
+export const PASTE_MODE_PREFIX = `${ESC}[200~`;
+export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
export interface Key {
name: string;
@@ -16,6 +31,7 @@ export interface Key {
shift: boolean;
paste: boolean;
sequence: string;
+ kittyProtocol?: boolean;
}
/**
@@ -30,10 +46,16 @@ export interface Key {
* @param onKeypress - The callback function to execute on each keypress.
* @param options - Options to control the hook's behavior.
* @param options.isActive - Whether the hook should be actively listening for input.
+ * @param options.kittyProtocolEnabled - Whether Kitty keyboard protocol is enabled.
+ * @param options.config - Optional config for telemetry logging.
*/
export function useKeypress(
onKeypress: (key: Key) => void,
- { isActive }: { isActive: boolean },
+ {
+ isActive,
+ kittyProtocolEnabled = false,
+ config,
+ }: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config },
) {
const { stdin, setRawMode } = useStdin();
const onKeypressRef = useRef(onKeypress);
@@ -64,8 +86,210 @@ export function useKeypress(
let isPaste = false;
let pasteBuffer = Buffer.alloc(0);
+ let kittySequenceBuffer = '';
+ let backslashTimeout: NodeJS.Timeout | null = null;
+ let waitingForEnterAfterBackslash = false;
+
+ // Parse Kitty protocol sequences
+ const parseKittySequence = (sequence: string): Key | null => {
+ // Match CSI <number> ; <modifiers> u or ~
+ // Format: ESC [ <keycode> ; <modifiers> u/~
+ const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
+ const match = sequence.match(kittyPattern);
+ if (!match) return null;
+
+ const keyCode = parseInt(match[1], 10);
+ const modifiers = match[3] ? parseInt(match[3], 10) : 1;
+
+ // Decode modifiers (subtract 1 as per Kitty protocol spec)
+ const modifierBits = modifiers - 1;
+ const shift = (modifierBits & 1) === 1;
+ const alt = (modifierBits & 2) === 2;
+ const ctrl = (modifierBits & 4) === 4;
+
+ // Handle Escape key (code 27)
+ if (keyCode === 27) {
+ return {
+ name: 'escape',
+ ctrl,
+ meta: alt,
+ shift,
+ paste: false,
+ sequence,
+ kittyProtocol: true,
+ };
+ }
+
+ // Handle Enter key (code 13)
+ if (keyCode === 13) {
+ return {
+ name: 'return',
+ ctrl,
+ meta: alt,
+ shift,
+ paste: false,
+ sequence,
+ kittyProtocol: true,
+ };
+ }
+
+ // Handle Ctrl+letter combinations (a-z)
+ // ASCII codes: a=97, b=98, c=99, ..., z=122
+ if (keyCode >= 97 && keyCode <= 122 && ctrl) {
+ const letter = String.fromCharCode(keyCode);
+ return {
+ name: letter,
+ ctrl: true,
+ meta: alt,
+ shift,
+ paste: false,
+ sequence,
+ kittyProtocol: true,
+ };
+ }
+
+ // Handle other keys as needed
+ return null;
+ };
const handleKeypress = (_: unknown, key: Key) => {
+ // Handle VS Code's backslash+return pattern (Shift+Enter)
+ if (key.name === 'return' && waitingForEnterAfterBackslash) {
+ // Cancel the timeout since we got the Enter
+ if (backslashTimeout) {
+ clearTimeout(backslashTimeout);
+ backslashTimeout = null;
+ }
+ waitingForEnterAfterBackslash = false;
+
+ // Convert to Shift+Enter
+ onKeypressRef.current({
+ ...key,
+ shift: true,
+ sequence: '\\\r', // VS Code's Shift+Enter representation
+ });
+ return;
+ }
+
+ // Handle backslash - hold it to see if Enter follows
+ if (key.sequence === '\\' && !key.name) {
+ // Don't pass through the backslash yet - wait to see if Enter follows
+ waitingForEnterAfterBackslash = true;
+
+ // Set up a timeout to pass through the backslash if no Enter follows
+ backslashTimeout = setTimeout(() => {
+ waitingForEnterAfterBackslash = false;
+ backslashTimeout = null;
+ // Pass through the backslash since no Enter followed
+ onKeypressRef.current(key);
+ }, BACKSLASH_ENTER_DETECTION_WINDOW_MS);
+
+ return;
+ }
+
+ // If we're waiting for Enter after backslash but got something else,
+ // pass through the backslash first, then the new key
+ if (waitingForEnterAfterBackslash && key.name !== 'return') {
+ if (backslashTimeout) {
+ clearTimeout(backslashTimeout);
+ backslashTimeout = null;
+ }
+ waitingForEnterAfterBackslash = false;
+
+ // Pass through the backslash that was held
+ onKeypressRef.current({
+ name: '',
+ sequence: '\\',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: false,
+ });
+
+ // Then continue processing the current key normally
+ }
+
+ // If readline has already identified an arrow key, pass it through
+ // immediately, bypassing the Kitty protocol sequence buffering.
+ if (['up', 'down', 'left', 'right'].includes(key.name)) {
+ onKeypressRef.current(key);
+ return;
+ }
+
+ // Always pass through Ctrl+C immediately, regardless of protocol state
+ // Check both standard format and Kitty protocol sequence
+ if (
+ (key.ctrl && key.name === 'c') ||
+ key.sequence === `${ESC}${KITTY_CTRL_C}`
+ ) {
+ kittySequenceBuffer = '';
+ // If it's the Kitty sequence, create a proper key object
+ if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
+ onKeypressRef.current({
+ name: 'c',
+ ctrl: true,
+ meta: false,
+ shift: false,
+ paste: false,
+ sequence: key.sequence,
+ kittyProtocol: true,
+ });
+ } else {
+ onKeypressRef.current(key);
+ }
+ return;
+ }
+
+ // If Kitty protocol is enabled, handle CSI sequences
+ if (kittyProtocolEnabled) {
+ // If we have a buffer or this starts a CSI sequence
+ if (
+ kittySequenceBuffer ||
+ (key.sequence.startsWith(`${ESC}[`) &&
+ !key.sequence.startsWith(PASTE_MODE_PREFIX) &&
+ !key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
+ !key.sequence.startsWith(FOCUS_IN) &&
+ !key.sequence.startsWith(FOCUS_OUT))
+ ) {
+ kittySequenceBuffer += key.sequence;
+
+ // Try to parse the buffer as a Kitty sequence
+ const kittyKey = parseKittySequence(kittySequenceBuffer);
+ if (kittyKey) {
+ kittySequenceBuffer = '';
+ onKeypressRef.current(kittyKey);
+ return;
+ }
+
+ if (config?.getDebugMode()) {
+ const codes = Array.from(kittySequenceBuffer).map((ch) =>
+ ch.charCodeAt(0),
+ );
+ // Unless the user is sshing over a slow connection, this likely
+ // indicates this is not a kitty sequence but we have incorrectly
+ // interpreted it as such. See the examples above for sequences
+ // such as FOCUS_IN that are not Kitty sequences.
+ console.warn('Kitty sequence buffer has char codes:', codes);
+ }
+
+ // If buffer doesn't match expected pattern and is getting long, flush it
+ if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
+ // Log telemetry for buffer overflow
+ if (config) {
+ const event = new KittySequenceOverflowEvent(
+ kittySequenceBuffer.length,
+ kittySequenceBuffer,
+ );
+ logKittySequenceOverflow(config, event);
+ }
+ // Not a Kitty sequence, treat as regular key
+ kittySequenceBuffer = '';
+ } else {
+ // Wait for more characters
+ return;
+ }
+ }
+ }
if (key.name === 'paste-start') {
isPaste = true;
} else if (key.name === 'paste-end') {
@@ -84,7 +308,7 @@ export function useKeypress(
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
} else {
// Handle special keys
- if (key.name === 'return' && key.sequence === '\x1B\r') {
+ if (key.name === 'return' && key.sequence === `${ESC}\r`) {
key.meta = true;
}
onKeypressRef.current({ ...key, paste: isPaste });
@@ -93,13 +317,13 @@ export function useKeypress(
};
const handleRawKeypress = (data: Buffer) => {
- const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~');
- const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~');
+ const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
+ const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
let pos = 0;
while (pos < data.length) {
- const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos);
- const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos);
+ const prefixPos = data.indexOf(pasteModePrefixBuffer, pos);
+ const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos);
// Determine which marker comes first, if any.
const isPrefixNext =
@@ -115,7 +339,7 @@ export function useKeypress(
} else if (isSuffixNext) {
nextMarkerPos = suffixPos;
}
- markerLength = PASTE_MODE_SUFFIX.length;
+ markerLength = pasteModeSuffixBuffer.length;
if (nextMarkerPos === -1) {
keypressStream.write(data.slice(pos));
@@ -170,6 +394,12 @@ export function useKeypress(
rl.close();
setRawMode(false);
+ // Clean up any pending backslash timeout
+ if (backslashTimeout) {
+ clearTimeout(backslashTimeout);
+ backslashTimeout = null;
+ }
+
// If we are in the middle of a paste, send what we have.
if (isPaste) {
onKeypressRef.current({
@@ -183,5 +413,5 @@ export function useKeypress(
pasteBuffer = Buffer.alloc(0);
}
};
- }, [isActive, stdin, setRawMode]);
+ }, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]);
}
diff --git a/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts b/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts
new file mode 100644
index 00000000..53c7566c
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState } from 'react';
+import {
+ isKittyProtocolEnabled,
+ isKittyProtocolSupported,
+} from '../utils/kittyProtocolDetector.js';
+
+export interface KittyProtocolStatus {
+ supported: boolean;
+ enabled: boolean;
+ checking: boolean;
+}
+
+/**
+ * Hook that returns the cached Kitty keyboard protocol status.
+ * Detection is done once at app startup to avoid repeated queries.
+ */
+export function useKittyKeyboardProtocol(): KittyProtocolStatus {
+ const [status] = useState<KittyProtocolStatus>({
+ supported: isKittyProtocolSupported(),
+ enabled: isKittyProtocolEnabled(),
+ checking: false,
+ });
+
+ return status;
+}
diff --git a/packages/cli/src/ui/utils/kittyProtocolDetector.ts b/packages/cli/src/ui/utils/kittyProtocolDetector.ts
new file mode 100644
index 00000000..5d77943a
--- /dev/null
+++ b/packages/cli/src/ui/utils/kittyProtocolDetector.ts
@@ -0,0 +1,105 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+let detectionComplete = false;
+let protocolSupported = false;
+let protocolEnabled = false;
+
+/**
+ * Detects Kitty keyboard protocol support.
+ * Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
+ * This function should be called once at app startup.
+ */
+export async function detectAndEnableKittyProtocol(): Promise<boolean> {
+ if (detectionComplete) {
+ return protocolSupported;
+ }
+
+ return new Promise((resolve) => {
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
+ detectionComplete = true;
+ resolve(false);
+ return;
+ }
+
+ const originalRawMode = process.stdin.isRaw;
+ if (!originalRawMode) {
+ process.stdin.setRawMode(true);
+ }
+
+ let responseBuffer = '';
+ let progressiveEnhancementReceived = false;
+ let checkFinished = false;
+
+ const handleData = (data: Buffer) => {
+ responseBuffer += data.toString();
+
+ // Check for progressive enhancement response (CSI ? <flags> u)
+ if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
+ progressiveEnhancementReceived = true;
+ }
+
+ // Check for device attributes response (CSI ? <attrs> c)
+ if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
+ if (!checkFinished) {
+ checkFinished = true;
+ process.stdin.removeListener('data', handleData);
+
+ if (!originalRawMode) {
+ process.stdin.setRawMode(false);
+ }
+
+ if (progressiveEnhancementReceived) {
+ // Enable the protocol
+ process.stdout.write('\x1b[>1u');
+ protocolSupported = true;
+ protocolEnabled = true;
+
+ // Set up cleanup on exit
+ process.on('exit', disableProtocol);
+ process.on('SIGTERM', disableProtocol);
+ }
+
+ detectionComplete = true;
+ resolve(protocolSupported);
+ }
+ }
+ };
+
+ process.stdin.on('data', handleData);
+
+ // Send queries
+ process.stdout.write('\x1b[?u'); // Query progressive enhancement
+ process.stdout.write('\x1b[c'); // Query device attributes
+
+ // Timeout after 50ms
+ setTimeout(() => {
+ if (!checkFinished) {
+ process.stdin.removeListener('data', handleData);
+ if (!originalRawMode) {
+ process.stdin.setRawMode(false);
+ }
+ detectionComplete = true;
+ resolve(false);
+ }
+ }, 50);
+ });
+}
+
+function disableProtocol() {
+ if (protocolEnabled) {
+ process.stdout.write('\x1b[<u');
+ protocolEnabled = false;
+ }
+}
+
+export function isKittyProtocolEnabled(): boolean {
+ return protocolEnabled;
+}
+
+export function isKittyProtocolSupported(): boolean {
+ return protocolSupported;
+}
diff --git a/packages/cli/src/ui/utils/platformConstants.ts b/packages/cli/src/ui/utils/platformConstants.ts
new file mode 100644
index 00000000..9d2e1990
--- /dev/null
+++ b/packages/cli/src/ui/utils/platformConstants.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Terminal Platform Constants
+ *
+ * This file contains terminal-related constants used throughout the application,
+ * specifically for handling keyboard inputs and terminal protocols.
+ */
+
+/**
+ * Kitty keyboard protocol sequences for enhanced keyboard input.
+ * @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/
+ */
+export const KITTY_CTRL_C = '[99;5u';
+
+/**
+ * Timing constants for terminal interactions
+ */
+export const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
+
+/**
+ * VS Code terminal integration constants
+ */
+export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
+
+/**
+ * Backslash + Enter detection window in milliseconds.
+ * Used to detect Shift+Enter pattern where backslash
+ * is followed by Enter within this timeframe.
+ */
+export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5;
+
+/**
+ * Maximum expected length of a Kitty keyboard protocol sequence.
+ * Format: ESC [ <keycode> ; <modifiers> u/~
+ * Example: \x1b[13;2u (Shift+Enter) = 8 chars
+ * Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers)
+ * We use 12 to provide a small buffer.
+ */
+export const MAX_KITTY_SEQUENCE_LENGTH = 12;
diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts
new file mode 100644
index 00000000..7f944847
--- /dev/null
+++ b/packages/cli/src/ui/utils/terminalSetup.ts
@@ -0,0 +1,340 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Terminal setup utility for configuring Shift+Enter and Ctrl+Enter support.
+ *
+ * This module provides automatic detection and configuration of various terminal
+ * emulators to support multiline input through modified Enter keys.
+ *
+ * Supported terminals:
+ * - VS Code: Configures keybindings.json to send \\\r\n
+ * - Cursor: Configures keybindings.json to send \\\r\n (VS Code fork)
+ * - Windsurf: Configures keybindings.json to send \\\r\n (VS Code fork)
+ *
+ * For VS Code and its forks:
+ * - Shift+Enter: Sends \\\r\n (backslash followed by CRLF)
+ * - Ctrl+Enter: Sends \\\r\n (backslash followed by CRLF)
+ *
+ * The module will not modify existing shift+enter or ctrl+enter keybindings
+ * to avoid conflicts with user customizations.
+ */
+
+import { promises as fs } from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
+import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js';
+
+const execAsync = promisify(exec);
+
+/**
+ * Removes single-line JSON comments (// ...) from a string to allow parsing
+ * VS Code style JSON files that may contain comments.
+ */
+function stripJsonComments(content: string): string {
+ // Remove single-line comments (// ...)
+ return content.replace(/^\s*\/\/.*$/gm, '');
+}
+
+export interface TerminalSetupResult {
+ success: boolean;
+ message: string;
+ requiresRestart?: boolean;
+}
+
+type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
+
+// Terminal detection
+async function detectTerminal(): Promise<SupportedTerminal | null> {
+ const termProgram = process.env.TERM_PROGRAM;
+
+ // Check VS Code and its forks - check forks first to avoid false positives
+ // Check for Cursor-specific indicators
+ if (
+ process.env.CURSOR_TRACE_ID ||
+ process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('cursor')
+ ) {
+ return 'cursor';
+ }
+ // Check for Windsurf-specific indicators
+ if (process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('windsurf')) {
+ return 'windsurf';
+ }
+ // Check VS Code last since forks may also set VSCODE env vars
+ if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) {
+ return 'vscode';
+ }
+
+ // Check parent process name
+ if (os.platform() !== 'win32') {
+ try {
+ const { stdout } = await execAsync('ps -o comm= -p $PPID');
+ const parentName = stdout.trim();
+
+ // Check forks before VS Code to avoid false positives
+ if (parentName.includes('windsurf') || parentName.includes('Windsurf'))
+ return 'windsurf';
+ if (parentName.includes('cursor') || parentName.includes('Cursor'))
+ return 'cursor';
+ if (parentName.includes('code') || parentName.includes('Code'))
+ return 'vscode';
+ } catch (error) {
+ // Continue detection even if process check fails
+ console.debug('Parent process detection failed:', error);
+ }
+ }
+
+ return null;
+}
+
+// Backup file helper
+async function backupFile(filePath: string): Promise<void> {
+ try {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const backupPath = `${filePath}.backup.${timestamp}`;
+ await fs.copyFile(filePath, backupPath);
+ } catch (error) {
+ // Log backup errors but continue with operation
+ console.warn(`Failed to create backup of ${filePath}:`, error);
+ }
+}
+
+// Helper function to get VS Code-style config directory
+function getVSCodeStyleConfigDir(appName: string): string | null {
+ const platform = os.platform();
+
+ if (platform === 'darwin') {
+ return path.join(
+ os.homedir(),
+ 'Library',
+ 'Application Support',
+ appName,
+ 'User',
+ );
+ } else if (platform === 'win32') {
+ if (!process.env.APPDATA) {
+ return null;
+ }
+ return path.join(process.env.APPDATA, appName, 'User');
+ } else {
+ return path.join(os.homedir(), '.config', appName, 'User');
+ }
+}
+
+// Generic VS Code-style terminal configuration
+async function configureVSCodeStyle(
+ terminalName: string,
+ appName: string,
+): Promise<TerminalSetupResult> {
+ const configDir = getVSCodeStyleConfigDir(appName);
+
+ if (!configDir) {
+ return {
+ success: false,
+ message: `Could not determine ${terminalName} config path on Windows: APPDATA environment variable is not set.`,
+ };
+ }
+
+ const keybindingsFile = path.join(configDir, 'keybindings.json');
+
+ try {
+ await fs.mkdir(configDir, { recursive: true });
+
+ let keybindings: unknown[] = [];
+ try {
+ const content = await fs.readFile(keybindingsFile, 'utf8');
+ await backupFile(keybindingsFile);
+ try {
+ const cleanContent = stripJsonComments(content);
+ const parsedContent = JSON.parse(cleanContent);
+ if (!Array.isArray(parsedContent)) {
+ return {
+ success: false,
+ message:
+ `${terminalName} keybindings.json exists but is not a valid JSON array. ` +
+ `Please fix the file manually or delete it to allow automatic configuration.\n` +
+ `File: ${keybindingsFile}`,
+ };
+ }
+ keybindings = parsedContent;
+ } catch (parseError) {
+ return {
+ success: false,
+ message:
+ `Failed to parse ${terminalName} keybindings.json. The file contains invalid JSON.\n` +
+ `Please fix the file manually or delete it to allow automatic configuration.\n` +
+ `File: ${keybindingsFile}\n` +
+ `Error: ${parseError}`,
+ };
+ }
+ } catch {
+ // File doesn't exist, will create new one
+ }
+
+ const shiftEnterBinding = {
+ key: 'shift+enter',
+ command: 'workbench.action.terminal.sendSequence',
+ when: 'terminalFocus',
+ args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
+ };
+
+ const ctrlEnterBinding = {
+ key: 'ctrl+enter',
+ command: 'workbench.action.terminal.sendSequence',
+ when: 'terminalFocus',
+ args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
+ };
+
+ // Check if ANY shift+enter or ctrl+enter bindings already exist
+ const existingShiftEnter = keybindings.find((kb) => {
+ const binding = kb as { key?: string };
+ return binding.key === 'shift+enter';
+ });
+
+ const existingCtrlEnter = keybindings.find((kb) => {
+ const binding = kb as { key?: string };
+ return binding.key === 'ctrl+enter';
+ });
+
+ if (existingShiftEnter || existingCtrlEnter) {
+ const messages: string[] = [];
+ if (existingShiftEnter) {
+ messages.push(`- Shift+Enter binding already exists`);
+ }
+ if (existingCtrlEnter) {
+ messages.push(`- Ctrl+Enter binding already exists`);
+ }
+ return {
+ success: false,
+ message:
+ `Existing keybindings detected. Will not modify to avoid conflicts.\n` +
+ messages.join('\n') +
+ '\n' +
+ `Please check and modify manually if needed: ${keybindingsFile}`,
+ };
+ }
+
+ // Check if our specific bindings already exist
+ const hasOurShiftEnter = keybindings.some((kb) => {
+ const binding = kb as {
+ command?: string;
+ args?: { text?: string };
+ key?: string;
+ };
+ return (
+ binding.key === 'shift+enter' &&
+ binding.command === 'workbench.action.terminal.sendSequence' &&
+ binding.args?.text === '\\\r\n'
+ );
+ });
+
+ const hasOurCtrlEnter = keybindings.some((kb) => {
+ const binding = kb as {
+ command?: string;
+ args?: { text?: string };
+ key?: string;
+ };
+ return (
+ binding.key === 'ctrl+enter' &&
+ binding.command === 'workbench.action.terminal.sendSequence' &&
+ binding.args?.text === '\\\r\n'
+ );
+ });
+
+ if (!hasOurShiftEnter || !hasOurCtrlEnter) {
+ if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
+ if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
+
+ await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
+ return {
+ success: true,
+ message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
+ requiresRestart: true,
+ };
+ } else {
+ return {
+ success: true,
+ message: `${terminalName} keybindings already configured.`,
+ };
+ }
+ } catch (error) {
+ return {
+ success: false,
+ message: `Failed to configure ${terminalName}.\nFile: ${keybindingsFile}\nError: ${error}`,
+ };
+ }
+}
+
+// Terminal-specific configuration functions
+
+async function configureVSCode(): Promise<TerminalSetupResult> {
+ return configureVSCodeStyle('VS Code', 'Code');
+}
+
+async function configureCursor(): Promise<TerminalSetupResult> {
+ return configureVSCodeStyle('Cursor', 'Cursor');
+}
+
+async function configureWindsurf(): Promise<TerminalSetupResult> {
+ return configureVSCodeStyle('Windsurf', 'Windsurf');
+}
+
+/**
+ * Main terminal setup function that detects and configures the current terminal.
+ *
+ * This function:
+ * 1. Detects the current terminal emulator
+ * 2. Applies appropriate configuration for Shift+Enter and Ctrl+Enter support
+ * 3. Creates backups of configuration files before modifying them
+ *
+ * @returns Promise<TerminalSetupResult> Result object with success status and message
+ *
+ * @example
+ * const result = await terminalSetup();
+ * if (result.success) {
+ * console.log(result.message);
+ * if (result.requiresRestart) {
+ * console.log('Please restart your terminal');
+ * }
+ * }
+ */
+export async function terminalSetup(): Promise<TerminalSetupResult> {
+ // Check if terminal already has optimal keyboard support
+ if (isKittyProtocolEnabled()) {
+ return {
+ success: true,
+ message:
+ 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
+ };
+ }
+
+ const terminal = await detectTerminal();
+
+ if (!terminal) {
+ return {
+ success: false,
+ message:
+ 'Could not detect terminal type. Supported terminals: VS Code, Cursor, and Windsurf.',
+ };
+ }
+
+ switch (terminal) {
+ case 'vscode':
+ return configureVSCode();
+ case 'cursor':
+ return configureCursor();
+ case 'windsurf':
+ return configureWindsurf();
+ default:
+ return {
+ success: false,
+ message: `Terminal "${terminal}" is not supported yet.`,
+ };
+ }
+}