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( 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 => { + 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 = ({ const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const escapeTimerRef = useRef(null); + const kittyProtocolStatus = useKittyKeyboardProtocol(); const [dirs, setDirs] = useState( config.getWorkspaceContext().getDirectories(), @@ -525,7 +527,11 @@ export const InputPrompt: React.FC = ({ ], ); - 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 ; u or ~ + // Format: ESC [ ; 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({ + 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 { + 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 ? u) + if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) { + progressiveEnhancementReceived = true; + } + + // Check for device attributes response (CSI ? 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/~ + * 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 { + 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 { + 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 { + 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 { + return configureVSCodeStyle('VS Code', 'Code'); +} + +async function configureCursor(): Promise { + return configureVSCodeStyle('Cursor', 'Cursor'); +} + +async function configureWindsurf(): Promise { + 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 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 { + // 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.`, + }; + } +} diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 0c13e864..9450f06d 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -19,6 +19,7 @@ import { SlashCommandEvent, MalformedJsonResponseEvent, IdeConnectionEvent, + KittySequenceOverflowEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import { Config } from '../../config/config.js'; @@ -43,6 +44,7 @@ const next_speaker_check_event_name = 'next_speaker_check'; const slash_command_event_name = 'slash_command'; const malformed_json_response_event_name = 'malformed_json_response'; const ide_connection_event_name = 'ide_connection'; +const kitty_sequence_overflow_event_name = 'kitty_sequence_overflow'; export interface LogResponse { nextRequestWaitMs?: number; @@ -675,6 +677,24 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logKittySequenceOverflowEvent(event: KittySequenceOverflowEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_SEQUENCE_LENGTH, + value: event.sequence_length.toString(), + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_TRUNCATED_SEQUENCE, + value: event.truncated_sequence, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(kitty_sequence_overflow_event_name, data), + ); + this.flushIfNeeded(); + } + logEndSessionEvent(event: EndSessionEvent): void { const data: EventValue[] = [ { diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 9dae3e0d..cb4172ed 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -212,6 +212,16 @@ export enum EventMetadataKey { // Logs user removed lines in edit/write tool response. GEMINI_CLI_USER_REMOVED_LINES = 50, + + // ========================================================================== + // Kitty Sequence Overflow Event Keys + // =========================================================================== + + // Logs the length of the kitty sequence that overflowed. + GEMINI_CLI_KITTY_SEQUENCE_LENGTH = 53, + + // Logs the truncated kitty sequence. + GEMINI_CLI_KITTY_TRUNCATED_SEQUENCE = 52, } export function getEventMetadataKey( diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 1663abdf..0f343ab3 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -27,6 +27,7 @@ export { logApiResponse, logFlashFallback, logSlashCommand, + logKittySequenceOverflow, } from './loggers.js'; export { StartSessionEvent, @@ -39,6 +40,7 @@ export { TelemetryEvent, FlashFallbackEvent, SlashCommandEvent, + KittySequenceOverflowEvent, makeSlashCommandEvent, SlashCommandStatus, } from './types.js'; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index e3726ccb..d7a81203 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -32,6 +32,7 @@ import { NextSpeakerCheckEvent, LoopDetectedEvent, SlashCommandEvent, + KittySequenceOverflowEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -378,3 +379,21 @@ export function logIdeConnection( }; logger.emit(logRecord); } + +export function logKittySequenceOverflow( + config: Config, + event: KittySequenceOverflowEvent, +): void { + ClearcutLogger.getInstance(config)?.logKittySequenceOverflowEvent(event); + if (!isTelemetrySdkInitialized()) return; + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + }; + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Kitty sequence buffer overflow: ${event.sequence_length} bytes`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index d590699c..b07c4ca4 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -346,6 +346,20 @@ export class IdeConnectionEvent { } } +export class KittySequenceOverflowEvent { + 'event.name': 'kitty_sequence_overflow'; + 'event.timestamp': string; // ISO 8601 + sequence_length: number; + truncated_sequence: string; + constructor(sequence_length: number, truncated_sequence: string) { + this['event.name'] = 'kitty_sequence_overflow'; + this['event.timestamp'] = new Date().toISOString(); + this.sequence_length = sequence_length; + // Truncate to first 20 chars for logging (avoid logging sensitive data) + this.truncated_sequence = truncated_sequence.substring(0, 20); + } +} + export type TelemetryEvent = | StartSessionEvent | EndSessionEvent @@ -360,4 +374,4 @@ export type TelemetryEvent = | SlashCommandEvent | MalformedJsonResponseEvent | IdeConnectionEvent - | SlashCommandEvent; + | KittySequenceOverflowEvent;