diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 2e626c9101..337fa30cb9 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -22,19 +22,19 @@ they appear in the UI. ### General -| UI Label | Setting | Description | Default | -| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | -| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | -| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | -| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | -| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | -| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | -| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | -| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | -| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | -| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | +| UI Label | Setting | Description | Default | +| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | +| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | +| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | +| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | +| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | +| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | +| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ef95e1727e..0c99d75558 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -105,7 +105,8 @@ their corresponding top-level category object in your `settings.json` file. - **`general.defaultApprovalMode`** (enum): - **Description:** The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is - read-only mode. 'yolo' is not supported yet. + read-only mode. YOLO mode (auto-approve all actions) can only be enabled via + command line (--yolo or --approval-mode=yolo). - **Default:** `"default"` - **Values:** `"default"`, `"auto_edit"`, `"plan"` diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 3529ead3ec..f8aafa6502 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -8,126 +8,173 @@ available combinations. #### Basic Controls -| Action | Keys | -| --------------------------------------------------------------- | ------------------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | -| Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | -| Exit the CLI when the input buffer is empty. | `Ctrl+D` | +| Command | Action | Keys | +| --------------- | --------------------------------------------------------------- | ------------------- | +| `basic.confirm` | Confirm the current selection or choice. | `Enter` | +| `basic.cancel` | Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl+[` | +| `basic.quit` | Cancel the current request or quit the CLI when input is empty. | `Ctrl+C` | +| `basic.exit` | Exit the CLI when the input buffer is empty. | `Ctrl+D` | #### Cursor Movement -| Action | Keys | -| ------------------------------------------- | ------------------------------------------ | -| Move the cursor to the start of the line. | `Ctrl+A`
`Home` | -| Move the cursor to the end of the line. | `Ctrl+E`
`End` | -| Move the cursor up one line. | `Up` | -| Move the cursor down one line. | `Down` | -| Move the cursor one character to the left. | `Left` | -| Move the cursor one character to the right. | `Right`
`Ctrl+F` | -| Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | -| Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | +| Command | Action | Keys | +| ------------------ | ------------------------------------------- | ------------------------------------------ | +| `cursor.home` | Move the cursor to the start of the line. | `Ctrl+A`
`Home` | +| `cursor.end` | Move the cursor to the end of the line. | `Ctrl+E`
`End` | +| `cursor.up` | Move the cursor up one line. | `Up` | +| `cursor.down` | Move the cursor down one line. | `Down` | +| `cursor.left` | Move the cursor one character to the left. | `Left` | +| `cursor.right` | Move the cursor one character to the right. | `Right`
`Ctrl+F` | +| `cursor.wordLeft` | Move the cursor one word to the left. | `Ctrl+Left`
`Alt+Left`
`Alt+B` | +| `cursor.wordRight` | Move the cursor one word to the right. | `Ctrl+Right`
`Alt+Right`
`Alt+F` | #### Editing -| Action | Keys | -| ------------------------------------------------ | -------------------------------------------------------- | -| Delete from the cursor to the end of the line. | `Ctrl+K` | -| Delete from the cursor to the start of the line. | `Ctrl+U` | -| Clear all text in the input field. | `Ctrl+C` | -| Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | -| Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | -| Delete the character to the left. | `Backspace`
`Ctrl+H` | -| Delete the character to the right. | `Delete`
`Ctrl+D` | -| Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | -| Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | +| Command | Action | Keys | +| ---------------------- | ------------------------------------------------ | -------------------------------------------------------- | +| `edit.deleteRightAll` | Delete from the cursor to the end of the line. | `Ctrl+K` | +| `edit.deleteLeftAll` | Delete from the cursor to the start of the line. | `Ctrl+U` | +| `edit.clear` | Clear all text in the input field. | `Ctrl+C` | +| `edit.deleteWordLeft` | Delete the previous word. | `Ctrl+Backspace`
`Alt+Backspace`
`Ctrl+W` | +| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | +| `edit.deleteLeft` | Delete the character to the left. | `Backspace`
`Ctrl+H` | +| `edit.deleteRight` | Delete the character to the right. | `Delete`
`Ctrl+D` | +| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | +| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | #### Scrolling -| Action | Keys | -| ------------------------ | ----------------------------- | -| Scroll content up. | `Shift+Up` | -| Scroll content down. | `Shift+Down` | -| Scroll to the top. | `Ctrl+Home`
`Shift+Home` | -| Scroll to the bottom. | `Ctrl+End`
`Shift+End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | +| Command | Action | Keys | +| ----------------- | ------------------------ | ----------------------------- | +| `scroll.up` | Scroll content up. | `Shift+Up` | +| `scroll.down` | Scroll content down. | `Shift+Down` | +| `scroll.home` | Scroll to the top. | `Ctrl+Home`
`Shift+Home` | +| `scroll.end` | Scroll to the bottom. | `Ctrl+End`
`Shift+End` | +| `scroll.pageUp` | Scroll up by one page. | `Page Up` | +| `scroll.pageDown` | Scroll down by one page. | `Page Down` | #### History & Search -| Action | Keys | -| -------------------------------------------- | -------- | -| Show the previous entry in history. | `Ctrl+P` | -| Show the next entry in history. | `Ctrl+N` | -| Start reverse search through history. | `Ctrl+R` | -| Submit the selected reverse-search match. | `Enter` | -| Accept a suggestion while reverse searching. | `Tab` | +| Command | Action | Keys | +| ----------------------- | -------------------------------------------- | -------- | +| `history.previous` | Show the previous entry in history. | `Ctrl+P` | +| `history.next` | Show the next entry in history. | `Ctrl+N` | +| `history.search.start` | Start reverse search through history. | `Ctrl+R` | +| `history.search.submit` | Submit the selected reverse-search match. | `Enter` | +| `history.search.accept` | Accept a suggestion while reverse searching. | `Tab` | #### Navigation -| Action | Keys | -| -------------------------------------------------- | --------------- | -| Move selection up in lists. | `Up` | -| Move selection down in lists. | `Down` | -| Move up within dialog options. | `Up`
`K` | -| Move down within dialog options. | `Down`
`J` | -| Move to the next item or question in a dialog. | `Tab` | -| Move to the previous item or question in a dialog. | `Shift+Tab` | +| Command | Action | Keys | +| --------------------- | -------------------------------------------------- | --------------- | +| `nav.up` | Move selection up in lists. | `Up` | +| `nav.down` | Move selection down in lists. | `Down` | +| `nav.dialog.up` | Move up within dialog options. | `Up`
`K` | +| `nav.dialog.down` | Move down within dialog options. | `Down`
`J` | +| `nav.dialog.next` | Move to the next item or question in a dialog. | `Tab` | +| `nav.dialog.previous` | Move to the previous item or question in a dialog. | `Shift+Tab` | #### Suggestions & Completions -| Action | Keys | -| --------------------------------------- | -------------------- | -| Accept the inline suggestion. | `Tab`
`Enter` | -| Move to the previous completion option. | `Up`
`Ctrl+P` | -| Move to the next completion option. | `Down`
`Ctrl+N` | -| Expand an inline suggestion. | `Right` | -| Collapse an inline suggestion. | `Left` | +| Command | Action | Keys | +| ----------------------- | --------------------------------------- | -------------------- | +| `suggest.accept` | Accept the inline suggestion. | `Tab`
`Enter` | +| `suggest.focusPrevious` | Move to the previous completion option. | `Up`
`Ctrl+P` | +| `suggest.focusNext` | Move to the next completion option. | `Down`
`Ctrl+N` | +| `suggest.expand` | Expand an inline suggestion. | `Right` | +| `suggest.collapse` | Collapse an inline suggestion. | `Left` | #### Text Input -| Action | Keys | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter` | -| Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | -| Open the current prompt or the plan in an external editor. | `Ctrl+X` | -| Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | +| Command | Action | Keys | +| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `input.submit` | Submit the current prompt. | `Enter` | +| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | +| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | #### App Controls -| Action | Keys | -| -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| Toggle detailed error information. | `F12` | -| Toggle the full TODO list. | `Ctrl+T` | -| Show IDE context details. | `Ctrl+G` | -| Toggle Markdown rendering. | `Alt+M` | -| Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | -| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | -| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | -| Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | -| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` | -| Move focus from Gemini to the active shell. | `Tab` | -| Move focus from the shell back to Gemini. | `Shift+Tab` | -| Clear the terminal screen and redraw the UI. | `Ctrl+L` | -| Restart the application. | `R`
`Shift+R` | -| Suspend the CLI and move it to the background. | `Ctrl+Z` | -| Show warning when trying to move focus away from shell input. | `Tab` | +| Command | Action | Keys | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `app.showErrorDetails` | Toggle detailed error information. | `F12` | +| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` | +| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` | +| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` | +| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `Ctrl+S` | +| `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` | +| `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` | +| `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` | +| `app.expandPaste` | Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl+O` | +| `app.focusShellInput` | Move focus from Gemini to the active shell. | `Tab` | +| `app.unfocusShellInput` | Move focus from the shell back to Gemini. | `Shift+Tab` | +| `app.clearScreen` | Clear the terminal screen and redraw the UI. | `Ctrl+L` | +| `app.restart` | Restart the application. | `R`
`Shift+R` | +| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` | +| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` | #### Background Shell Controls -| Action | Keys | -| ------------------------------------------------------------------ | ----------- | -| Dismiss background shell list. | `Esc` | -| Confirm selection in background shell list. | `Enter` | -| Toggle current background shell visibility. | `Ctrl+B` | -| Toggle background shell list. | `Ctrl+L` | -| Kill the active background shell. | `Ctrl+K` | -| Move focus from background shell to Gemini. | `Shift+Tab` | -| Move focus from background shell list to Gemini. | `Tab` | -| Show warning when trying to move focus away from background shell. | `Tab` | +| Command | Action | Keys | +| --------------------------- | ------------------------------------------------------------------ | ----------- | +| `background.escape` | Dismiss background shell list. | `Esc` | +| `background.select` | Confirm selection in background shell list. | `Enter` | +| `background.toggle` | Toggle current background shell visibility. | `Ctrl+B` | +| `background.toggleList` | Toggle background shell list. | `Ctrl+L` | +| `background.kill` | Kill the active background shell. | `Ctrl+K` | +| `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` | +| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` | +| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` | +## Customizing Keybindings + +You can add alternative keybindings for commands by creating or modifying the +`keybindings.json` file in your home gemini directory (typically +`~/.gemini/keybindings.json`). This allows you to bind commands to additional +key combinations. Note that default keybindings cannot be removed. + +### Configuration Format + +The configuration uses a JSON array of objects, similar to VS Code's keybinding +schema. Each object must specify a `command` from the reference tables above and +a `key` combination. + +```json +[ + { + "command": "input.submit", + "key": "cmd+s" + }, + { + "command": "edit.clear", + "key": "ctrl+l" + } +] +``` + +### Keyboard Rules + +- **Explicit Modifiers**: Key matching is explicit. For example, a binding for + `ctrl+f` will only trigger on exactly `ctrl+f`, not `ctrl+shift+f` or + `alt+ctrl+f`. You must specify the exact modifier keys (`ctrl`, `shift`, + `alt`/`opt`/`option`, `cmd`/`meta`). +- **Literal Characters**: Terminals often translate complex key combinations + (especially on macOS with the `Option` key) into special characters. To handle + this reliably across all operating systems and SSH sessions, you can bind + directly to the literal character produced. For example, instead of trying to + bind `shift+5`, bind directly to `%`. +- **Special Keys**: Supported special keys include: + - **Navigation**: `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, + `pagedown` + - **Actions**: `enter`, `escape`, `tab`, `space`, `backspace`, `delete`, + `clear`, `insert`, `printscreen` + - **Toggles**: `capslock`, `numlock`, `scrolllock`, `pausebreak` + - **Function Keys**: `f1` through `f35` + - **Numpad**: `numpad0` through `numpad9`, `numpad_add`, `numpad_subtract`, + `numpad_multiply`, `numpad_divide`, `numpad_decimal`, `numpad_separator` + ## Additional context-specific shortcuts - `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your diff --git a/img.png b/img.png new file mode 100644 index 0000000000..ab9f0bafcd Binary files /dev/null and b/img.png differ diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 597aba3969..390fa9d48a 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -204,7 +204,8 @@ const SETTINGS_SCHEMA = { description: oneLine` The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, - and 'plan' is read-only mode. 'yolo' is not supported yet. + and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can + only be enabled via command line (--yolo or --approval-mode=yolo). `, showInDialog: true, options: [ diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index d33dc5884d..05bbe2d852 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -17,6 +17,7 @@ vi.mock('@google/gemini-cli-core', async () => { ...actual, uiTelemetryService: { setLastPromptTokenCount: vi.fn(), + clear: vi.fn(), }, }; }); @@ -74,17 +75,16 @@ describe('clearCommand', () => { expect(mockResetChat).toHaveBeenCalledTimes(1); expect(mockHintClear).toHaveBeenCalledTimes(1); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1); + expect(uiTelemetryService.clear).toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(mockContext.ui.clear).toHaveBeenCalledTimes(1); // Check the order of operations. const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock .invocationCallOrder[0]; const resetChatOrder = mockResetChat.mock.invocationCallOrder[0]; - const resetTelemetryOrder = ( - uiTelemetryService.setLastPromptTokenCount as Mock - ).mock.invocationCallOrder[0]; + const resetTelemetryOrder = (uiTelemetryService.clear as Mock).mock + .invocationCallOrder[0]; const clearOrder = (mockContext.ui.clear as Mock).mock .invocationCallOrder[0]; @@ -110,8 +110,8 @@ describe('clearCommand', () => { 'Clearing terminal.', ); expect(mockResetChat).not.toHaveBeenCalled(); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0); - expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1); + expect(uiTelemetryService.clear).toHaveBeenCalled(); + expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1); expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 385d3f9540..2ae2609204 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -23,10 +23,6 @@ export const clearCommand: SlashCommand = { action: async (context, _args) => { const geminiClient = context.services.config?.getGeminiClient(); const config = context.services.config; - const chatRecordingService = context.services.config - ?.getGeminiClient() - ?.getChat() - .getChatRecordingService(); // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); @@ -34,6 +30,18 @@ export const clearCommand: SlashCommand = { await hookSystem.fireSessionEndEvent(SessionEndReason.Clear); } + // Reset user steering hints + config?.userHintService.clear(); + + // Start a new conversation recording with a new session ID + // We MUST do this before calling resetChat() so the new ChatRecordingService + // initialized by GeminiChat picks up the new session ID. + let newSessionId: string | undefined; + if (config) { + newSessionId = randomUUID(); + config.setSessionId(newSessionId); + } + if (geminiClient) { context.ui.setDebugMessage('Clearing terminal and resetting chat.'); // If resetChat fails, the exception will propagate and halt the command, @@ -43,16 +51,6 @@ export const clearCommand: SlashCommand = { context.ui.setDebugMessage('Clearing terminal.'); } - // Reset user steering hints - config?.userHintService.clear(); - - // Start a new conversation recording with a new session ID - if (config && chatRecordingService) { - const newSessionId = randomUUID(); - config.setSessionId(newSessionId); - chatRecordingService.initialize(); - } - // Fire SessionStart hook after clearing let result; if (hookSystem) { @@ -69,7 +67,7 @@ export const clearCommand: SlashCommand = { await flushTelemetry(config); } - uiTelemetryService.setLastPromptTokenCount(0); + uiTelemetryService.clear(newSessionId); context.ui.clear(); if (result?.systemMessage) { diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx index 5ab76e4519..753d128a7c 100644 --- a/packages/cli/src/ui/contexts/SessionContext.test.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -238,6 +238,34 @@ describe('SessionStatsContext', () => { unmount(); }); + it('should update session ID and reset stats when the uiTelemetryService emits a clear event', () => { + const contextRef: MutableRefObject< + ReturnType | undefined + > = { current: undefined }; + + const { unmount } = render( + + + , + ); + + const initialStartTime = contextRef.current?.stats.sessionStartTime; + const newSessionId = 'new-session-id'; + + act(() => { + uiTelemetryService.emit('clear', newSessionId); + }); + + const stats = contextRef.current?.stats; + expect(stats?.sessionId).toBe(newSessionId); + expect(stats?.promptCount).toBe(0); + expect(stats?.sessionStartTime.getTime()).toBeGreaterThanOrEqual( + initialStartTime!.getTime(), + ); + + unmount(); + }); + it('should throw an error when useSessionStats is used outside of a provider', () => { const onError = vi.fn(); // Suppress console.error from React for this test diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 7102bc9fa0..5ca37a1569 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -216,7 +216,17 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({ }); }; + const handleClear = (newSessionId?: string) => { + setStats((prevState) => ({ + ...prevState, + sessionId: newSessionId || prevState.sessionId, + sessionStartTime: new Date(), + promptCount: 0, + })); + }; + uiTelemetryService.on('update', handleUpdate); + uiTelemetryService.on('clear', handleClear); // Set initial state handleUpdate({ metrics: uiTelemetryService.getMetrics(), @@ -225,6 +235,7 @@ export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({ return () => { uiTelemetryService.off('update', handleUpdate); + uiTelemetryService.off('clear', handleClear); }; }, []); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index d356def6a9..73022f1542 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -23,6 +23,7 @@ import { import { coreEvents, convertSessionToClientHistory, + uiTelemetryService, } from '@google/gemini-cli-core'; // Mock modules @@ -36,6 +37,17 @@ vi.mock('../../utils/sessionUtils.js', async (importOriginal) => { getSessionFiles: vi.fn(), }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + uiTelemetryService: { + clear: vi.fn(), + hydrate: vi.fn(), + }, + }; +}); const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp'; const MOCKED_CHATS_DIR = '/test/project/temp/chats'; @@ -102,6 +114,7 @@ describe('useSessionBrowser', () => { expect(mockConfig.setSessionId).toHaveBeenCalledWith( 'existing-session-456', ); + expect(uiTelemetryService.hydrate).toHaveBeenCalledWith(mockConversation); expect(result.current.isSessionBrowserOpen).toBe(false); expect(mockOnLoadHistory).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 9c6d05b322..7e667b8473 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -16,6 +16,7 @@ import type { import { coreEvents, convertSessionToClientHistory, + uiTelemetryService, } from '@google/gemini-cli-core'; import type { SessionInfo } from '../../utils/sessionUtils.js'; import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js'; @@ -68,6 +69,7 @@ export const useSessionBrowser = ( // Use the old session's ID to continue it. const existingSessionId = conversation.sessionId; config.setSessionId(existingSessionId); + uiTelemetryService.hydrate(conversation); const resumedSessionData = { conversation, diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index cd234022fc..b151ad8ee3 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -134,6 +134,7 @@ export class KeyBinding { 'insert', 'numlock', 'scrolllock', + 'printscreen', 'numpad_multiply', 'numpad_add', 'numpad_separator', diff --git a/packages/core/src/code_assist/telemetry.test.ts b/packages/core/src/code_assist/telemetry.test.ts index b9452f9e6c..e2260ba788 100644 --- a/packages/core/src/code_assist/telemetry.test.ts +++ b/packages/core/src/code_assist/telemetry.test.ts @@ -375,7 +375,7 @@ describe('telemetry', () => { expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith( expect.objectContaining({ - language: 'TypeScript', + language: 'typescript', }), ); }); @@ -408,7 +408,7 @@ describe('telemetry', () => { expect(mockServer.recordConversationInteraction).toHaveBeenCalledWith( expect.objectContaining({ - language: 'Python', + language: 'python', }), ); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 6dd24fd42a..8aba60b0e0 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -171,6 +171,7 @@ export class ChatRecordingService { this.cachedConversation = null; } else { // Create new session + this.sessionId = this.config.getSessionId(); const chatsDir = path.join( this.config.storage.getProjectTempDir(), 'chats', diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 771b0615dc..5805930673 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -1398,12 +1398,11 @@ describe('ShellExecutionService child_process fallback', () => { expectedSignal, ); } else { - expect(mockCpSpawn).toHaveBeenCalledWith(expectedCommand, [ - '/pid', - String(mockChildProcess.pid), - '/f', - '/t', - ]); + expect(mockCpSpawn).toHaveBeenCalledWith( + expectedCommand, + ['/pid', String(mockChildProcess.pid), '/f', '/t'], + undefined, + ); } }); }, diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 50052fb781..d92f395706 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -1181,10 +1181,12 @@ export class ShellExecutionService { await this.cleanupLogStream(pid); if (activeChild) { - killProcessGroup({ pid }).catch(() => {}); + await killProcessGroup({ pid }).catch(() => {}); this.activeChildProcesses.delete(pid); } else if (activePty) { - killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {}); + await killProcessGroup({ pid, pty: activePty.ptyProcess }).catch( + () => {}, + ); try { (activePty.ptyProcess as IPty & { destroy?: () => void }).destroy?.(); } catch { diff --git a/packages/core/src/telemetry/telemetry-utils.test.ts b/packages/core/src/telemetry/telemetry-utils.test.ts index 4240ae6666..8b1b173a1d 100644 --- a/packages/core/src/telemetry/telemetry-utils.test.ts +++ b/packages/core/src/telemetry/telemetry-utils.test.ts @@ -18,14 +18,14 @@ describe('getProgrammingLanguage', () => { { name: 'file_path is present', args: { file_path: 'src/test.ts' }, - expected: 'TypeScript', + expected: 'typescript', }, { name: 'absolute_path is present', args: { absolute_path: 'src/test.py' }, - expected: 'Python', + expected: 'python', }, - { name: 'path is present', args: { path: 'src/test.go' }, expected: 'Go' }, + { name: 'path is present', args: { path: 'src/test.go' }, expected: 'go' }, { name: 'no file path is present', args: {}, diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index f78f0801af..abbfecf313 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -15,6 +15,7 @@ import { type ApiErrorEvent, type ApiResponseEvent, } from './types.js'; +import { type ConversationRecord } from '../services/chatRecordingService.js'; import type { CompletedToolCall, ErroredToolCall, @@ -698,6 +699,121 @@ describe('UiTelemetryService', () => { }); }); + describe('clear', () => { + it('should reset metrics and last prompt token count', () => { + // Set up initial state with some metrics + const event = { + 'event.name': EVENT_API_RESPONSE, + model: 'gemini-2.5-pro', + duration_ms: 500, + usage: { + input_token_count: 100, + output_token_count: 200, + total_token_count: 300, + cached_content_token_count: 50, + thoughts_token_count: 20, + tool_token_count: 30, + }, + } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; + + service.addEvent(event); + service.setLastPromptTokenCount(123); + + expect(service.getMetrics().models['gemini-2.5-pro']).toBeDefined(); + expect(service.getLastPromptTokenCount()).toBe(123); + + service.clear(); + + expect(service.getMetrics().models).toEqual({}); + expect(service.getLastPromptTokenCount()).toBe(0); + }); + + it('should emit clear and update events', () => { + const clearSpy = vi.fn(); + const updateSpy = vi.fn(); + service.on('clear', clearSpy); + service.on('update', updateSpy); + + const newSessionId = 'new-session-id'; + service.clear(newSessionId); + + expect(clearSpy).toHaveBeenCalledWith(newSessionId); + expect(updateSpy).toHaveBeenCalledOnce(); + const { metrics, lastPromptTokenCount } = updateSpy.mock.calls[0][0]; + expect(metrics.models).toEqual({}); + expect(lastPromptTokenCount).toBe(0); + }); + }); + + describe('hydrate', () => { + it('should aggregate metrics from a ConversationRecord', () => { + const conversation = { + sessionId: 'resumed-session', + messages: [ + { + type: 'user', + content: 'Hello', + }, + { + type: 'gemini', + model: 'gemini-1.5-pro', + tokens: { + input: 10, + output: 20, + total: 30, + cached: 5, + thoughts: 2, + tool: 3, + }, + toolCalls: [ + { name: 'test_tool', status: 'success' }, + { name: 'test_tool', status: 'error' }, + ], + }, + { + type: 'gemini', + model: 'gemini-1.5-pro', + tokens: { + input: 100, + output: 200, + total: 300, + cached: 50, + thoughts: 20, + tool: 30, + }, + }, + ], + } as unknown as ConversationRecord; + + const clearSpy = vi.fn(); + const updateSpy = vi.fn(); + service.on('clear', clearSpy); + service.on('update', updateSpy); + + service.hydrate(conversation); + + expect(clearSpy).toHaveBeenCalledWith('resumed-session'); + const metrics = service.getMetrics(); + const modelMetrics = metrics.models['gemini-1.5-pro']; + + expect(modelMetrics).toBeDefined(); + expect(modelMetrics.tokens.prompt).toBe(110); // 10 + 100 + expect(modelMetrics.tokens.candidates).toBe(220); // 20 + 200 + expect(modelMetrics.tokens.cached).toBe(55); // 5 + 50 + expect(modelMetrics.tokens.thoughts).toBe(22); // 2 + 20 + expect(modelMetrics.tokens.tool).toBe(33); // 3 + 30 + expect(modelMetrics.tokens.input).toBe(55); // 110 - 55 + + expect(metrics.tools.totalCalls).toBe(2); + expect(metrics.tools.totalSuccess).toBe(1); + expect(metrics.tools.totalFail).toBe(1); + expect(metrics.tools.byName['test_tool'].count).toBe(2); + + expect(service.getLastPromptTokenCount()).toBe(300); // 100 (input) + 200 (output) + expect(updateSpy).toHaveBeenCalled(); + }); + }); + describe('Tool Call Event with Line Count Metadata', () => { it('should aggregate valid line count metadata', () => { const toolCall = createFakeCompletedToolCall('test_tool', true, 100); diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 36953c02c1..91a262b964 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -16,6 +16,7 @@ import { } from './types.js'; import { ToolCallDecision } from './tool-call-decision.js'; +import { type ConversationRecord } from '../services/chatRecordingService.js'; export type UiEvent = | (ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }) @@ -185,6 +186,96 @@ export class UiTelemetryService extends EventEmitter { }); } + clear(newSessionId?: string): void { + this.#metrics = createInitialMetrics(); + this.#lastPromptTokenCount = 0; + this.emit('clear', newSessionId); + this.emit('update', { + metrics: this.#metrics, + lastPromptTokenCount: this.#lastPromptTokenCount, + }); + } + + /** + * Hydrates the telemetry metrics from a historical conversation record. + * This is used when resuming a session to restore token counts and tool stats. + */ + hydrate(conversation: ConversationRecord): void { + this.clear(conversation.sessionId); + + let totalTokensInContext = 0; + + for (const message of conversation.messages) { + if (message.type === 'gemini') { + const model = message.model || 'unknown'; + const modelMetrics = this.getOrCreateModelMetrics(model); + + // Restore API request stats + modelMetrics.api.totalRequests++; + + // Restore token metrics + if (message.tokens) { + modelMetrics.tokens.prompt += message.tokens.input; + modelMetrics.tokens.candidates += message.tokens.output; + modelMetrics.tokens.total += message.tokens.total; + modelMetrics.tokens.cached += message.tokens.cached; + modelMetrics.tokens.thoughts += message.tokens.thoughts || 0; + modelMetrics.tokens.tool += message.tokens.tool || 0; + modelMetrics.tokens.input = Math.max( + 0, + modelMetrics.tokens.prompt - modelMetrics.tokens.cached, + ); + + // The total tokens of the last Gemini message represents the context + // size at that point in time. + totalTokensInContext = message.tokens.total; + } + + // Restore tool metrics + if (message.toolCalls) { + for (const toolCall of message.toolCalls) { + this.#metrics.tools.totalCalls++; + if (toolCall.status === 'success') { + this.#metrics.tools.totalSuccess++; + } else if (toolCall.status === 'error') { + this.#metrics.tools.totalFail++; + } + + if (!this.#metrics.tools.byName[toolCall.name]) { + this.#metrics.tools.byName[toolCall.name] = { + count: 0, + success: 0, + fail: 0, + durationMs: 0, + decisions: { + [ToolCallDecision.ACCEPT]: 0, + [ToolCallDecision.REJECT]: 0, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, + }, + }; + } + + const toolStats = this.#metrics.tools.byName[toolCall.name]; + toolStats.count++; + if (toolCall.status === 'success') { + toolStats.success++; + } else if (toolCall.status === 'error') { + toolStats.fail++; + } + } + } + } + } + + this.#lastPromptTokenCount = totalTokensInContext; + + this.emit('update', { + metrics: this.#metrics, + lastPromptTokenCount: this.#lastPromptTokenCount, + }); + } + private getOrCreateModelMetrics(modelName: string): ModelMetrics { if (!this.#metrics.models[modelName]) { this.#metrics.models[modelName] = createInitialModelMetrics(); diff --git a/packages/core/src/utils/language-detection.test.ts b/packages/core/src/utils/language-detection.test.ts new file mode 100644 index 0000000000..e6c8d3f72b --- /dev/null +++ b/packages/core/src/utils/language-detection.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getLanguageFromFilePath } from './language-detection.js'; + +describe('language-detection', () => { + it('should return correct LSP identifiers for various extensions', () => { + expect(getLanguageFromFilePath('test.ts')).toBe('typescript'); + expect(getLanguageFromFilePath('test.js')).toBe('javascript'); + expect(getLanguageFromFilePath('test.py')).toBe('python'); + expect(getLanguageFromFilePath('test.java')).toBe('java'); + expect(getLanguageFromFilePath('test.go')).toBe('go'); + expect(getLanguageFromFilePath('test.cs')).toBe('csharp'); + expect(getLanguageFromFilePath('test.cpp')).toBe('cpp'); + expect(getLanguageFromFilePath('test.sh')).toBe('shellscript'); + expect(getLanguageFromFilePath('test.bat')).toBe('bat'); + expect(getLanguageFromFilePath('test.json')).toBe('json'); + expect(getLanguageFromFilePath('test.md')).toBe('markdown'); + expect(getLanguageFromFilePath('test.tsx')).toBe('typescriptreact'); + expect(getLanguageFromFilePath('test.jsx')).toBe('javascriptreact'); + }); + + it('should handle uppercase extensions', () => { + expect(getLanguageFromFilePath('TEST.TS')).toBe('typescript'); + }); + + it('should handle filenames without extensions but in map', () => { + expect(getLanguageFromFilePath('.gitignore')).toBe('ignore'); + expect(getLanguageFromFilePath('.dockerfile')).toBe('dockerfile'); + expect(getLanguageFromFilePath('Dockerfile')).toBe('dockerfile'); + }); + + it('should return undefined for unknown extensions', () => { + expect(getLanguageFromFilePath('test.unknown')).toBeUndefined(); + }); + + it('should return undefined for files without extension or known filename', () => { + expect(getLanguageFromFilePath('just_a_file')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/utils/language-detection.ts b/packages/core/src/utils/language-detection.ts index ebbefe8b31..c0debcbaea 100644 --- a/packages/core/src/utils/language-detection.ts +++ b/packages/core/src/utils/language-detection.ts @@ -6,98 +6,107 @@ import * as path from 'node:path'; +/** + * Maps file extensions or filenames to LSP 3.18 language identifiers. + * See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocumentItem + */ const extensionToLanguageMap: { [key: string]: string } = { - '.ts': 'TypeScript', - '.js': 'JavaScript', - '.mjs': 'JavaScript', - '.cjs': 'JavaScript', - '.jsx': 'JavaScript', - '.tsx': 'TypeScript', - '.py': 'Python', - '.java': 'Java', - '.go': 'Go', - '.rb': 'Ruby', - '.php': 'PHP', - '.phtml': 'PHP', - '.cs': 'C#', - '.cpp': 'C++', - '.cxx': 'C++', - '.cc': 'C++', - '.c': 'C', - '.h': 'C/C++', - '.hpp': 'C++', - '.swift': 'Swift', - '.kt': 'Kotlin', - '.rs': 'Rust', - '.m': 'Objective-C', - '.mm': 'Objective-C', - '.pl': 'Perl', - '.pm': 'Perl', - '.lua': 'Lua', - '.r': 'R', - '.scala': 'Scala', - '.sc': 'Scala', - '.sh': 'Shell', - '.ps1': 'PowerShell', - '.bat': 'Batch', - '.cmd': 'Batch', - '.sql': 'SQL', - '.html': 'HTML', - '.htm': 'HTML', - '.css': 'CSS', - '.less': 'Less', - '.sass': 'Sass', - '.scss': 'Sass', - '.json': 'JSON', - '.xml': 'XML', - '.yaml': 'YAML', - '.yml': 'YAML', - '.md': 'Markdown', - '.markdown': 'Markdown', - '.dockerfile': 'Dockerfile', - '.vim': 'Vim script', - '.vb': 'Visual Basic', - '.fs': 'F#', - '.clj': 'Clojure', - '.cljs': 'Clojure', - '.dart': 'Dart', - '.ex': 'Elixir', - '.erl': 'Erlang', - '.hs': 'Haskell', - '.lisp': 'Lisp', - '.rkt': 'Racket', - '.groovy': 'Groovy', - '.jl': 'Julia', - '.tex': 'LaTeX', - '.ino': 'Arduino', - '.asm': 'Assembly', - '.s': 'Assembly', - '.toml': 'TOML', - '.vue': 'Vue', - '.svelte': 'Svelte', - '.gohtml': 'Go Template', - '.hbs': 'Handlebars', - '.ejs': 'EJS', - '.erb': 'ERB', - '.jsp': 'JSP', - '.dockerignore': 'Docker', - '.gitignore': 'Git', - '.npmignore': 'npm', - '.editorconfig': 'EditorConfig', - '.prettierrc': 'Prettier', - '.eslintrc': 'ESLint', - '.babelrc': 'Babel', - '.tsconfig': 'TypeScript', - '.flow': 'Flow', - '.graphql': 'GraphQL', - '.proto': 'Protocol Buffers', + '.ts': 'typescript', + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.jsx': 'javascriptreact', + '.tsx': 'typescriptreact', + '.py': 'python', + '.java': 'java', + '.go': 'go', + '.rb': 'ruby', + '.php': 'php', + '.phtml': 'php', + '.cs': 'csharp', + '.cpp': 'cpp', + '.cxx': 'cpp', + '.cc': 'cpp', + '.c': 'c', + '.h': 'c', + '.hpp': 'cpp', + '.swift': 'swift', + '.kt': 'kotlin', + '.rs': 'rust', + '.m': 'objective-c', + '.mm': 'objective-cpp', + '.pl': 'perl', + '.pm': 'perl', + '.lua': 'lua', + '.r': 'r', + '.scala': 'scala', + '.sc': 'scala', + '.sh': 'shellscript', + '.ps1': 'powershell', + '.bat': 'bat', + '.cmd': 'bat', + '.sql': 'sql', + '.html': 'html', + '.htm': 'html', + '.css': 'css', + '.less': 'less', + '.sass': 'sass', + '.scss': 'scss', + '.json': 'json', + '.xml': 'xml', + '.yaml': 'yaml', + '.yml': 'yaml', + '.md': 'markdown', + '.markdown': 'markdown', + '.dockerfile': 'dockerfile', + '.vim': 'vim', + '.vb': 'vb', + '.fs': 'fsharp', + '.clj': 'clojure', + '.cljs': 'clojure', + '.dart': 'dart', + '.ex': 'elixir', + '.erl': 'erlang', + '.hs': 'haskell', + '.lisp': 'lisp', + '.rkt': 'racket', + '.groovy': 'groovy', + '.jl': 'julia', + '.tex': 'latex', + '.ino': 'arduino', + '.asm': 'assembly', + '.s': 'assembly', + '.toml': 'toml', + '.vue': 'vue', + '.svelte': 'svelte', + '.gohtml': 'gohtml', // Not in standard LSP well-known list but kept for compatibility + '.hbs': 'handlebars', + '.ejs': 'ejs', + '.erb': 'erb', + '.jsp': 'jsp', + '.dockerignore': 'ignore', + '.gitignore': 'ignore', + '.npmignore': 'ignore', + '.editorconfig': 'properties', + '.prettierrc': 'json', + '.eslintrc': 'json', + '.babelrc': 'json', + '.tsconfig': 'json', + '.flow': 'javascript', + '.graphql': 'graphql', + '.proto': 'proto', }; export function getLanguageFromFilePath(filePath: string): string | undefined { - const extension = path.extname(filePath).toLowerCase(); - if (extension) { - return extensionToLanguageMap[extension]; - } const filename = path.basename(filePath).toLowerCase(); - return extensionToLanguageMap[`.${filename}`]; + const extension = path.extname(filePath).toLowerCase(); + + const candidates = [ + extension, // 1. Standard extension (e.g., '.js') + filename, // 2. Exact filename (e.g., 'dockerfile') + `.${filename}`, // 3. Dot-prefixed filename (e.g., '.gitignore') + ]; + const match = candidates.find((key) => key in extensionToLanguageMap); + + return match ? extensionToLanguageMap[match] : undefined; } diff --git a/packages/core/src/utils/process-utils.test.ts b/packages/core/src/utils/process-utils.test.ts index 9da6048a15..8bfcd506f8 100644 --- a/packages/core/src/utils/process-utils.test.ts +++ b/packages/core/src/utils/process-utils.test.ts @@ -4,27 +4,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type MockInstance, +} from 'vitest'; import os from 'node:os'; -import { spawn as cpSpawn } from 'node:child_process'; import { killProcessGroup, SIGKILL_TIMEOUT_MS } from './process-utils.js'; +import { spawnAsync } from './shell-utils.js'; vi.mock('node:os'); -vi.mock('node:child_process'); +vi.mock('./shell-utils.js'); describe('process-utils', () => { - const mockProcessKill = vi - .spyOn(process, 'kill') - .mockImplementation(() => true); - const mockSpawn = vi.mocked(cpSpawn); + let mockProcessKill: MockInstance; + let mockSpawnAsync: Mock; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); + mockProcessKill = vi.spyOn(process, 'kill').mockImplementation(() => true); + mockSpawnAsync = vi.mocked(spawnAsync); }); afterEach(() => { vi.useRealTimers(); + vi.restoreAllMocks(); }); describe('killProcessGroup', () => { @@ -33,7 +43,7 @@ describe('process-utils', () => { await killProcessGroup({ pid: 1234 }); - expect(mockSpawn).toHaveBeenCalledWith('taskkill', [ + expect(mockSpawnAsync).toHaveBeenCalledWith('taskkill', [ '/pid', '1234', '/f', @@ -42,14 +52,20 @@ describe('process-utils', () => { expect(mockProcessKill).not.toHaveBeenCalled(); }); - it('should use pty.kill() on Windows if pty is provided', async () => { + it('should use pty.kill() on Windows if pty is provided and also taskkill for descendants', async () => { vi.mocked(os.platform).mockReturnValue('win32'); const mockPty = { kill: vi.fn() }; await killProcessGroup({ pid: 1234, pty: mockPty }); expect(mockPty.kill).toHaveBeenCalled(); - expect(mockSpawn).not.toHaveBeenCalled(); + // taskkill is also called to reap orphaned descendant processes + expect(mockSpawnAsync).toHaveBeenCalledWith('taskkill', [ + '/pid', + '1234', + '/f', + '/t', + ]); }); it('should kill the process group on Unix with SIGKILL by default', async () => { @@ -130,5 +146,23 @@ describe('process-utils', () => { expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL'); }); + + it('should attempt process group kill on Unix after pty fallback to reap orphaned descendants', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + // First call (group kill) throws to trigger PTY fallback + mockProcessKill.mockImplementationOnce(() => { + throw new Error('ESRCH'); + }); + // Second call (group kill retry after pty.kill) should succeed + mockProcessKill.mockImplementationOnce(() => true); + const mockPty = { kill: vi.fn() }; + + await killProcessGroup({ pid: 1234, pty: mockPty }); + + // Group kill should be called first to ensure it's hit before PTY leader dies + expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL'); + // Then PTY kill should be called + expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL'); + }); }); }); diff --git a/packages/core/src/utils/process-utils.ts b/packages/core/src/utils/process-utils.ts index 74f802718f..9ea7b00d0f 100644 --- a/packages/core/src/utils/process-utils.ts +++ b/packages/core/src/utils/process-utils.ts @@ -5,7 +5,8 @@ */ import os from 'node:os'; -import { spawn as cpSpawn } from 'node:child_process'; + +import { spawnAsync } from './shell-utils.js'; /** Default timeout for SIGKILL escalation on Unix systems. */ export const SIGKILL_TIMEOUT_MS = 200; @@ -44,8 +45,12 @@ export async function killProcessGroup(options: KillOptions): Promise { } catch { // Ignore errors for dead processes } - } else { - cpSpawn('taskkill', ['/pid', pid.toString(), '/f', '/t']); + } + // Invoke taskkill to ensure the entire tree is terminated and any orphaned descendant processes are reaped. + try { + await spawnAsync('taskkill', ['/pid', pid.toString(), '/f', '/t']); + } catch (_e) { + // Ignore errors if the process tree is already dead } return; } @@ -73,14 +78,24 @@ export async function killProcessGroup(options: KillOptions): Promise { if (pty) { if (escalate) { try { + // Attempt the group kill BEFORE the pty session leader dies + process.kill(-pid, 'SIGTERM'); pty.kill('SIGTERM'); await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); - if (!isExited()) pty.kill('SIGKILL'); + if (!isExited()) { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // Ignore + } + pty.kill('SIGKILL'); + } } catch { // Ignore } } else { try { + process.kill(-pid, 'SIGKILL'); // Group kill first pty.kill('SIGKILL'); } catch { // Ignore diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 3d7f64c172..ec2873378e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -54,8 +54,8 @@ }, "defaultApprovalMode": { "title": "Default Approval Mode", - "description": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet.", - "markdownDescription": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `default`", + "description": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo).", + "markdownDescription": "The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo).\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `default`", "default": "default", "type": "string", "enum": ["default", "auto_edit", "plan"] diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index ee908ff25d..10c95d9649 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -27,6 +27,7 @@ const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md']; import { formatKeyBinding } from '../packages/cli/src/ui/key/keybindingUtils.js'; export interface KeybindingDocCommand { + command: string; description: string; bindings: readonly KeyBinding[]; } @@ -81,6 +82,7 @@ export function buildDefaultDocSections(): readonly KeybindingDocSection[] { return commandCategories.map((category) => ({ title: category.title, commands: category.commands.map((command) => ({ + command: command, description: commandDescriptions[command], bindings: defaultKeyBindingConfig.get(command) ?? [], })), @@ -94,14 +96,14 @@ export function renderDocumentation( const rows = section.commands.map((command) => { const formattedBindings = formatBindings(command.bindings); const keysCell = formattedBindings.join('
'); - return `| ${command.description} | ${keysCell} |`; + return `| \`${command.command}\` | ${command.description} | ${keysCell} |`; }); return [ `#### ${section.title}`, '', - '| Action | Keys |', - '| --- | --- |', + '| Command | Action | Keys |', + '| --- | --- | --- |', ...rows, ].join('\n'); }); diff --git a/scripts/tests/generate-keybindings-doc.test.ts b/scripts/tests/generate-keybindings-doc.test.ts index 19ba2e0f98..e6319e03fe 100644 --- a/scripts/tests/generate-keybindings-doc.test.ts +++ b/scripts/tests/generate-keybindings-doc.test.ts @@ -10,6 +10,7 @@ import { renderDocumentation, type KeybindingDocSection, } from '../generate-keybindings-doc.ts'; +import { KeyBinding } from '../../packages/cli/src/ui/key/keyBindings.js'; describe('generate-keybindings-doc', () => { it('keeps keyboard shortcut documentation in sync in check mode', async () => { @@ -31,12 +32,14 @@ describe('generate-keybindings-doc', () => { title: 'Custom Controls', commands: [ { + command: 'custom.trigger', description: 'Trigger custom action.', - bindings: [{ key: 'x', ctrl: true }], + bindings: [new KeyBinding('ctrl+x')], }, { + command: 'custom.submit', description: 'Submit with Enter if no modifiers are held.', - bindings: [{ key: 'enter', shift: false, ctrl: false }], + bindings: [new KeyBinding('enter')], }, ], }, @@ -44,11 +47,9 @@ describe('generate-keybindings-doc', () => { title: 'Navigation', commands: [ { + command: 'nav.up', description: 'Move up through results.', - bindings: [ - { key: 'up', shift: false }, - { key: 'p', shift: false, ctrl: true }, - ], + bindings: [new KeyBinding('up'), new KeyBinding('ctrl+p')], }, ], }, @@ -56,11 +57,14 @@ describe('generate-keybindings-doc', () => { const markdown = renderDocumentation(sections); expect(markdown).toContain('#### Custom Controls'); + expect(markdown).toContain('`custom.trigger`'); expect(markdown).toContain('Trigger custom action.'); expect(markdown).toContain('`Ctrl+X`'); + expect(markdown).toContain('`custom.submit`'); expect(markdown).toContain('Submit with Enter if no modifiers are held.'); expect(markdown).toContain('`Enter`'); expect(markdown).toContain('#### Navigation'); + expect(markdown).toContain('`nav.up`'); expect(markdown).toContain('Move up through results.'); expect(markdown).toContain('`Up`
`Ctrl+P`'); });