summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks')
-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
4 files changed, 281 insertions, 15 deletions
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;
+}