Add terminal setup command for Shift+Enter and Ctrl+Enter support (#3289)
Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
parent
74a13fb535
commit
9c7fb870c1
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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] =
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue