From 135dbd99b54c4b4229d0d8d2c8420bfc674b0807 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Sat, 28 Feb 2026 16:17:42 -0500 Subject: [PATCH 01/37] feat: add native voice input support (Gemini zero-install + Whisper) --- docs/cli/settings.md | 9 + docs/reference/configuration.md | 27 ++ docs/reference/keyboard-shortcuts.md | 6 + package-lock.json | 25 +- packages/cli/src/config/keyBindings.ts | 16 + packages/cli/src/config/settingsSchema.ts | 52 ++++ packages/cli/src/nonInteractiveCli.ts | 5 +- packages/cli/src/nonInteractiveCliCommands.ts | 5 + .../cli/src/services/BuiltinCommandLoader.ts | 7 +- packages/cli/src/test-utils/render.tsx | 71 +++-- packages/cli/src/ui/AppContainer.tsx | 36 ++- .../cli/src/ui/commands/extensionsCommand.ts | 2 +- packages/cli/src/ui/commands/types.ts | 1 + packages/cli/src/ui/commands/voiceCommand.ts | 214 ++++++++++++++ packages/cli/src/ui/components/Composer.tsx | 1 + .../src/ui/components/HistoryItemDisplay.tsx | 6 + .../cli/src/ui/components/InputPrompt.tsx | 92 +++++- packages/cli/src/ui/components/VoiceHelp.tsx | 108 +++++++ .../cli/src/ui/components/VoiceStatus.tsx | 63 ++++ .../__snapshots__/AskUserDialog.test.tsx.snap | 74 +++++ .../__snapshots__/InputPrompt.test.tsx.snap | 21 -- .../__snapshots__/RewindViewer.test.tsx.snap | 148 ++++++++++ .../BaseSelectionList.test.tsx.snap | 111 +++++++ .../cli/src/ui/contexts/VoiceContext.test.tsx | 38 +++ packages/cli/src/ui/contexts/VoiceContext.tsx | 23 ++ .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 16 + .../ui/hooks/useVoiceInput.log-volume.test.ts | 115 ++++++++ .../hooks/useVoiceInput.replication.test.tsx | 149 ++++++++++ .../src/ui/hooks/useVoiceInput.stress.test.ts | 118 ++++++++ .../cli/src/ui/hooks/useVoiceInput.test.ts | 202 +++++++++++++ packages/cli/src/ui/hooks/useVoiceInput.ts | 179 ++++++++++++ packages/cli/src/ui/keyMatchers.test.ts | 11 +- .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + packages/cli/src/ui/types.ts | 30 ++ packages/core/src/index.ts | 4 + .../src/services/voice/GeminiRestBackend.ts | 274 ++++++++++++++++++ .../src/services/voice/LocalWhisperBackend.ts | 223 ++++++++++++++ packages/core/src/services/voice/types.ts | 44 +++ packages/core/src/utils/events.ts | 25 +- schemas/settings.schema.json | 36 +++ 41 files changed, 2520 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/ui/commands/voiceCommand.ts create mode 100644 packages/cli/src/ui/components/VoiceHelp.tsx create mode 100644 packages/cli/src/ui/components/VoiceStatus.tsx create mode 100644 packages/cli/src/ui/contexts/VoiceContext.test.tsx create mode 100644 packages/cli/src/ui/contexts/VoiceContext.tsx create mode 100644 packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts create mode 100644 packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx create mode 100644 packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts create mode 100644 packages/cli/src/ui/hooks/useVoiceInput.test.ts create mode 100644 packages/cli/src/ui/hooks/useVoiceInput.ts create mode 100644 packages/core/src/services/voice/GeminiRestBackend.ts create mode 100644 packages/core/src/services/voice/LocalWhisperBackend.ts create mode 100644 packages/core/src/services/voice/types.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 37508fc04eb..5893d4cd77d 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -75,6 +75,15 @@ they appear in the UI. | Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | +### Voice + +| UI Label | Setting | Description | Default | +| --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Voice Input | `voice.enabled` | Enable voice input. When enabled, press **Alt+R** or **Ctrl+Q** to start/stop recording. | `false` | +| Transcription Backend | `voice.provider` | Transcription backend to use: `gemini` (zero-install, uses existing Gemini API auth) or `whisper` (local binary). | `"gemini"` | +| Silence Detection | `voice.silenceThreshold` | RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values capture quieter speech (e.g. whispering). `0` disables silence detection. | `80` | +| Whisper Binary Path | `voice.whisperPath` | Path to the Whisper executable. Only used when `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper`). | `undefined` | + ### IDE | UI Label | Setting | Description | Default | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 82ee987eb2e..cfd35435c07 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -340,6 +340,33 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +#### `voice` + +- **`voice.enabled`** (boolean): + - **Description:** Enable voice input. When enabled, press **Alt+R** or + **Ctrl+Q** to start/stop recording. Use `/voice enable` or `/voice disable` + to toggle. + - **Default:** `false` + +- **`voice.provider`** (string: `"gemini"` | `"whisper"`): + - **Description:** Transcription backend. `gemini` uses the Gemini API with + your existing auth (zero additional setup). `whisper` uses a locally + installed Whisper binary for offline/faster transcription. + - **Default:** `"gemini"` + +- **`voice.silenceThreshold`** (number, 0–1000): + - **Description:** RMS energy threshold for silence detection. Audio below + this level is discarded without an API call. Lower values capture quieter + speech (e.g. whispering). Set to `0` to disable silence detection and always + transcribe. Use `/voice sensitivity ` to adjust at runtime. + - **Default:** `80` + +- **`voice.whisperPath`** (string): + - **Description:** Path to the Whisper executable. Only used when + `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper` or + `~/.local/bin/whisper`). Use `/voice set-path ` to set at runtime. + - **Default:** `undefined` + #### `ide` - **`ide.enabled`** (boolean): diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index e5691c43ee2..2e714d22998 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -94,6 +94,12 @@ available combinations. | Open the current prompt or the plan in an external editor. | `Ctrl + X` | | Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | +#### Voice Input + +| Action | Keys | +| ----------------------------------------------- | ------------------------- | +| Toggle voice input recording (Alt+R or Ctrl+Q). | `Alt + R`
`Ctrl + Q` | + #### App Controls | Action | Keys | diff --git a/package-lock.json b/package-lock.json index 85448711c7b..74ecb5379c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2292,6 +2292,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2472,6 +2473,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2521,6 +2523,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2895,6 +2898,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2928,6 +2932,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2982,6 +2987,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4178,6 +4184,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4451,6 +4458,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5298,6 +5306,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7901,6 +7910,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8533,6 +8543,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10126,6 +10137,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13808,6 +13820,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13818,6 +13831,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15906,6 +15920,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16129,7 +16144,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16137,6 +16153,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16296,6 +16313,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16519,6 +16537,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16632,6 +16651,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16644,6 +16664,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17288,6 +17309,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17687,6 +17709,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 3122acef1d9..db07a91b585 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -97,6 +97,9 @@ export enum Command { CLEAR_SCREEN = 'app.clearScreen', RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', + + // Voice Input + VOICE_INPUT = 'input.voice', } /** @@ -297,6 +300,12 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], + + // Voice Input + [Command.VOICE_INPUT]: [ + { key: 'r', alt: true }, // Alt+R + { key: 'q', ctrl: true }, // Ctrl+Q + ], }; interface CommandCategory { @@ -391,6 +400,10 @@ export const commandCategories: readonly CommandCategory[] = [ Command.PASTE_CLIPBOARD, ], }, + { + title: 'Voice Input', + commands: [Command.VOICE_INPUT], + }, { title: 'App Controls', commands: [ @@ -525,4 +538,7 @@ export const commandDescriptions: Readonly> = { [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', + + // Voice Input + [Command.VOICE_INPUT]: 'Toggle voice input recording (Alt+R or Ctrl+Q).', }; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fb0520d3349..15ba6fbf5e7 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -774,6 +774,58 @@ const SETTINGS_SCHEMA = { }, }, + voice: { + type: 'object', + label: 'Voice Input', + category: 'General', + requiresRestart: false, + default: {}, + description: 'Settings for voice input.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Voice Input', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable voice input support.', + showInDialog: true, + }, + provider: { + type: 'string', + label: 'Transcription Backend', + category: 'General', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Transcription backend: "gemini" (default, zero-install) or "whisper" (local).', + showInDialog: true, + }, + whisperPath: { + type: 'string', + label: 'Whisper Binary Path', + category: 'General', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Path to the whisper executable. Only used when provider is "whisper".', + showInDialog: true, + }, + silenceThreshold: { + type: 'number', + label: 'Silence Detection Threshold', + category: 'General', + requiresRestart: false, + default: 80, + description: + 'RMS energy threshold (0–1000) below which audio is discarded as silence. ' + + 'Lower values allow quieter speech such as whispering. 0 disables silence detection.', + showInDialog: true, + }, + }, + }, + ide: { type: 'object', label: 'IDE', diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c2cab72353b..95257702a0f 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -247,11 +247,12 @@ export async function runNonInteractive({ settings, ); // If a slash command is found and returns a prompt, use it. - // Otherwise, slashCommandResult falls through to the default prompt - // handling. + // Otherwise, if it was a slash command, we are done. if (slashCommandResult) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion query = slashCommandResult as Part[]; + } else { + return; } } diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index e09db71312b..8e764942efc 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -90,6 +90,11 @@ export const handleSlashCommand = async ( switch (result.type) { case 'submit_prompt': return result.content; + case 'message': { + const prefix = result.messageType?.toUpperCase() || 'INFO'; + process.stdout.write(`[${prefix}] ${result.content}\n`); + return; + } case 'confirm_shell_commands': // This result indicates a command attempted to confirm shell commands. // However note that currently, ShellTool is excluded in non-interactive diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 31673e921a0..1cdb18ed26d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -58,6 +58,7 @@ import { shellsCommand } from '../ui/commands/shellsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; +import { voiceCommand } from '../ui/commands/voiceCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -73,7 +74,10 @@ export class BuiltinCommandLoader implements ICommandLoader { * @param _signal An AbortSignal (unused for this synchronous loader). * @returns A promise that resolves to an array of `SlashCommand` objects. */ - async loadCommands(_signal: AbortSignal): Promise { + async loadCommands(signal: AbortSignal): Promise { + if (signal.aborted) { + return []; + } const handle = startupProfiler.start('load_builtin_commands'); const isNightlyBuild = await isNightly(process.cwd()); @@ -185,6 +189,7 @@ export class BuiltinCommandLoader implements ICommandLoader { vimCommand, setupGithubCommand, terminalSetupCommand, + voiceCommand, ]; handle?.end(); return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 86c46e79e51..a3578ae578a 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -42,6 +42,8 @@ import { type OverflowActions, type OverflowState, } from '../ui/contexts/OverflowContext.js'; +import { VoiceContext } from '../ui/contexts/VoiceContext.js'; +import type { VoiceInputReturn } from '../ui/hooks/useVoiceInput.js'; import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; @@ -563,6 +565,19 @@ export const mockAppState: AppState = { startupWarnings: [], }; +const mockVoiceReturn: VoiceInputReturn = { + isEnabled: true, + state: { + isRecording: false, + isTranscribing: false, + error: null, + }, + startRecording: vi.fn(async () => {}), + stopRecording: vi.fn(async () => {}), + cancelRecording: vi.fn(async () => {}), + toggleRecording: vi.fn(async () => {}), +}; + const mockUIActions: UIActions = { handleThemeSelect: vi.fn(), closeThemeDialog: vi.fn(), @@ -646,6 +661,7 @@ export const renderWithProviders = ( uiActions, persistentState, appState = mockAppState, + voice = mockVoiceReturn, }: { shellFocus?: boolean; settings?: LoadedSettings; @@ -660,6 +676,7 @@ export const renderWithProviders = ( set?: typeof persistentStateMock.set; }; appState?: AppState; + voice?: VoiceInputReturn; } = {}, ): RenderInstance & { simulateClick: ( @@ -768,32 +785,34 @@ export const renderWithProviders = ( config={finalConfig} toolCalls={allToolCalls} > - - - - - - - - {component} - - - - - - - + + + + + + + + + {component} + + + + + + + + diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d656169c518..27aed563374 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -22,6 +22,7 @@ import { } from 'ink'; import { App } from './App.js'; import { AppContext } from './contexts/AppContext.js'; +import { VoiceContext } from './contexts/VoiceContext.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIActionsContext, @@ -117,6 +118,7 @@ import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; import { useFocus } from './hooks/useFocus.js'; +import { useVoiceInput, type VoiceInputConfig } from './hooks/useVoiceInput.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; import { keyMatchers, Command } from './keyMatchers.js'; @@ -224,6 +226,28 @@ export const AppContainer = (props: AppContainerProps) => { const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); + const rawProvider = settings.merged.voice?.provider; + const voiceConfig = useMemo( + () => ({ + provider: + rawProvider === 'gemini' || rawProvider === 'whisper' + ? rawProvider + : undefined, + whisperPath: settings.merged.voice?.whisperPath, + silenceThreshold: settings.merged.voice?.silenceThreshold, + config, + }), + [ + rawProvider, + settings.merged.voice?.whisperPath, + settings.merged.voice?.silenceThreshold, + config, + ], + ); + const voice = useVoiceInput( + settings.merged.voice?.enabled ? voiceConfig : undefined, + ); + const historyManager = useHistory({ chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), }); @@ -921,6 +945,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }, toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), setText: stableSetText, + toggleVoice: voice.toggleRecording, }), [ setAuthState, @@ -940,6 +965,7 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, setShortcutsHelpVisible, stableSetText, + voice, ], ); @@ -1741,6 +1767,9 @@ Logging in with Google... Restarting Gemini CLI to continue. } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); return true; + } else if (keyMatchers[Command.VOICE_INPUT](key)) { + void voice.toggleRecording(); + return true; } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; @@ -1869,6 +1898,9 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.general.devtools, showErrorDetails, triggerExpandHint, + tabFocusTimeoutRef, + handleWarning, + voice, ], ); @@ -2605,7 +2637,9 @@ Logging in with Google... Restarting Gemini CLI to continue. > - + + + diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 842a680a14c..fd9e9616166 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -209,7 +209,7 @@ async function restartAction( const s = extensionsToRestart.length > 1 ? 's' : ''; const restartingMessage = { - type: MessageType.INFO, + type: 'info' as const, text: `Restarting ${extensionsToRestart.length} extension${s}...`, color: theme.text.primary, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7e..912ec1d1c11 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -92,6 +92,7 @@ export interface CommandContext { removeComponent: () => void; toggleBackgroundShell: () => void; toggleShortcutsHelp: () => void; + toggleVoice: () => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/commands/voiceCommand.ts b/packages/cli/src/ui/commands/voiceCommand.ts new file mode 100644 index 00000000000..c194504897e --- /dev/null +++ b/packages/cli/src/ui/commands/voiceCommand.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; +import { + MessageType, + type HistoryItemVoiceHelp, + type HistoryItemVoiceStatus, +} from '../types.js'; +import { SettingScope } from '../../config/settings.js'; + +const voiceHelpCommand: SlashCommand = { + name: 'help', + description: 'Show all voice configuration commands and shortcuts', + kind: CommandKind.BUILT_IN, + action: async (context) => { + const item: Omit = { + type: MessageType.VOICE_HELP, + timestamp: new Date(), + }; + context.ui.addItem(item); + }, +}; + +const voiceEnableCommand: SlashCommand = { + name: 'enable', + description: 'Enable voice input (record with Alt+R or Ctrl+Q)', + kind: CommandKind.BUILT_IN, + action: async (context) => { + context.services.settings.setValue( + SettingScope.User, + 'voice.enabled', + true, + ); + return { + type: 'message', + messageType: MessageType.INFO, + content: 'Voice input enabled. Press Alt+R or Ctrl+Q to start recording.', + }; + }, +}; + +const voiceDisableCommand: SlashCommand = { + name: 'disable', + description: 'Disable voice input', + kind: CommandKind.BUILT_IN, + action: async (context) => { + context.services.settings.setValue( + SettingScope.User, + 'voice.enabled', + false, + ); + return { + type: 'message', + messageType: MessageType.INFO, + content: 'Voice input disabled.', + }; + }, +}; + +const voiceProviderCommand: SlashCommand = { + name: 'provider', + description: 'Set transcription backend: gemini (default) or whisper', + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + const provider = args?.trim().toLowerCase(); + if (provider !== 'gemini' && provider !== 'whisper') { + return { + type: 'message', + messageType: MessageType.ERROR, + content: 'Usage: /voice provider [gemini|whisper]', + }; + } + context.services.settings.setValue( + SettingScope.User, + 'voice.provider', + provider, + ); + return { + type: 'message', + messageType: MessageType.INFO, + content: `Voice transcription backend set to: ${provider}`, + }; + }, +}; + +const voiceSensitivityCommand: SlashCommand = { + name: 'sensitivity', + description: 'Set silence threshold (0=off, 80=default, 300+=loud only)', + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + const raw = args?.trim(); + const value = raw !== undefined && raw !== '' ? Number(raw) : NaN; + if (isNaN(value) || value < 0 || value > 1000) { + return { + type: 'message', + messageType: MessageType.ERROR, + content: + 'Usage: /voice sensitivity <0-1000>\n' + + ' 0 = disable silence detection (captures all audio)\n' + + ' 1-80 = sensitive (whispered speech)\n' + + ' 80 = default\n' + + ' 300+ = only loud speech', + }; + } + context.services.settings.setValue( + SettingScope.User, + 'voice.silenceThreshold', + value, + ); + const hint = + value === 0 + ? 'Silence detection disabled.' + : value <= 80 + ? 'Sensitive — whispered speech will be captured.' + : value <= 200 + ? 'Moderate — quiet speech captured.' + : 'High — only louder speech captured.'; + return { + type: 'message', + messageType: MessageType.INFO, + content: `Voice sensitivity threshold set to ${value}. ${hint}`, + }; + }, +}; + +export const voiceCommand: SlashCommand = { + name: 'voice', + description: 'Configure voice input settings', + kind: CommandKind.BUILT_IN, + subCommands: [ + voiceHelpCommand, + voiceEnableCommand, + voiceDisableCommand, + voiceProviderCommand, + voiceSensitivityCommand, + ], + action: async (context, args) => { + const trimmedArgs = args?.trim() || ''; + const parts = trimmedArgs.split(/\s+/); + const subCommand = parts[0]?.toLowerCase(); + + if (subCommand === 'enable') { + return voiceEnableCommand.action!(context, parts.slice(1).join(' ')); + } + + if (subCommand === 'disable') { + return voiceDisableCommand.action!(context, parts.slice(1).join(' ')); + } + + if (subCommand === 'provider') { + return voiceProviderCommand.action!(context, parts.slice(1).join(' ')); + } + + if (subCommand === 'sensitivity') { + return voiceSensitivityCommand.action!(context, parts.slice(1).join(' ')); + } + + // /voice set-path — functional but not in autocomplete hints + if (subCommand === 'set-path') { + const path = parts[1]; + if (!path) { + return { + type: 'message', + messageType: MessageType.ERROR, + content: 'Usage: /voice set-path ', + }; + } + context.services.settings.setValue( + SettingScope.User, + 'voice.whisperPath', + path, + ); + return { + type: 'message', + messageType: MessageType.INFO, + content: `Whisper binary path set to: ${path}`, + }; + } + + if (subCommand === 'help') { + return voiceHelpCommand.action!(context, ''); + } + + // Default: show current voice settings status + const voiceSettings = context.services.settings.merged.voice ?? {}; + const enabled = voiceSettings.enabled ?? false; + const provider = voiceSettings.provider ?? 'gemini'; + const whisperPath = voiceSettings.whisperPath ?? '(not set)'; + const threshold: number = voiceSettings.silenceThreshold ?? 80; + const sensitivityLabel = + threshold === 0 + ? 'disabled' + : threshold <= 80 + ? `${threshold} (whisper-sensitive)` + : threshold <= 300 + ? `${threshold} (moderate)` + : `${threshold} (loud speech only)`; + + const statusItem: Omit = { + type: MessageType.VOICE_STATUS, + timestamp: new Date(), + enabled, + provider, + sensitivityLabel, + whisperPath, + }; + context.ui.addItem(statusItem); + return; + }, +}; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 51c879e772b..3720ade35e4 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -435,6 +435,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {uiState.isInputActive && ( = ({ {itemForDisplay.type === 'help' && commands && ( )} + {itemForDisplay.type === 'voice_help' && } + {itemForDisplay.type === 'voice_status' && ( + + )} {itemForDisplay.type === 'stats' && ( = ({ [], ); + // Voice input hook - MUST be before handleSubmit + // NOTE: Transcript is delivered via events, not context, to avoid infinite render loops + const { + isEnabled: voiceEnabled, + state: voiceState, + toggleRecording, + cancelRecording, + } = useVoiceContext(); + + // Handle voice transcript via event listener (not context) to avoid re-renders + useEffect(() => { + const handleTranscript = (transcript: string) => { + // Insert transcribed text at cursor position with trailing space for next input + buffer.insert(transcript + ' '); + }; + + const unsubscribe = onVoiceTranscript(handleTranscript); + return unsubscribe; + }, [buffer]); + const handleSubmitAndClear = useCallback( (submittedValue: string) => { let processedValue = submittedValue; @@ -654,6 +679,20 @@ export const InputPrompt: React.FC = ({ return true; } + // Hide the shortcuts panel if voice is active or if other keys are pressed + if (shortcutsHelpVisible) { + setShortcutsHelpVisible(false); + } + + // Cancel active voice transcription if escape is hit + if (key.name === 'escape') { + if (voiceState.isRecording || voiceState.isTranscribing) { + cancelRecording(); + resetEscapeState(); + return true; + } + } + if ( key.name === 'escape' && (streamingState === StreamingState.Responding || @@ -698,7 +737,6 @@ export const InputPrompt: React.FC = ({ } else { resetPlainTabPress(); } - if (key.name === 'paste') { if (shortcutsHelpVisible) { setShortcutsHelpVisible(false); @@ -794,6 +832,11 @@ export const InputPrompt: React.FC = ({ } if (keyMatchers[Command.ESCAPE](key)) { + if (voiceState.isRecording) { + void cancelRecording(); + return true; + } + const cancelSearch = ( setActive: (active: boolean) => void, resetCompletion: () => void, @@ -842,12 +885,24 @@ export const InputPrompt: React.FC = ({ return true; } + if (keyMatchers[Command.QUIT](key)) { + if (voiceState.isRecording) { + void toggleRecording(); + return true; + } + } + if (keyMatchers[Command.CLEAR_SCREEN](key)) { setBannerVisible(false); onClearScreen(); return true; } + if (voiceEnabled && keyMatchers[Command.VOICE_INPUT](key)) { + void toggleRecording(); + return true; + } + if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { setReverseSearchActive(true); setTextBeforeReverseSearch(buffer.text); @@ -1259,6 +1314,11 @@ export const InputPrompt: React.FC = ({ shouldShowSuggestions, isShellSuggestionsVisible, forceShowShellSuggestions, + history, + voiceEnabled, + toggleRecording, + cancelRecording, + voiceState.isRecording, ], ); @@ -1431,6 +1491,24 @@ export const InputPrompt: React.FC = ({ statusText = 'Accepting edits'; } + // Voice input status + // ⚠️ Voice state intentionally overrides mode indicators + // (recording must always be visible regardless of approval mode) + if (voiceState.isRecording) { + statusColor = theme.status.error; + statusText = '🎤 Recording... (Alt+R/Ctrl+Q to stop, Esc to cancel)'; + } else if (voiceState.isTranscribing) { + statusColor = theme.status.warning; + statusText = 'Transcribing...'; + } + + // Dynamic placeholder reflecting voice state + const effectivePlaceholder = voiceState.isRecording + ? 'Speak now...' + : voiceState.isTranscribing + ? 'Transcribing your speech...' + : placeholder; + const suggestionsNode = shouldShowSuggestions ? ( = ({ (r:) ) : showYoloStyling ? ( '*' + ) : voiceState.isRecording ? ( + '🎤' + ) : voiceState.isTranscribing ? ( + ) : ( '>' )}{' '} - {buffer.text.length === 0 && placeholder ? ( + {buffer.text.length === 0 && effectivePlaceholder ? ( showCursor ? ( - {chalk.inverse(placeholder.slice(0, 1))} + {chalk.inverse(effectivePlaceholder.slice(0, 1))} - {placeholder.slice(1)} + {effectivePlaceholder.slice(1)} ) : ( - {placeholder} + {effectivePlaceholder} ) ) : ( linesToRender diff --git a/packages/cli/src/ui/components/VoiceHelp.tsx b/packages/cli/src/ui/components/VoiceHelp.tsx new file mode 100644 index 00000000000..114f442e351 --- /dev/null +++ b/packages/cli/src/ui/components/VoiceHelp.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; + +export const VoiceHelp: React.FC = () => ( + + + Voice Input: + + + + + + Commands: + + {[ + ['/voice', 'Show current voice settings'], + ['/voice enable', 'Enable voice input'], + ['/voice disable', 'Disable voice input'], + ['/voice provider [gemini|whisper]', 'Set transcription backend'], + ['/voice sensitivity <0-1000>', 'Set silence detection threshold'], + ['/voice set-path ', 'Set path to Whisper binary'], + ['/voice help', 'Show this help'], + ].map(([cmd, desc]) => ( + + + {' '} + {cmd} + + {' - ' + desc} + + ))} + + + + + Recording Shortcuts: + + + + Alt+R + + {' or '} + + Ctrl+Q + + {' - Start / stop recording'} + + + + Esc + + {' - Cancel recording (discards audio, no transcription)'} + + + + + + Silence Sensitivity Guide: + + {[ + ['0', 'Disable silence detection (captures all audio)'], + ['1–80', 'Sensitive — whispered speech (default: 80)'], + ['80–300', 'Moderate — quiet speech'], + ['300+', 'High — only louder speech'], + ].map(([val, desc]) => ( + + + {' '} + {val} + + {' ' + desc} + + ))} + + + + + Transcription Backends: + + + + {' '} + gemini + + {' Zero-install. Uses your existing Gemini API auth. (default)'} + + + + {' '} + whisper + + {' Local Whisper binary (faster-whisper or openai-whisper).'} + + +); diff --git a/packages/cli/src/ui/components/VoiceStatus.tsx b/packages/cli/src/ui/components/VoiceStatus.tsx new file mode 100644 index 00000000000..18a6609acfa --- /dev/null +++ b/packages/cli/src/ui/components/VoiceStatus.tsx @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import type { HistoryItemVoiceStatus } from '../types.js'; + +interface Props { + item: HistoryItemVoiceStatus; +} + +export const VoiceStatus: React.FC = ({ item }) => ( + + + Voice Input Settings: + + + + + {[ + ['Enabled', item.enabled ? 'yes' : 'no'], + ['Provider', item.provider], + ['Sensitivity', item.sensitivityLabel], + ['Whisper path', item.whisperPath], + ].map(([label, value]) => ( + + + {' '} + {label} + + {' ' + value} + + ))} + + + + + Shortcuts:{' '} + + Alt+R + + {' or '} + + Ctrl+Q + + {' to record · '} + + /voice help + + {' for all commands'} + + +); diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 96440266340..61414fe2f20 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -150,6 +150,43 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; +exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` "What should we name this component? @@ -276,3 +313,40 @@ README → (not answered) Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel " `; + +exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index f40887b3b9c..5a2819702e0 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -78,27 +78,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub " `; -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ -" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ -" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ -" -`; - exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index 06f7d49105f..ff54d49c4d5 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -110,6 +110,43 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] " `; +exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -134,6 +171,43 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` " `; +exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -158,6 +232,43 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] " `; +exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -182,6 +293,43 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = " `; +exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap index 040b6babeca..d22a6cad6e4 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap @@ -16,6 +16,43 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro " `; +exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = ` "▲ 4. Item 4 @@ -25,6 +62,43 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro " `; +exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; + exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = ` "▲ ● 1. Item 1 @@ -33,3 +107,40 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro ▼ " `; + +exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 2`] = ` +" + ERROR Text string " " must be rendered inside component + + file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 + + - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) + -completeWor + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:9082:42) + -runWithFiberIn + EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:1738:13) + -completeUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve + lopment.js:12962:19) + -performUnitOfW + rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12843:11) + -workLoopSyn + (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:12644:41) + -renderRootSy + c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop + ment.js:12624:11) + -performWorkOnR + ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12135:44) + -performSyncWorkOn + oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de + velopment.js:2446:7) + -flushSyncWorkAcrossRoo + s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil + er.development.js:2294:21) +" +`; diff --git a/packages/cli/src/ui/contexts/VoiceContext.test.tsx b/packages/cli/src/ui/contexts/VoiceContext.test.tsx new file mode 100644 index 00000000000..60e93fd33f4 --- /dev/null +++ b/packages/cli/src/ui/contexts/VoiceContext.test.tsx @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { VoiceContext, useVoiceContext } from './VoiceContext.js'; +import type { VoiceInputReturn } from '../hooks/useVoiceInput.js'; +import type React from 'react'; + +describe('VoiceContext', () => { + it('should provide voice input state', () => { + const mockVoiceInput: VoiceInputReturn = { + isEnabled: true, + state: { + isRecording: false, + isTranscribing: false, + error: null, + }, + startRecording: vi.fn(), + stopRecording: vi.fn(), + cancelRecording: vi.fn(), + toggleRecording: vi.fn(), + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useVoiceContext(), { wrapper }); + + expect(result.current).toBe(mockVoiceInput); + }); +}); diff --git a/packages/cli/src/ui/contexts/VoiceContext.tsx b/packages/cli/src/ui/contexts/VoiceContext.tsx new file mode 100644 index 00000000000..69136fb2a42 --- /dev/null +++ b/packages/cli/src/ui/contexts/VoiceContext.tsx @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createContext, useContext } from 'react'; +import type { VoiceInputReturn } from '../hooks/useVoiceInput.js'; + +export const VoiceContext = createContext(null); + +export const useVoiceContext = () => { + const context = useContext(VoiceContext); + if (!context) { + throw new Error( + 'useVoiceContext must be used within a VoiceContext.Provider', + ); + } + return context; +}; + +// Re-export event subscription for convenience +export { onVoiceTranscript } from '../hooks/useVoiceInput.js'; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 6190d163f78..3a3a1936e08 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -240,6 +240,7 @@ describe('useSlashCommandProcessor', () => { toggleBackgroundShell: vi.fn(), toggleShortcutsHelp: vi.fn(), setText: vi.fn(), + toggleVoice: vi.fn(), }, new Map(), // extensionsUpdateState true, // isConfigInitialized diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c3f178ad1bb..55188ad6954 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -86,6 +86,7 @@ interface SlashCommandProcessorActions { toggleBackgroundShell: () => void; toggleShortcutsHelp: () => void; setText: (text: string) => void; + toggleVoice: () => void; } /** @@ -172,6 +173,20 @@ export const useSlashCommandProcessor = ( type: 'help', timestamp: message.timestamp, }; + } else if (message.type === MessageType.VOICE_HELP) { + historyItemContent = { + type: 'voice_help', + timestamp: message.timestamp, + }; + } else if (message.type === MessageType.VOICE_STATUS) { + historyItemContent = { + type: 'voice_status', + timestamp: message.timestamp, + enabled: message.enabled, + provider: message.provider, + sensitivityLabel: message.sensitivityLabel, + whisperPath: message.whisperPath, + }; } else if (message.type === MessageType.STATS) { historyItemContent = { type: 'stats', @@ -243,6 +258,7 @@ export const useSlashCommandProcessor = ( removeComponent: () => setCustomDialog(null), toggleBackgroundShell: actions.toggleBackgroundShell, toggleShortcutsHelp: actions.toggleShortcutsHelp, + toggleVoice: actions.toggleVoice, }, session: { stats: session.stats, diff --git a/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts new file mode 100644 index 00000000000..ee0ef2002db --- /dev/null +++ b/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useVoiceInput } from './useVoiceInput.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import type { VoiceBackendOptions } from '@google/gemini-cli-core'; + +// --------------------------------------------------------------------------- +// Mock @google/gemini-cli-core: provide mock backends so tests run without +// real audio recording infrastructure (sox/arecord, Gemini API). +// --------------------------------------------------------------------------- +let capturedGeminiOptions: VoiceBackendOptions | null = null; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + GeminiRestBackend: vi + .fn() + .mockImplementation((opts: VoiceBackendOptions) => { + capturedGeminiOptions = opts; + return { + start: vi.fn(), + stop: vi.fn(), + cleanup: vi.fn(), + }; + }), + LocalWhisperBackend: vi.fn().mockImplementation(() => ({ + start: vi.fn(), + stop: vi.fn(), + cleanup: vi.fn(), + })), + }; +}); + +const mockConfig = { + getContentGenerator: vi.fn().mockReturnValue({}), +} as unknown as import('@google/gemini-cli-core').Config; + +describe('useVoiceInput Log Volume', () => { + let logSpy: ReturnType; + let warnSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + capturedGeminiOptions = null; + + logSpy = vi.spyOn(debugLogger, 'log'); + warnSpy = vi.spyOn(debugLogger, 'warn'); + errorSpy = vi.spyOn(debugLogger, 'error'); + // We intentionally DO NOT spy on 'debug' — those logs are safe/hidden. + }); + + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('should remain SILENT (no visible logs) during normal recording start/stop', async () => { + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + // --- Action 1: Start Recording --- + await act(async () => { + await result.current.startRecording(); + }); + + // Starting recording should produce ZERO visible logs. + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + + // --- Action 2: Stop Recording --- + await act(async () => { + await result.current.stopRecording(); + }); + + // Stopping should also be silent. + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should remain SILENT even when the backend emits rapid state changes', async () => { + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + await act(async () => { + await result.current.startRecording(); + }); + + // Simulate the backend emitting many intermediate state notifications + // (e.g. progress callbacks). None of these should produce visible logs. + await act(async () => { + for (let i = 0; i < 100; i++) { + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + } + }); + + expect(logSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx b/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx new file mode 100644 index 00000000000..8bb197b7b18 --- /dev/null +++ b/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, useContext, useEffect } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { useVoiceInput, onVoiceTranscript } from './useVoiceInput.js'; +import { VoiceContext } from '../contexts/VoiceContext.js'; +import type { VoiceBackendOptions } from '@google/gemini-cli-core'; +import { coreEvents } from '@google/gemini-cli-core'; + +// --------------------------------------------------------------------------- +// Mock @google/gemini-cli-core: provide mock backends so tests run without +// real audio recording infrastructure (sox/arecord, Gemini API). +// Keep real coreEvents so transcript events flow through properly. +// --------------------------------------------------------------------------- +let capturedGeminiOptions: VoiceBackendOptions | null = null; +const mockGeminiStart = vi.fn(); +const mockGeminiStop = vi.fn(); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + // Extend coreEvents with emitVoiceTranscript if not already present. + const VOICE_TRANSCRIPT_EVENT = 'voice-transcript'; + const extendedCoreEvents = Object.assign(original.coreEvents, { + emitVoiceTranscript: (transcript: string) => { + (original.coreEvents as import('node:events').EventEmitter).emit( + VOICE_TRANSCRIPT_EVENT, + transcript, + ); + }, + }); + const extendedCoreEvent = { + ...original.CoreEvent, + VoiceTranscript: VOICE_TRANSCRIPT_EVENT, + }; + + return { + ...original, + coreEvents: extendedCoreEvents, + CoreEvent: extendedCoreEvent, + GeminiRestBackend: vi + .fn() + .mockImplementation((opts: VoiceBackendOptions) => { + capturedGeminiOptions = opts; + return { + start: mockGeminiStart, + stop: mockGeminiStop, + cleanup: vi.fn(), + }; + }), + LocalWhisperBackend: vi.fn().mockImplementation(() => ({ + start: vi.fn(), + stop: vi.fn(), + cleanup: vi.fn(), + })), + }; +}); + +const mockConfig = { + getContentGenerator: vi.fn().mockReturnValue({}), +} as unknown as import('@google/gemini-cli-core').Config; + +describe('Voice Input Full Cycle Replication', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedGeminiOptions = null; + }); + + it('should deliver transcript via event without causing excessive re-renders', async () => { + let consumerRenders = 0; + let transcriptReceived: string | null = null; + + // Provider: useVoiceInput hook + const { result: providerResult } = renderHook(() => + useVoiceInput({ config: mockConfig }), + ); + + // Consumer: simulates InputPrompt's event-based transcript handling + renderHook( + () => { + consumerRenders++; + useContext(VoiceContext); + + useEffect(() => { + const unsubscribe = onVoiceTranscript((transcript) => { + transcriptReceived = transcript; + }); + return unsubscribe; + }, []); + }, + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + // Reset render counter after initial setup + consumerRenders = 0; + + // Step 1: Start recording + await act(async () => { + await providerResult.current.toggleRecording(); + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + }); + + const rendersBefore = consumerRenders; + + // Step 2: Stop recording — backend emits transcript via coreEvents + await act(async () => { + await providerResult.current.toggleRecording(); + void capturedGeminiOptions?.onStateChange({ + isRecording: false, + isTranscribing: true, + error: null, + }); + // Simulate backend emitting transcript after transcription completes + coreEvents.emitVoiceTranscript('this is a test'); + void capturedGeminiOptions?.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + }); + + // Wait for transcript event to propagate without using fixed timeouts + await waitFor(() => { + expect(transcriptReceived).toBe('this is a test'); + }); + + // With event-based delivery, the consumer renders very few times. + // It should not re-render for every state change in the backend. + const rendersAfter = consumerRenders; + expect(rendersAfter - rendersBefore).toBeLessThan(5); + }); +}); diff --git a/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts new file mode 100644 index 00000000000..ee6120cd42f --- /dev/null +++ b/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useVoiceInput } from './useVoiceInput.js'; +import type { VoiceBackendOptions } from '@google/gemini-cli-core'; + +// --------------------------------------------------------------------------- +// Mock @google/gemini-cli-core: provide mock backends so tests run without +// real audio recording infrastructure (sox/arecord, Gemini API). +// --------------------------------------------------------------------------- +let capturedGeminiOptions: VoiceBackendOptions | null = null; +const mockGeminiStart = vi.fn(); +const mockGeminiStop = vi.fn(); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + GeminiRestBackend: vi + .fn() + .mockImplementation((opts: VoiceBackendOptions) => { + capturedGeminiOptions = opts; + return { + start: mockGeminiStart, + stop: mockGeminiStop, + cleanup: vi.fn(), + }; + }), + LocalWhisperBackend: vi.fn().mockImplementation(() => ({ + start: vi.fn(), + stop: vi.fn(), + cleanup: vi.fn(), + })), + }; +}); + +const mockConfig = { + getContentGenerator: vi.fn().mockReturnValue({}), +} as unknown as import('@google/gemini-cli-core').Config; + +describe('useVoiceInput Stress Tests', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedGeminiOptions = null; + }); + + it('should not cause excessive re-renders when receiving rapid state notifications', async () => { + let renderCount = 0; + const { result } = renderHook(() => { + renderCount++; + return useVoiceInput({ config: mockConfig }); + }); + + // Reset count after initial render + renderCount = 0; + + await act(async () => { + await result.current.startRecording(); + }); + + const baseRenders = renderCount; + + // Simulate 100 rapid state notifications with the same value. + // React batching should coalesce these into at most 1 additional render. + await act(async () => { + for (let i = 0; i < 100; i++) { + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + } + }); + + // 100 identical state updates should not cause 100 re-renders. + expect(renderCount - baseRenders).toBeLessThanOrEqual(2); + }); + + it('should not cause excessive re-renders when rapid toggleRecording calls are ignored', async () => { + let renderCount = 0; + const { result } = renderHook(() => { + renderCount++; + return useVoiceInput({ config: mockConfig }); + }); + + // Start recording + await act(async () => { + await result.current.startRecording(); + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + }); + + const rendersAfterStart = renderCount; + + // Simulate rapid toggleRecording calls while already recording. + // The isTogglingRef guard in the hook should drop concurrent calls. + await act(async () => { + const calls = []; + for (let i = 0; i < 50; i++) { + calls.push(result.current.toggleRecording()); + } + await Promise.all(calls); + }); + + // We expect a small, bounded number of renders — not 50. + expect(renderCount - rendersAfterStart).toBeLessThanOrEqual(5); + }); +}); diff --git a/packages/cli/src/ui/hooks/useVoiceInput.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.test.ts new file mode 100644 index 00000000000..ffb27a151e2 --- /dev/null +++ b/packages/cli/src/ui/hooks/useVoiceInput.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useVoiceInput, onVoiceTranscript } from './useVoiceInput.js'; +import type { VoiceBackendOptions } from '@google/gemini-cli-core'; +import { coreEvents } from '@google/gemini-cli-core'; + +// --------------------------------------------------------------------------- +// Partially mock @google/gemini-cli-core: override backends, keep real +// coreEvents/CoreEvent/debugLogger so transcript events flow through properly. +// --------------------------------------------------------------------------- +const mockGeminiStart = vi.fn(); +const mockGeminiStop = vi.fn(); +const mockGeminiCancel = vi.fn(); +const mockGeminiCleanup = vi.fn(); + +const mockWhisperStart = vi.fn(); +const mockWhisperStop = vi.fn(); +const mockWhisperCancel = vi.fn(); +const mockWhisperCleanup = vi.fn(); + +// Captured options so tests can invoke onStateChange +let capturedGeminiOptions: VoiceBackendOptions | null = null; +let capturedWhisperOptions: VoiceBackendOptions | null = null; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + GeminiRestBackend: vi + .fn() + .mockImplementation((opts: VoiceBackendOptions) => { + capturedGeminiOptions = opts; + return { + start: mockGeminiStart, + stop: mockGeminiStop, + cancel: mockGeminiCancel, + cleanup: mockGeminiCleanup, + }; + }), + LocalWhisperBackend: vi + .fn() + .mockImplementation((opts: VoiceBackendOptions) => { + capturedWhisperOptions = opts; + return { + start: mockWhisperStart, + stop: mockWhisperStop, + cancel: mockWhisperCancel, + cleanup: mockWhisperCleanup, + }; + }), + }; +}); + +// Minimal mock Config with a working ContentGenerator stub +const mockConfig = { + getContentGenerator: vi.fn().mockReturnValue({}), +} as unknown as import('@google/gemini-cli-core').Config; + +describe('useVoiceInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedGeminiOptions = null; + capturedWhisperOptions = null; + }); + + it('initializes with default state', () => { + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + expect(result.current.state).toEqual({ + isRecording: false, + isTranscribing: false, + error: null, + }); + }); + + it('uses GeminiRestBackend by default', () => { + renderHook(() => useVoiceInput({ config: mockConfig })); + expect(capturedGeminiOptions).not.toBeNull(); + expect(capturedWhisperOptions).toBeNull(); + }); + + it('uses LocalWhisperBackend when provider is "whisper"', () => { + renderHook(() => + useVoiceInput({ provider: 'whisper', config: mockConfig }), + ); + expect(capturedWhisperOptions).not.toBeNull(); + expect(capturedGeminiOptions).toBeNull(); + }); + + it('delegates startRecording to the active backend', async () => { + mockGeminiStart.mockResolvedValue(undefined); + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + await act(async () => { + await result.current.startRecording(); + }); + expect(mockGeminiStart).toHaveBeenCalledOnce(); + }); + + it('delegates stopRecording to the active backend', async () => { + mockGeminiStop.mockResolvedValue(undefined); + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + await act(async () => { + await result.current.stopRecording(); + }); + expect(mockGeminiStop).toHaveBeenCalledOnce(); + }); + + it('reflects state changes from the backend', async () => { + mockGeminiStart.mockImplementation(async () => { + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + }); + + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + await act(async () => { + await result.current.startRecording(); + }); + + expect(result.current.state.isRecording).toBe(true); + }); + + it('delivers transcript via coreEvents, not state', async () => { + mockGeminiStop.mockImplementation(async () => { + // Backends emit via coreEvents rather than a callback + coreEvents.emitVoiceTranscript('Hello world'); + void capturedGeminiOptions?.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + }); + + const transcripts: string[] = []; + const unsubscribe = onVoiceTranscript((t) => transcripts.push(t)); + + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + await act(async () => { + await result.current.stopRecording(); + }); + + expect(transcripts).toContain('Hello world'); + expect(result.current.state).toEqual({ + isRecording: false, + isTranscribing: false, + error: null, + }); + + unsubscribe(); + }); + + it('surfaces errors from the backend in state', async () => { + mockGeminiStart.mockImplementation(async () => { + void capturedGeminiOptions?.onStateChange({ + isRecording: false, + isTranscribing: false, + error: 'Neither sox nor arecord found', + }); + }); + + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + await act(async () => { + await result.current.startRecording(); + }); + + expect(result.current.state.error).toContain( + 'Neither sox nor arecord found', + ); + expect(result.current.state.isRecording).toBe(false); + }); + + it('delegates cancelRecording to the active backend', async () => { + mockGeminiCancel.mockResolvedValue(undefined); + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + await act(async () => { + await result.current.cancelRecording(); + }); + expect(mockGeminiCancel).toHaveBeenCalledOnce(); + }); + + it('cancelRecording is a no-op when no backend is initialized', async () => { + const { result } = renderHook(() => useVoiceInput()); + await act(async () => { + await result.current.cancelRecording(); + }); + expect(mockGeminiCancel).not.toHaveBeenCalled(); + expect(mockWhisperCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useVoiceInput.ts b/packages/cli/src/ui/hooks/useVoiceInput.ts new file mode 100644 index 00000000000..d99d64f02bd --- /dev/null +++ b/packages/cli/src/ui/hooks/useVoiceInput.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + debugLogger, + coreEvents, + CoreEvent, + GeminiRestBackend, + LocalWhisperBackend, +} from '@google/gemini-cli-core'; +import type { + VoiceBackend, + VoiceInputState, + Config, +} from '@google/gemini-cli-core'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; + +export type { + VoiceInputState, + VoiceBackend, + VoiceInputReturn, +} from '@google/gemini-cli-core'; + +/** + * Subscribe to voice transcript events emitted by the active backend. + * Uses the coreEvents bus (CoreEvent.VoiceTranscript) to avoid React + * re-render cascades. + */ +export function onVoiceTranscript( + callback: (transcript: string) => void, +): () => void { + coreEvents.on(CoreEvent.VoiceTranscript, callback); + return () => { + coreEvents.off(CoreEvent.VoiceTranscript, callback); + }; +} + +export interface VoiceInputConfig { + /** + * Which transcription backend to use. + * - 'gemini' (default): zero-install, uses the CLI's existing Gemini API auth. + * - 'whisper': local Whisper binary (faster-whisper or openai-whisper). + */ + provider?: 'gemini' | 'whisper'; + /** Path to a custom Whisper binary. Only used when provider is 'whisper'. */ + whisperPath?: string; + /** The CLI Config instance, used by the Gemini backend for auth. */ + config: Config; + /** + * RMS energy threshold for silence detection (0–1000). Audio below this + * level is discarded without an API call. 0 disables silence detection. + * Default: 80 (allows whispered speech in quiet environments). + */ + silenceThreshold?: number; +} + +/** + * Hook for voice input using system audio recording and a pluggable + * transcription backend (from packages/core). + * + * Backends: + * - GeminiRestBackend (default): records raw PCM in-memory, builds a WAV + * buffer, and transcribes via the Gemini API using the CLI's existing + * ContentGenerator. Works with both API key and OAuth auth. + * - LocalWhisperBackend: records a WAV file and transcribes with a locally + * installed Whisper binary. Used when provider is set to 'whisper'. + * + * Transcripts are delivered via CoreEvent.VoiceTranscript on coreEvents. + */ +export function useVoiceInput(voiceConfig?: VoiceInputConfig) { + const [state, setState] = useState({ + isRecording: false, + isTranscribing: false, + error: null, + }); + + const backendRef = useRef(null); + const isTogglingRef = useRef(false); + + // Initialize (or re-initialize) the backend when config changes + useEffect(() => { + const options = { + onStateChange: async (newState: VoiceInputState) => { + setState(newState); + if (newState.error) { + coreEvents.emitFeedback('error', newState.error); + } + // Yield one macrotask after signalling isTranscribing:true so Ink + // can flush the state update and render ⏳ before the network call. + if (newState.isTranscribing) { + await new Promise((resolve) => setImmediate(resolve)); + } + }, + silenceThreshold: voiceConfig?.silenceThreshold, + }; + + if (voiceConfig?.provider === 'whisper') { + backendRef.current = new LocalWhisperBackend(options, { + whisperPath: voiceConfig.whisperPath, + }); + } else if (voiceConfig?.config) { + backendRef.current = new GeminiRestBackend(options, voiceConfig.config); + } + + return () => { + void backendRef.current?.cleanup(); + backendRef.current = null; + }; + }, [ + voiceConfig?.provider, + voiceConfig?.whisperPath, + voiceConfig?.config, + voiceConfig?.silenceThreshold, + ]); + + const startRecording = useCallback(async () => { + if (!backendRef.current) { + debugLogger.debug( + 'useVoiceInput: startRecording — no backend (voice disabled?)', + ); + return; + } + debugLogger.debug('useVoiceInput: startRecording'); + await backendRef.current.start(); + }, []); + + const stopRecording = useCallback(async () => { + if (!backendRef.current) return; + debugLogger.debug('useVoiceInput: stopRecording'); + await backendRef.current.stop(); + }, []); + + const cancelRecording = useCallback(async () => { + if (!backendRef.current) return; + await backendRef.current.cancel(); + }, []); + + const toggleRecording = useCallback(async () => { + if (isTogglingRef.current) return; + isTogglingRef.current = true; + try { + if (state.isRecording) { + await stopRecording(); + } else { + await startRecording(); + } + } catch (e) { + debugLogger.error('useVoiceInput: toggle error', e); + } finally { + isTogglingRef.current = false; + } + }, [state.isRecording, startRecording, stopRecording]); + + const isEnabled = !!( + voiceConfig?.config || voiceConfig?.provider === 'whisper' + ); + + return useMemo( + () => ({ + isEnabled, + state, + startRecording, + stopRecording, + cancelRecording, + toggleRecording, + }), + [ + isEnabled, + state, + startRecording, + stopRecording, + cancelRecording, + toggleRecording, + ], + ); +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 763754ec951..41035905ddd 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -288,7 +288,11 @@ describe('keyMatchers', () => { }, { command: Command.PASTE_CLIPBOARD, - positive: [createKey('v', { ctrl: true })], + positive: [ + createKey('v', { ctrl: true }), + createKey('v', { cmd: true }), + createKey('v', { alt: true }), + ], negative: [createKey('v'), createKey('c', { ctrl: true })], }, @@ -352,6 +356,11 @@ describe('keyMatchers', () => { createKey('l', { ctrl: true }), ], }, + { + command: Command.VOICE_INPUT, + positive: [createKey('r', { alt: true })], + negative: [createKey('v'), createKey('v', { ctrl: true })], + }, // Shell commands { command: Command.REVERSE_SEARCH, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 00efd3f7fcd..f18fee2d17f 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -43,5 +43,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { removeComponent: () => {}, toggleBackgroundShell: () => {}, toggleShortcutsHelp: () => {}, + toggleVoice: () => {}, }; } diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index c9910179a5d..ad23e95f4df 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -185,6 +185,19 @@ export type HistoryItemHelp = HistoryItemBase & { timestamp: Date; }; +export type HistoryItemVoiceHelp = HistoryItemBase & { + type: 'voice_help'; + timestamp: Date; +}; + +export type HistoryItemVoiceStatus = HistoryItemBase & { + type: 'voice_status'; + timestamp: Date; + enabled: boolean; + provider: string; + sensitivityLabel: string; + whisperPath: string; +}; export interface HistoryItemQuotaBase extends HistoryItemBase { selectedAuthType?: string; userEmail?: string; @@ -364,6 +377,8 @@ export type HistoryItemWithoutId = | HistoryItemWarning | HistoryItemAbout | HistoryItemHelp + | HistoryItemVoiceHelp + | HistoryItemVoiceStatus | HistoryItemToolGroup | HistoryItemStats | HistoryItemModelStats @@ -403,6 +418,8 @@ export enum MessageType { MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', HINT = 'hint', + VOICE_HELP = 'voice_help', + VOICE_STATUS = 'voice_status', } // Simplified message structure for internal feedback @@ -430,6 +447,19 @@ export type Message = timestamp: Date; content?: string; // Optional content, not really used for HELP } + | { + type: MessageType.VOICE_HELP; + timestamp: Date; + content?: string; + } + | { + type: MessageType.VOICE_STATUS; + timestamp: Date; + enabled: boolean; + provider: string; + sensitivityLabel: string; + whisperPath: string; + } | { type: MessageType.STATS; timestamp: Date; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c6353256e81..e83b8705cfa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -212,3 +212,7 @@ export * from './utils/terminal.js'; // Export types from @google/genai export type { Content, Part, FunctionCall } from '@google/genai'; +// Voice services +export * from './services/voice/types.js'; +export { GeminiRestBackend } from './services/voice/GeminiRestBackend.js'; +export { LocalWhisperBackend } from './services/voice/LocalWhisperBackend.js'; diff --git a/packages/core/src/services/voice/GeminiRestBackend.ts b/packages/core/src/services/voice/GeminiRestBackend.ts new file mode 100644 index 00000000000..f202ab09fbb --- /dev/null +++ b/packages/core/src/services/voice/GeminiRestBackend.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Config } from '../../config/config.js'; +import { coreEvents } from '../../utils/events.js'; +import { resolveExecutable } from '../../utils/shell-utils.js'; +import { LlmRole } from '../../telemetry/llmRole.js'; +import { spawn } from 'node:child_process'; +import type { VoiceBackend, VoiceBackendOptions } from './types.js'; +import type { GenerateContentParameters } from '@google/genai'; + +const SAMPLE_RATE = 16000; +const CHANNELS = 1; + +/** + * Voice backend that uses the Gemini API for transcription. + * + * Records raw PCM audio in-memory via sox or arecord, then builds a WAV + * buffer and sends it to the Gemini API using the CLI's existing + * ContentGenerator (works with both API key and OAuth auth). + * + * Note: The recording process uses `spawn` directly rather than `spawnAsync` + * because audio capture requires streaming binary stdout chunk-by-chunk in + * real-time. `spawnAsync` buffers all output until the process exits, which + * is incompatible with the push-to-talk recording pattern. + * + * Transcripts are delivered via `coreEvents.emitVoiceTranscript()`. + */ +export class GeminiRestBackend implements VoiceBackend { + private recordingProcess: ReturnType | null = null; + private audioChunks: Buffer[] = []; + private isStopping = false; + + constructor( + private readonly options: VoiceBackendOptions, + private readonly config: Config, + ) {} + + async start(): Promise { + if (this.recordingProcess) return; + + try { + this.isStopping = false; + this.audioChunks = []; + void this.options.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + + const soxPath = await resolveExecutable('sox'); + if (soxPath) { + this.recordingProcess = spawn(soxPath, [ + '-d', + '-b', + '16', + '-r', + SAMPLE_RATE.toString(), + '-c', + CHANNELS.toString(), + '-e', + 'signed-integer', + '-t', + 'raw', + '-', + ]); + } else { + const arecordPath = await resolveExecutable('arecord'); + if (arecordPath) { + this.recordingProcess = spawn(arecordPath, [ + '-f', + 'S16_LE', + '-r', + SAMPLE_RATE.toString(), + '-c', + CHANNELS.toString(), + '-t', + 'raw', + '-D', + 'default', + ]); + } else { + throw new Error( + 'Neither sox nor arecord found.\n' + + ' macOS: brew install sox\n' + + ' Linux: sudo apt install sox (or: sudo apt install alsa-utils)', + ); + } + } + + this.recordingProcess.stdout?.on('data', (chunk: Buffer) => { + if (!this.isStopping) { + this.audioChunks.push(chunk); + } + }); + + this.recordingProcess.on('error', (err) => { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: `Recording error: ${err.message}`, + }); + }); + } catch (err) { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + async cancel(): Promise { + if (!this.recordingProcess) return; + this.isStopping = true; + const proc = this.recordingProcess; + this.recordingProcess = null; + proc.kill('SIGINT'); + this.audioChunks = []; + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + } + + async stop(): Promise { + if (!this.recordingProcess) return; + + this.isStopping = true; + const proc = this.recordingProcess; + this.recordingProcess = null; + proc.kill('SIGINT'); + + try { + const audioBuffer = Buffer.concat(this.audioChunks); + if (audioBuffer.length === 0) throw new Error('No audio captured'); + + // Reject silent recordings before showing the transcribing state, so + // the ⏳ indicator only appears when an actual API call will be made. + // RMS of 16-bit LE PCM: background noise <200, audible speech ~500+. + if (this.isSilentPcm(audioBuffer)) { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + return; + } + + // Signal transcription only now — the ⏳ will be visible for the + // full duration of the Gemini API call. Awaiting allows the UI layer + // to flush the state change before the network call begins. + await this.options.onStateChange({ + isRecording: false, + isTranscribing: true, + error: null, + }); + + const wavBuffer = this.createWavBuffer(audioBuffer, SAMPLE_RATE); + const transcript = await this.transcribe(wavBuffer); + coreEvents.emitVoiceTranscript(transcript); + + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + } catch (err) { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + await this.cleanup(); + } + } + + /** + * Builds a WAV buffer from raw 16-bit LE PCM data. + * Gemini's audio/wav MIME type requires a valid RIFF header. + */ + private createWavBuffer(pcmBuffer: Buffer, sampleRate: number): Buffer { + const header = Buffer.alloc(44); + const dataSize = pcmBuffer.length; + + header.write('RIFF', 0); + header.writeUInt32LE(dataSize + 36, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); // format chunk size + header.writeUInt16LE(1, 20); // PCM format + header.writeUInt16LE(CHANNELS, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * CHANNELS * 2, 28); // byte rate + header.writeUInt16LE(CHANNELS * 2, 32); // block align + header.writeUInt16LE(16, 34); // bits per sample + header.write('data', 36); + header.writeUInt32LE(dataSize, 40); + + return Buffer.concat([header, pcmBuffer]); + } + + private async transcribe(audioBuffer: Buffer): Promise { + const generator = this.config.getContentGenerator(); + if (!generator) throw new Error('Gemini API not initialized'); + + const request: GenerateContentParameters = { + model: 'gemini-2.0-flash', + contents: [ + { + role: 'user', + parts: [ + { + text: 'Transcribe the following audio exactly as spoken. Output only the transcribed text with no additional commentary or formatting.', + }, + { + inlineData: { + mimeType: 'audio/wav', + data: audioBuffer.toString('base64'), + }, + }, + ], + }, + ], + }; + + const response = await generator.generateContent( + request, + 'voice-transcription', + LlmRole.UTILITY_TOOL, + ); + + const parts = response.candidates?.[0]?.content?.parts; + return parts?.[0]?.text?.trim() ?? ''; + } + + /** + * Returns true if the raw 16-bit LE PCM buffer is effectively silent. + * Computes RMS amplitude and compares against options.silenceThreshold + * (default 80). A threshold of 0 disables silence detection entirely. + * + * RMS guide (16-bit PCM, 16 kHz mono): + * ~0-30 near-digital silence + * ~30-100 quiet room ambient / electrical noise + * ~100-400 whispered speech + * ~500+ normal conversational speech + */ + private isSilentPcm(pcmBuffer: Buffer): boolean { + const threshold = this.options.silenceThreshold ?? 80; + if (threshold === 0) return false; // disabled + const samples = pcmBuffer.length / 2; // 16-bit = 2 bytes per sample + if (samples === 0) return true; + let sumSquares = 0; + for (let i = 0; i < pcmBuffer.length - 1; i += 2) { + const sample = pcmBuffer.readInt16LE(i); + sumSquares += sample * sample; + } + const rms = Math.sqrt(sumSquares / samples); + return rms < threshold; + } + + async cleanup(): Promise { + if (this.recordingProcess) { + this.recordingProcess.kill('SIGINT'); + this.recordingProcess = null; + } + this.audioChunks = []; + } +} diff --git a/packages/core/src/services/voice/LocalWhisperBackend.ts b/packages/core/src/services/voice/LocalWhisperBackend.ts new file mode 100644 index 00000000000..b86fd43de33 --- /dev/null +++ b/packages/core/src/services/voice/LocalWhisperBackend.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '../../utils/debugLogger.js'; +import { tmpdir } from '../../utils/paths.js'; +import { resolveExecutable, spawnAsync } from '../../utils/shell-utils.js'; +import { coreEvents } from '../../utils/events.js'; +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { VoiceBackend, VoiceBackendOptions } from './types.js'; + +const RECORDING_FORMAT = 'wav'; +const SAMPLE_RATE = 16000; +const CHANNELS = 1; + +/** + * Voice backend that records a WAV file via sox/arecord and transcribes + * it using a locally-installed Whisper binary (faster-whisper or openai-whisper). + * + * Note: The recording process uses `spawn` directly rather than `spawnAsync` + * because audio capture requires real-time process control (SIGINT to stop). + * `spawnAsync` is used for the Whisper transcription step, which is a + * standard command-and-result invocation. + * + * Transcripts are delivered via `coreEvents.emitVoiceTranscript()`. + */ +export class LocalWhisperBackend implements VoiceBackend { + private recordingProcess: ReturnType | null = null; + private tempDir: string | null = null; + private audioFile: string | null = null; + + constructor( + private readonly options: VoiceBackendOptions, + private readonly config: { whisperPath?: string } = {}, + ) {} + + async start(): Promise { + if (this.recordingProcess) { + debugLogger.log('LocalWhisperBackend: already recording'); + return; + } + + try { + void this.options.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + + this.tempDir = await mkdtemp(join(tmpdir(), 'gemini-voice-')); + this.audioFile = join(this.tempDir, `recording.${RECORDING_FORMAT}`); + + const soxPath = await resolveExecutable('sox'); + if (soxPath) { + this.recordingProcess = spawn(soxPath, [ + '-d', + '-b', + '16', + '-r', + SAMPLE_RATE.toString(), + '-c', + CHANNELS.toString(), + '-e', + 'signed-integer', + '-t', + RECORDING_FORMAT, + this.audioFile, + ]); + } else { + const arecordPath = await resolveExecutable('arecord'); + if (arecordPath) { + this.recordingProcess = spawn(arecordPath, [ + '-f', + 'S16_LE', + '-r', + SAMPLE_RATE.toString(), + '-c', + CHANNELS.toString(), + '-D', + 'default', + this.audioFile, + ]); + } else { + throw new Error( + 'Neither sox nor arecord found. Please install one of them.', + ); + } + } + + this.recordingProcess.on('error', (err) => { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: `Recording error: ${err.message}`, + }); + }); + } catch (err) { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + async cancel(): Promise { + if (!this.recordingProcess) return; + const proc = this.recordingProcess; + this.recordingProcess = null; + proc.kill('SIGINT'); + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + await this.cleanup(); + } + + async stop(): Promise { + if (!this.recordingProcess) return; + + const proc = this.recordingProcess; + this.recordingProcess = null; + proc.kill('SIGINT'); + + // Wait for the recording process to fully close (stdio streams flushed) + // before reading the audio file. + await new Promise((resolve) => { + proc.on('close', resolve); + }); + + await this.options.onStateChange({ + isRecording: false, + isTranscribing: true, + error: null, + }); + + try { + if (!this.audioFile) throw new Error('No audio file'); + + const stats = await stat(this.audioFile).catch(() => null); + if (!stats || stats.size === 0) + throw new Error('No audio recorded (file is empty)'); + + const transcript = await this.transcribe(this.audioFile); + coreEvents.emitVoiceTranscript(transcript); + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + } catch (err) { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + await this.cleanup(); + } + } + + private async transcribe(audioFile: string): Promise { + // Reject paths with shell metacharacters that could enable injection + const validatePath = (p: string): string => { + const sanitized = p.replace(/['"]/g, ''); + if (/[;&|`$(){}[\]<>!]/.test(sanitized)) + throw new Error('Invalid binary path: contains shell metacharacters'); + return sanitized; + }; + + const args = [ + audioFile, + '--model', + 'tiny', + '--output_format', + 'txt', + '--output_dir', + this.tempDir!, + ]; + + if (this.config.whisperPath) { + await spawnAsync(validatePath(this.config.whisperPath), args); + } else { + try { + await spawnAsync('whisper-faster', args); + } catch { + try { + await spawnAsync('whisper', args); + } catch { + throw new Error( + 'Whisper not found. Please install faster-whisper or openai-whisper, ' + + 'or configure the path in settings.', + ); + } + } + } + + const transcriptFile = audioFile.replace('.wav', '.txt'); + const raw = await readFile(transcriptFile, 'utf-8'); + return raw + .split('\n') + .map((l) => + l + .replace(/^\[\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}\.\d{3}\]\s*/, '') + .trim(), + ) + .filter(Boolean) + .join(' '); + } + + async cleanup(): Promise { + if (this.tempDir) { + await rm(this.tempDir, { recursive: true, force: true }).catch(() => {}); + this.tempDir = null; + this.audioFile = null; + } + } +} diff --git a/packages/core/src/services/voice/types.ts b/packages/core/src/services/voice/types.ts new file mode 100644 index 00000000000..b881d58b03a --- /dev/null +++ b/packages/core/src/services/voice/types.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface VoiceInputState { + isRecording: boolean; + isTranscribing: boolean; + error: string | null; +} + +export interface VoiceBackend { + start(): Promise; + stop(): Promise; + /** Cancel recording without transcribing (e.g., user pressed Escape). */ + cancel(): Promise; + cleanup(): Promise; +} + +/** + * Options passed to a VoiceBackend at construction time. + * Transcripts are delivered via coreEvents (CoreEvent.VoiceTranscript) + * rather than a callback, to align with the project's cross-service + * communication pattern. + */ +export interface VoiceBackendOptions { + onStateChange: (state: VoiceInputState) => void | Promise; + /** + * RMS energy threshold below which audio is treated as silence and + * discarded without an API call. 0 disables silence detection entirely. + * Default: 80 (allows whispered speech; blocks near-silence). + */ + silenceThreshold?: number; +} + +export interface VoiceInputReturn { + isEnabled: boolean; + state: VoiceInputState; + startRecording: () => Promise; + stopRecording: () => Promise; + cancelRecording: () => Promise; + toggleRecording: () => Promise; +} diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 159dde2a6df..41b45029a4f 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -182,21 +182,21 @@ export enum CoreEvent { ConsentRequest = 'consent-request', McpProgress = 'mcp-progress', AgentsDiscovered = 'agents-discovered', - RequestEditorSelection = 'request-editor-selection', - EditorSelected = 'editor-selected', - SlashCommandConflicts = 'slash-command-conflicts', - QuotaChanged = 'quota-changed', - TelemetryKeychainAvailability = 'telemetry-keychain-availability', - TelemetryTokenStorageType = 'telemetry-token-storage-type', +RequestEditorSelection = 'request-editor-selection', +EditorSelected = 'editor-selected', +SlashCommandConflicts = 'slash-command-conflicts', +QuotaChanged = 'quota-changed', +TelemetryKeychainAvailability = 'telemetry-keychain-availability', +TelemetryTokenStorageType = 'telemetry-token-storage-type', +VoiceTranscript = 'voice-transcript', } /** - * Payload for the 'editor-selected' event. - */ +* Payload for the 'editor-selected' event. +*/ export interface EditorSelectedPayload { - editor?: EditorType; +editor?: EditorType; } - export interface CoreEvents extends ExtensionEvents { [CoreEvent.UserFeedback]: [UserFeedbackPayload]; [CoreEvent.ModelChanged]: [ModelChangedPayload]; @@ -221,6 +221,7 @@ export interface CoreEvents extends ExtensionEvents { [CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload]; [CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent]; [CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent]; + [CoreEvent.VoiceTranscript]: [string]; } type EventBacklogItem = { @@ -409,6 +410,10 @@ export class CoreEventEmitter extends EventEmitter { emitTelemetryTokenStorageType(event: TokenStorageInitializationEvent): void { this._emitOrQueue(CoreEvent.TelemetryTokenStorageType, event); } + + emitVoiceTranscript(transcript: string): void { + this.emit(CoreEvent.VoiceTranscript, transcript); + } } export const coreEvents = new CoreEventEmitter(); diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index a0ef69eab55..ae7a4beb4dc 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -475,6 +475,42 @@ }, "additionalProperties": false }, + "voice": { + "title": "Voice Input", + "description": "Settings for voice input.", + "markdownDescription": "Settings for voice input.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "Enable Voice Input", + "description": "Enable voice input support.", + "markdownDescription": "Enable voice input support.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "provider": { + "title": "Transcription Backend", + "description": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).", + "markdownDescription": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).\n\n- Category: `General`\n- Requires restart: `no`", + "type": "string" + }, + "whisperPath": { + "title": "Whisper Binary Path", + "description": "Path to the whisper executable. Only used when provider is \"whisper\".", + "markdownDescription": "Path to the whisper executable. Only used when provider is \"whisper\".\n\n- Category: `General`\n- Requires restart: `no`", + "type": "string" + }, + "silenceThreshold": { + "title": "Silence Detection Threshold", + "description": "RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values allow quieter speech such as whispering. 0 disables silence detection.", + "markdownDescription": "RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values allow quieter speech such as whispering. 0 disables silence detection.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `80`", + "default": 80, + "type": "number" + } + }, + "additionalProperties": false + }, "ide": { "title": "IDE", "description": "IDE integration settings.", From 82a93d658f31ba0df2981ba46cf358f84ecb4d96 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 2 Mar 2026 20:01:36 -0500 Subject: [PATCH 02/37] fix(voice): replace Alt+R/Ctrl+Q with double-space trigger; revert package-lock.json - Remove Alt+R and Ctrl+Q keyboard shortcuts per reviewer feedback - Implement double-space-on-empty-input gesture to start recording - Update VoiceHelp.tsx, VoiceStatus.tsx, and keyboard-shortcuts.md - Revert package-lock.json to upstream state (cosmetic npm normalization) - Fix missing void on cancelRecording() and missing dep voiceState.isTranscribing --- docs/reference/keyboard-shortcuts.md | 6 ++--- package-lock.json | 25 +------------------ packages/cli/src/config/keyBindings.ts | 10 +++----- .../cli/src/ui/components/InputPrompt.tsx | 19 +++++++++++--- packages/cli/src/ui/components/VoiceHelp.tsx | 8 ++---- .../cli/src/ui/components/VoiceStatus.tsx | 8 ++---- 6 files changed, 27 insertions(+), 49 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 2e714d22998..a1409f3936c 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -96,9 +96,9 @@ available combinations. #### Voice Input -| Action | Keys | -| ----------------------------------------------- | ------------------------- | -| Toggle voice input recording (Alt+R or Ctrl+Q). | `Alt + R`
`Ctrl + Q` | +| Action | Keys | +| ---------------------------------------------------- | ------------- | +| Start voice recording (double-space on empty input). | `Space Space` | #### App Controls diff --git a/package-lock.json b/package-lock.json index 74ecb5379c8..85448711c7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2292,7 +2292,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2473,7 +2472,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2523,7 +2521,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2898,7 +2895,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2932,7 +2928,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2987,7 +2982,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4184,7 +4178,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4458,7 +4451,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5306,7 +5298,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7910,7 +7901,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8543,7 +8533,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10137,7 +10126,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13820,7 +13808,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13831,7 +13818,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15920,7 +15906,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16144,8 +16129,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16153,7 +16137,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16313,7 +16296,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16537,7 +16519,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16651,7 +16632,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16664,7 +16644,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17309,7 +17288,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17709,7 +17687,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index db07a91b585..6170c76b754 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -301,11 +301,8 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], - // Voice Input - [Command.VOICE_INPUT]: [ - { key: 'r', alt: true }, // Alt+R - { key: 'q', ctrl: true }, // Ctrl+Q - ], + // Voice Input — triggered by double-space on empty input (see InputPrompt.tsx) + [Command.VOICE_INPUT]: [], }; interface CommandCategory { @@ -540,5 +537,6 @@ export const commandDescriptions: Readonly> = { [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', // Voice Input - [Command.VOICE_INPUT]: 'Toggle voice input recording (Alt+R or Ctrl+Q).', + [Command.VOICE_INPUT]: + 'Start voice input recording (double-space on empty input).', }; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 9a55cc7720d..91721598430 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -260,6 +260,8 @@ export const InputPrompt: React.FC = ({ const pasteTimeoutRef = useRef(null); const innerBoxRef = useRef(null); const hasUserNavigatedSuggestions = useRef(false); + // Double-space voice trigger: track last space press time (only fires on empty input) + const lastSpacePressRef = useRef(0); const [reverseSearchActive, setReverseSearchActive] = useState(false); const [commandSearchActive, setCommandSearchActive] = useState(false); @@ -687,7 +689,7 @@ export const InputPrompt: React.FC = ({ // Cancel active voice transcription if escape is hit if (key.name === 'escape') { if (voiceState.isRecording || voiceState.isTranscribing) { - cancelRecording(); + void cancelRecording(); resetEscapeState(); return true; } @@ -898,9 +900,17 @@ export const InputPrompt: React.FC = ({ return true; } - if (voiceEnabled && keyMatchers[Command.VOICE_INPUT](key)) { - void toggleRecording(); - return true; + // Double-space on empty input triggers voice recording. + // Both taps are consumed so the buffer stays empty between them. + if (voiceEnabled && key.sequence === ' ' && buffer.text.length === 0) { + const now = Date.now(); + if (now - lastSpacePressRef.current < 500) { + lastSpacePressRef.current = 0; + void toggleRecording(); + } else { + lastSpacePressRef.current = now; + } + return true; // consume the space in both cases } if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { @@ -1319,6 +1329,7 @@ export const InputPrompt: React.FC = ({ toggleRecording, cancelRecording, voiceState.isRecording, + voiceState.isTranscribing, ], ); diff --git a/packages/cli/src/ui/components/VoiceHelp.tsx b/packages/cli/src/ui/components/VoiceHelp.tsx index 114f442e351..703db494a95 100644 --- a/packages/cli/src/ui/components/VoiceHelp.tsx +++ b/packages/cli/src/ui/components/VoiceHelp.tsx @@ -50,13 +50,9 @@ export const VoiceHelp: React.FC = () => (
- Alt+R + Space Space - {' or '} - - Ctrl+Q - - {' - Start / stop recording'} + {' (on empty input) - Start recording'} diff --git a/packages/cli/src/ui/components/VoiceStatus.tsx b/packages/cli/src/ui/components/VoiceStatus.tsx index 18a6609acfa..5a60d074241 100644 --- a/packages/cli/src/ui/components/VoiceStatus.tsx +++ b/packages/cli/src/ui/components/VoiceStatus.tsx @@ -47,13 +47,9 @@ export const VoiceStatus: React.FC = ({ item }) => ( Shortcuts:{' '} - Alt+R + Space Space - {' or '} - - Ctrl+Q - - {' to record · '} + {' (empty input) to record · '} /voice help From deb7f73acddd06364877d485eb338de3d07e9ddf Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 2 Mar 2026 22:07:25 -0500 Subject: [PATCH 03/37] fix(voice): enable voice appending and add Ctrl+Space shortcut --- docs/reference/keyboard-shortcuts.md | 6 ++-- packages/cli/src/config/keyBindings.ts | 7 +++-- .../cli/src/ui/components/InputPrompt.tsx | 29 ++++++++++++++----- packages/cli/src/ui/components/VoiceHelp.tsx | 9 ++++-- .../cli/src/ui/components/VoiceStatus.tsx | 9 ++++-- .../cli/src/ui/contexts/KeypressContext.tsx | 7 +++-- 6 files changed, 46 insertions(+), 21 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index a1409f3936c..9bed373d3de 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -96,9 +96,9 @@ available combinations. #### Voice Input -| Action | Keys | -| ---------------------------------------------------- | ------------- | -| Start voice recording (double-space on empty input). | `Space Space` | +| Action | Keys | +| --------------------------------------------------------- | -------------- | +| Toggle voice input recording (press space twice rapidly). | `Ctrl + space` | #### App Controls diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 6170c76b754..ca7a130efa8 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -301,8 +301,9 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], - // Voice Input — triggered by double-space on empty input (see InputPrompt.tsx) - [Command.VOICE_INPUT]: [], + // Voice Input — triggered by double-space anywhere (see InputPrompt.tsx) + // or Ctrl+Space as a fallback combo. + [Command.VOICE_INPUT]: [{ key: 'space', ctrl: true }], }; interface CommandCategory { @@ -538,5 +539,5 @@ export const commandDescriptions: Readonly> = { // Voice Input [Command.VOICE_INPUT]: - 'Start voice input recording (double-space on empty input).', + 'Toggle voice input recording (press space twice rapidly).', }; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 91721598430..7462fcfbf27 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -900,17 +900,32 @@ export const InputPrompt: React.FC = ({ return true; } - // Double-space on empty input triggers voice recording. - // Both taps are consumed so the buffer stays empty between them. - if (voiceEnabled && key.sequence === ' ' && buffer.text.length === 0) { + // Reset double-space timer on any non-space key + if (key.sequence !== ' ') { + lastSpacePressRef.current = 0; + } + + // Double-space triggers voice recording. + if (voiceEnabled && key.sequence === ' ') { const now = Date.now(); - if (now - lastSpacePressRef.current < 500) { + const delta = now - lastSpacePressRef.current; + if (delta > 0 && delta < 300) { lastSpacePressRef.current = 0; void toggleRecording(); - } else { - lastSpacePressRef.current = now; + return true; // Consume the second space } - return true; // consume the space in both cases + lastSpacePressRef.current = now; + + // Consume the first space only if the buffer is empty to avoid leading whitespace. + // If not empty, let it through so normal typing isn't delayed. + if (buffer.text.length === 0) { + return true; + } + } + + if (voiceEnabled && keyMatchers[Command.VOICE_INPUT](key)) { + void toggleRecording(); + return true; } if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { diff --git a/packages/cli/src/ui/components/VoiceHelp.tsx b/packages/cli/src/ui/components/VoiceHelp.tsx index 703db494a95..9f603fcb642 100644 --- a/packages/cli/src/ui/components/VoiceHelp.tsx +++ b/packages/cli/src/ui/components/VoiceHelp.tsx @@ -52,9 +52,12 @@ export const VoiceHelp: React.FC = () => ( Space Space - {' (on empty input) - Start recording'} - - + {' or '} + + Ctrl+Space + + {' - Start/stop recording'} + Esc diff --git a/packages/cli/src/ui/components/VoiceStatus.tsx b/packages/cli/src/ui/components/VoiceStatus.tsx index 5a60d074241..e87206ef497 100644 --- a/packages/cli/src/ui/components/VoiceStatus.tsx +++ b/packages/cli/src/ui/components/VoiceStatus.tsx @@ -49,11 +49,14 @@ export const VoiceStatus: React.FC = ({ item }) => ( Space Space - {' (empty input) to record · '} + {' or '} + + Ctrl+Space + + {' to record · '} /voice help {' for all commands'} - -
+
); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 7d1881644d4..af9b15603d1 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -605,8 +605,11 @@ function* emitKeys( name = 'space'; alt = escaped; insertable = true; - } else if (!escaped && ch <= '\x1a') { - // ctrl+letter + } else if (!escaped && ch === '\x00') { + // ctrl+space sends \x00 in many terminals + name = 'space'; + ctrl = true; + } else if (!escaped && ch <= '\x1a') { // ctrl+letter name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1); ctrl = true; } else if (/^[0-9A-Za-z]$/.exec(ch) !== null) { From 97b657c71ed446dd0811535c31f7801339e6b2ae Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 2 Mar 2026 22:10:38 -0500 Subject: [PATCH 04/37] fix(voice): resolve Ink whitespace rendering crash --- packages/cli/src/ui/components/VoiceHelp.tsx | 3 ++- packages/cli/src/ui/components/VoiceStatus.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/VoiceHelp.tsx b/packages/cli/src/ui/components/VoiceHelp.tsx index 9f603fcb642..16d45c89277 100644 --- a/packages/cli/src/ui/components/VoiceHelp.tsx +++ b/packages/cli/src/ui/components/VoiceHelp.tsx @@ -57,7 +57,8 @@ export const VoiceHelp: React.FC = () => ( Ctrl+Space {' - Start/stop recording'} - + + Esc diff --git a/packages/cli/src/ui/components/VoiceStatus.tsx b/packages/cli/src/ui/components/VoiceStatus.tsx index e87206ef497..82b3b5ad3d4 100644 --- a/packages/cli/src/ui/components/VoiceStatus.tsx +++ b/packages/cli/src/ui/components/VoiceStatus.tsx @@ -58,5 +58,6 @@ export const VoiceStatus: React.FC = ({ item }) => ( /voice help {' for all commands'} -
+ +
); From 3c1f6bf0015ac993361ee23781a0e7f7eee93488 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 2 Mar 2026 22:15:18 -0500 Subject: [PATCH 05/37] fix(voice): consume double-space characters properly when appending --- packages/cli/src/ui/components/InputPrompt.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 7462fcfbf27..38fd842836c 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -911,6 +911,14 @@ export const InputPrompt: React.FC = ({ const delta = now - lastSpacePressRef.current; if (delta > 0 && delta < 300) { lastSpacePressRef.current = 0; + + // The first space was already inserted into the buffer on the previous keypress + // (because we let it pass through to keep typing feeling responsive). + // We must manually delete that first space now before starting recording. + if (buffer.text.length > 0 && buffer.text.endsWith(' ')) { + buffer.setText(buffer.text.slice(0, -1)); + } + void toggleRecording(); return true; // Consume the second space } From 5a440075ac1e8eec5f02152ffc7a0d9b5cb41dfa Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 2 Mar 2026 22:16:59 -0500 Subject: [PATCH 06/37] fix(voice): prevent voice toggle on continuous spacebar hold --- packages/cli/src/ui/components/InputPrompt.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 38fd842836c..bcbc097b1e2 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -909,7 +909,10 @@ export const InputPrompt: React.FC = ({ if (voiceEnabled && key.sequence === ' ') { const now = Date.now(); const delta = now - lastSpacePressRef.current; - if (delta > 0 && delta < 300) { + + // If they are just holding down the spacebar, the OS will send repeated keys + // very quickly (e.g. every 30ms). We don't want to trigger voice on continuous hold. + if (delta > 50 && delta < 300) { lastSpacePressRef.current = 0; // The first space was already inserted into the buffer on the previous keypress @@ -922,6 +925,7 @@ export const InputPrompt: React.FC = ({ void toggleRecording(); return true; // Consume the second space } + lastSpacePressRef.current = now; // Consume the first space only if the buffer is empty to avoid leading whitespace. @@ -930,7 +934,6 @@ export const InputPrompt: React.FC = ({ return true; } } - if (voiceEnabled && keyMatchers[Command.VOICE_INPUT](key)) { void toggleRecording(); return true; From 6079a0457d15b4769af493baa9bae7b2cb020bc2 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 2 Mar 2026 23:07:00 -0500 Subject: [PATCH 07/37] test(voice): use createMockConfig utility to fix strict typing violation --- packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts | 5 +++-- packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx | 5 +++-- packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts | 5 +++-- packages/cli/src/ui/hooks/useVoiceInput.test.ts | 5 +++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts index ee0ef2002db..31690567eb1 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts +++ b/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { createMockConfig } from '../../test-utils/mockConfig.js'; import { useVoiceInput } from './useVoiceInput.js'; import { debugLogger } from '@google/gemini-cli-core'; import type { VoiceBackendOptions } from '@google/gemini-cli-core'; @@ -40,9 +41,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -const mockConfig = { +const mockConfig = createMockConfig({ getContentGenerator: vi.fn().mockReturnValue({}), -} as unknown as import('@google/gemini-cli-core').Config; +}); describe('useVoiceInput Log Volume', () => { let logSpy: ReturnType; diff --git a/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx b/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx index 8bb197b7b18..435e8c4adac 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx +++ b/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act, useContext, useEffect } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { createMockConfig } from '../../test-utils/mockConfig.js'; import { waitFor } from '../../test-utils/async.js'; import { useVoiceInput, onVoiceTranscript } from './useVoiceInput.js'; import { VoiceContext } from '../contexts/VoiceContext.js'; @@ -63,9 +64,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -const mockConfig = { +const mockConfig = createMockConfig({ getContentGenerator: vi.fn().mockReturnValue({}), -} as unknown as import('@google/gemini-cli-core').Config; +}); describe('Voice Input Full Cycle Replication', () => { beforeEach(() => { diff --git a/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts index ee6120cd42f..6f03b36e248 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts +++ b/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { createMockConfig } from '../../test-utils/mockConfig.js'; import { useVoiceInput } from './useVoiceInput.js'; import type { VoiceBackendOptions } from '@google/gemini-cli-core'; @@ -41,9 +42,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -const mockConfig = { +const mockConfig = createMockConfig({ getContentGenerator: vi.fn().mockReturnValue({}), -} as unknown as import('@google/gemini-cli-core').Config; +}); describe('useVoiceInput Stress Tests', () => { beforeEach(() => { diff --git a/packages/cli/src/ui/hooks/useVoiceInput.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.test.ts index ffb27a151e2..9324b0ddba3 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.test.ts +++ b/packages/cli/src/ui/hooks/useVoiceInput.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; +import { createMockConfig } from '../../test-utils/mockConfig.js'; import { useVoiceInput, onVoiceTranscript } from './useVoiceInput.js'; import type { VoiceBackendOptions } from '@google/gemini-cli-core'; import { coreEvents } from '@google/gemini-cli-core'; @@ -61,9 +62,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); // Minimal mock Config with a working ContentGenerator stub -const mockConfig = { +const mockConfig = createMockConfig({ getContentGenerator: vi.fn().mockReturnValue({}), -} as unknown as import('@google/gemini-cli-core').Config; +}); describe('useVoiceInput', () => { beforeEach(() => { From ea161cf3ca4c2f2c5ae35b290d9136639247a68a Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 3 Mar 2026 05:21:31 -0500 Subject: [PATCH 08/37] fix(voice): remove Ctrl+Space shortcut per reviewer feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the Ctrl+Space additions added in the previous iteration: - keyBindings.ts: restore empty binding for VOICE_INPUT - KeypressContext.tsx: remove \x00 → Ctrl+Space parsing - VoiceHelp.tsx, VoiceStatus.tsx: remove Ctrl+Space from help text - keyboard-shortcuts.md: restore correct Space Space entry Only double-space on empty input triggers recording, per jacob314's request. --- docs/reference/keyboard-shortcuts.md | 6 +++--- packages/cli/src/config/keyBindings.ts | 5 ++--- packages/cli/src/ui/components/VoiceHelp.tsx | 6 +----- packages/cli/src/ui/components/VoiceStatus.tsx | 6 +----- packages/cli/src/ui/contexts/KeypressContext.tsx | 7 ++----- 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 9bed373d3de..a1409f3936c 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -96,9 +96,9 @@ available combinations. #### Voice Input -| Action | Keys | -| --------------------------------------------------------- | -------------- | -| Toggle voice input recording (press space twice rapidly). | `Ctrl + space` | +| Action | Keys | +| ---------------------------------------------------- | ------------- | +| Start voice recording (double-space on empty input). | `Space Space` | #### App Controls diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index ca7a130efa8..4d7107be946 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -301,9 +301,8 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], - // Voice Input — triggered by double-space anywhere (see InputPrompt.tsx) - // or Ctrl+Space as a fallback combo. - [Command.VOICE_INPUT]: [{ key: 'space', ctrl: true }], + // Voice Input — triggered by double-space on empty input (see InputPrompt.tsx) + [Command.VOICE_INPUT]: [], }; interface CommandCategory { diff --git a/packages/cli/src/ui/components/VoiceHelp.tsx b/packages/cli/src/ui/components/VoiceHelp.tsx index 16d45c89277..684a736515a 100644 --- a/packages/cli/src/ui/components/VoiceHelp.tsx +++ b/packages/cli/src/ui/components/VoiceHelp.tsx @@ -52,11 +52,7 @@ export const VoiceHelp: React.FC = () => ( Space Space - {' or '} - - Ctrl+Space - - {' - Start/stop recording'} + {' (on empty input) - Start/stop recording'} diff --git a/packages/cli/src/ui/components/VoiceStatus.tsx b/packages/cli/src/ui/components/VoiceStatus.tsx index 82b3b5ad3d4..5a60d074241 100644 --- a/packages/cli/src/ui/components/VoiceStatus.tsx +++ b/packages/cli/src/ui/components/VoiceStatus.tsx @@ -49,11 +49,7 @@ export const VoiceStatus: React.FC = ({ item }) => ( Space Space - {' or '} - - Ctrl+Space - - {' to record · '} + {' (empty input) to record · '} /voice help diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index af9b15603d1..7d1881644d4 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -605,11 +605,8 @@ function* emitKeys( name = 'space'; alt = escaped; insertable = true; - } else if (!escaped && ch === '\x00') { - // ctrl+space sends \x00 in many terminals - name = 'space'; - ctrl = true; - } else if (!escaped && ch <= '\x1a') { // ctrl+letter + } else if (!escaped && ch <= '\x1a') { + // ctrl+letter name = String.fromCharCode(ch.charCodeAt(0) + 'a'.charCodeAt(0) - 1); ctrl = true; } else if (/^[0-9A-Za-z]$/.exec(ch) !== null) { From 933af01999fe9532c5e411ab9f4aefa25ae5bdd3 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 3 Mar 2026 05:23:19 -0500 Subject: [PATCH 09/37] fix(voice): update stale Alt+R/Ctrl+Q references in docs and UX strings - docs/cli/settings.md: update voice.enabled description - docs/reference/configuration.md: update voice.enabled description - InputPrompt.tsx: update recording status text - voiceCommand.ts: update /voice enable description and confirmation message --- docs/cli/settings.md | 2 +- docs/reference/configuration.md | 6 +++--- packages/cli/src/ui/commands/voiceCommand.ts | 5 +++-- packages/cli/src/ui/components/InputPrompt.tsx | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 5893d4cd77d..a66cd5f519b 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -79,7 +79,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| Voice Input | `voice.enabled` | Enable voice input. When enabled, press **Alt+R** or **Ctrl+Q** to start/stop recording. | `false` | +| Voice Input | `voice.enabled` | Enable voice input. When enabled, double-tap Space on an empty prompt to start/stop recording. | `false` | | Transcription Backend | `voice.provider` | Transcription backend to use: `gemini` (zero-install, uses existing Gemini API auth) or `whisper` (local binary). | `"gemini"` | | Silence Detection | `voice.silenceThreshold` | RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values capture quieter speech (e.g. whispering). `0` disables silence detection. | `80` | | Whisper Binary Path | `voice.whisperPath` | Path to the Whisper executable. Only used when `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper`). | `undefined` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cfd35435c07..0fc590c41ce 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -343,9 +343,9 @@ their corresponding top-level category object in your `settings.json` file. #### `voice` - **`voice.enabled`** (boolean): - - **Description:** Enable voice input. When enabled, press **Alt+R** or - **Ctrl+Q** to start/stop recording. Use `/voice enable` or `/voice disable` - to toggle. + - **Description:** Enable voice input. When enabled, double-tap Space on an + empty prompt to start/stop recording. Use `/voice enable` or + `/voice disable` to toggle. - **Default:** `false` - **`voice.provider`** (string: `"gemini"` | `"whisper"`): diff --git a/packages/cli/src/ui/commands/voiceCommand.ts b/packages/cli/src/ui/commands/voiceCommand.ts index c194504897e..efe2f221645 100644 --- a/packages/cli/src/ui/commands/voiceCommand.ts +++ b/packages/cli/src/ui/commands/voiceCommand.ts @@ -27,7 +27,7 @@ const voiceHelpCommand: SlashCommand = { const voiceEnableCommand: SlashCommand = { name: 'enable', - description: 'Enable voice input (record with Alt+R or Ctrl+Q)', + description: 'Enable voice input (double-tap Space on empty input to record)', kind: CommandKind.BUILT_IN, action: async (context) => { context.services.settings.setValue( @@ -38,7 +38,8 @@ const voiceEnableCommand: SlashCommand = { return { type: 'message', messageType: MessageType.INFO, - content: 'Voice input enabled. Press Alt+R or Ctrl+Q to start recording.', + content: + 'Voice input enabled. Double-tap Space on an empty prompt to start recording.', }; }, }; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index bcbc097b1e2..4a9465ca34f 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1533,7 +1533,7 @@ export const InputPrompt: React.FC = ({ // (recording must always be visible regardless of approval mode) if (voiceState.isRecording) { statusColor = theme.status.error; - statusText = '🎤 Recording... (Alt+R/Ctrl+Q to stop, Esc to cancel)'; + statusText = '🎤 Recording... (Space Space or Esc to stop)'; } else if (voiceState.isTranscribing) { statusColor = theme.status.warning; statusText = 'Transcribing...'; From 0c212554508539f67a2d9dc73b22f9c804670800 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 3 Mar 2026 08:34:44 -0500 Subject: [PATCH 10/37] fix(voice): cleanup duplicate core exports and use default flash model for transcription --- packages/core/src/index.ts | 4 ---- packages/core/src/services/voice/GeminiRestBackend.ts | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e83b8705cfa..fccf9420df4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,7 +107,6 @@ export * from './utils/extensionLoader.js'; export * from './utils/package.js'; export * from './utils/version.js'; export * from './utils/checkpointUtils.js'; -export * from './utils/secure-browser-launcher.js'; export * from './utils/apiConversionUtils.js'; export * from './utils/channel.js'; export * from './utils/constants.js'; @@ -203,9 +202,6 @@ export * from './hooks/index.js'; // Export hook types export * from './hooks/types.js'; -// Export agent types -export * from './agents/types.js'; - // Export stdio utils export * from './utils/stdio.js'; export * from './utils/terminal.js'; diff --git a/packages/core/src/services/voice/GeminiRestBackend.ts b/packages/core/src/services/voice/GeminiRestBackend.ts index f202ab09fbb..5c46eaf8481 100644 --- a/packages/core/src/services/voice/GeminiRestBackend.ts +++ b/packages/core/src/services/voice/GeminiRestBackend.ts @@ -5,6 +5,7 @@ */ import { type Config } from '../../config/config.js'; +import { DEFAULT_GEMINI_FLASH_MODEL } from '../../config/models.js'; import { coreEvents } from '../../utils/events.js'; import { resolveExecutable } from '../../utils/shell-utils.js'; import { LlmRole } from '../../telemetry/llmRole.js'; @@ -210,7 +211,7 @@ export class GeminiRestBackend implements VoiceBackend { if (!generator) throw new Error('Gemini API not initialized'); const request: GenerateContentParameters = { - model: 'gemini-2.0-flash', + model: DEFAULT_GEMINI_FLASH_MODEL, contents: [ { role: 'user', From 1736fcdd03d4d641524f064ff3af1aa9b446eaa9 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Wed, 4 Mar 2026 02:53:17 -0500 Subject: [PATCH 11/37] fix(voice): eliminate layout shift and prevent misfires on hold --- packages/cli/src/ui/components/InputPrompt.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 4a9465ca34f..19ae0657646 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1621,18 +1621,23 @@ export const InputPrompt: React.FC = ({ (r:){' '} ) : ( - '!' + '! ' ) ) : commandSearchActive ? ( (r:) ) : showYoloStyling ? ( - '*' + '* ' ) : voiceState.isRecording ? ( '🎤' ) : voiceState.isTranscribing ? ( - + <> + + {' '} + ) : ( - '>' + // Use two spaces for single-width icons to match double-width emoji + // and prevent layout shift when toggling voice. + '> ' )}{' '} From 3bd199eace2c75ebd2b4d9b1a747889d3a6d7af7 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Wed, 4 Mar 2026 03:01:57 -0500 Subject: [PATCH 12/37] chore: trigger CI build From 7c9877201f731aa71b3dda517fa84badbc9b00be Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 9 Mar 2026 15:14:26 -0400 Subject: [PATCH 13/37] fix(ci): resolve linting errors and sync documentation --- docs/cli/settings.md | 12 +++---- docs/reference/configuration.md | 31 ++++++++----------- docs/reference/keyboard-shortcuts.md | 6 ++-- packages/cli/src/ui/AppContainer.tsx | 2 -- .../cli/src/ui/components/InputPrompt.tsx | 4 +-- 5 files changed, 23 insertions(+), 32 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 0479117d231..24b0e093364 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -77,12 +77,12 @@ they appear in the UI. ### Voice -| UI Label | Setting | Description | Default | -| --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| Voice Input | `voice.enabled` | Enable voice input. When enabled, double-tap Space on an empty prompt to start/stop recording. | `false` | -| Transcription Backend | `voice.provider` | Transcription backend to use: `gemini` (zero-install, uses existing Gemini API auth) or `whisper` (local binary). | `"gemini"` | -| Silence Detection | `voice.silenceThreshold` | RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values capture quieter speech (e.g. whispering). `0` disables silence detection. | `80` | -| Whisper Binary Path | `voice.whisperPath` | Path to the Whisper executable. Only used when `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper`). | `undefined` | +| UI Label | Setting | Description | Default | +| --------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | +| Enable Voice Input | `voice.enabled` | Enable voice input support. | `false` | +| Transcription Backend | `voice.provider` | Transcription backend: "gemini" (default, zero-install) or "whisper" (local). | `undefined` | +| Whisper Binary Path | `voice.whisperPath` | Path to the whisper executable. Only used when provider is "whisper". | `undefined` | +| Silence Detection Threshold | `voice.silenceThreshold` | RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values allow quieter speech such as whispering. 0 disables silence detection. | `80` | ### IDE diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 11872f504f2..3a1fbde2f02 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -353,30 +353,25 @@ their corresponding top-level category object in your `settings.json` file. #### `voice` - **`voice.enabled`** (boolean): - - **Description:** Enable voice input. When enabled, double-tap Space on an - empty prompt to start/stop recording. Use `/voice enable` or - `/voice disable` to toggle. + - **Description:** Enable voice input support. - **Default:** `false` -- **`voice.provider`** (string: `"gemini"` | `"whisper"`): - - **Description:** Transcription backend. `gemini` uses the Gemini API with - your existing auth (zero additional setup). `whisper` uses a locally - installed Whisper binary for offline/faster transcription. - - **Default:** `"gemini"` - -- **`voice.silenceThreshold`** (number, 0–1000): - - **Description:** RMS energy threshold for silence detection. Audio below - this level is discarded without an API call. Lower values capture quieter - speech (e.g. whispering). Set to `0` to disable silence detection and always - transcribe. Use `/voice sensitivity ` to adjust at runtime. - - **Default:** `80` +- **`voice.provider`** (string): + - **Description:** Transcription backend: "gemini" (default, zero-install) or + "whisper" (local). + - **Default:** `undefined` - **`voice.whisperPath`** (string): - - **Description:** Path to the Whisper executable. Only used when - `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper` or - `~/.local/bin/whisper`). Use `/voice set-path ` to set at runtime. + - **Description:** Path to the whisper executable. Only used when provider is + "whisper". - **Default:** `undefined` +- **`voice.silenceThreshold`** (number): + - **Description:** RMS energy threshold (0–1000) below which audio is + discarded as silence. Lower values allow quieter speech such as whispering. + 0 disables silence detection. + - **Default:** `80` + #### `ide` - **`ide.enabled`** (boolean): diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index ceae426eb43..ad05b9669f8 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -96,9 +96,9 @@ available combinations. #### Voice Input -| Action | Keys | -| ---------------------------------------------------- | ------------- | -| Start voice recording (double-space on empty input). | `Space Space` | +| Action | Keys | +| --------------------------------------------------------- | ---- | +| Toggle voice input recording (press space twice rapidly). | | #### App Controls diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7623e05b3b0..5891d45af6a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1877,8 +1877,6 @@ Logging in with Google... Restarting Gemini CLI to continue. settings.merged.general.devtools, showErrorDetails, triggerExpandHint, - tabFocusTimeoutRef, - handleWarning, voice, ], ); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 282161b298f..c55454e50da 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1350,7 +1350,6 @@ export const InputPrompt: React.FC = ({ shouldShowSuggestions, isShellSuggestionsVisible, forceShowShellSuggestions, - history, voiceEnabled, toggleRecording, cancelRecording, @@ -1631,8 +1630,7 @@ export const InputPrompt: React.FC = ({ '🎤' ) : voiceState.isTranscribing ? ( <> - - {' '} + {' '} ) : ( // Use two spaces for single-width icons to match double-width emoji From 3d26763d03c451538e9649e86996d1fe1456783f Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 9 Mar 2026 21:10:20 -0400 Subject: [PATCH 14/37] test(voice): update keyMatchers test to match new voice input behavior --- packages/cli/src/ui/keyMatchers.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 3ebda5e4e6a..6572413ca04 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -376,8 +376,12 @@ describe('keyMatchers', () => { }, { command: Command.VOICE_INPUT, - positive: [createKey('r', { alt: true })], - negative: [createKey('v'), createKey('v', { ctrl: true })], + positive: [], + negative: [ + createKey('r', { alt: true }), + createKey('v'), + createKey('v', { ctrl: true }), + ], }, // Shell commands { From 911eaf68fb464f0e4223467fdf9864733ec2d4d7 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 10 Mar 2026 07:32:27 -0400 Subject: [PATCH 15/37] fix(cli): restore non-interactive voice input flows --- packages/cli/src/nonInteractiveCli.test.ts | 120 ++++++++++++++- packages/cli/src/nonInteractiveCli.ts | 61 +++++++- packages/cli/src/nonInteractiveCliCommands.ts | 138 ++++++++++-------- .../src/ui/components/InputPrompt.test.tsx | 14 +- .../cli/src/ui/components/InputPrompt.tsx | 10 +- .../__snapshots__/InputPrompt.test.tsx.snap | 28 ++-- .../voice/LocalWhisperBackend.test.ts | 131 +++++++++++++++++ .../src/services/voice/LocalWhisperBackend.ts | 62 ++++++++ 8 files changed, 472 insertions(+), 92 deletions(-) create mode 100644 packages/core/src/services/voice/LocalWhisperBackend.test.ts diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 206d011e630..a2db84aec44 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -17,6 +17,7 @@ import { ToolErrorType, GeminiEventType, OutputFormat, + JsonStreamEventType, uiTelemetryService, FatalInputError, CoreEvent, @@ -1002,7 +1003,10 @@ describe('runNonInteractive', () => { nonInteractiveCliCommands, 'handleSlashCommand', ); - handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]); + handleSlashCommandSpy.mockResolvedValue({ + kind: 'submit_prompt', + content: [{ text: 'Slash command output' }], + }); const events: ServerGeminiStreamEvent[] = [ { type: GeminiEventType.Content, value: 'Response to slash command' }, @@ -1040,6 +1044,120 @@ describe('runNonInteractive', () => { handleSlashCommandSpy.mockRestore(); }); + it('should write slash command messages in text mode without invoking the model', async () => { + const mockCommand = { + name: 'testcommand', + description: 'a test command', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'Slash command message', + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testcommand', + prompt_id: 'prompt-id-slash-message-text', + }); + + expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(getWrittenOutput()).toBe('[INFO] Slash command message\n'); + }); + + it('should write slash command messages in JSON mode without invoking the model', async () => { + const mockCommand = { + name: 'testcommand', + description: 'a test command', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'Slash command message', + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testcommand', + prompt_id: 'prompt-id-slash-message-json', + }); + + expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + expect(getWrittenOutput()).toBe( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Slash command message', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should write slash command messages in stream-json mode without invoking the model', async () => { + const mockCommand = { + name: 'testcommand', + description: 'a test command', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'Slash command message', + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testcommand', + prompt_id: 'prompt-id-slash-message-stream', + }); + + expect(mockGeminiClient.sendMessageStream).not.toHaveBeenCalled(); + + const lines = getWrittenOutput() + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + + expect(lines).toHaveLength(4); + expect(lines[0]).toMatchObject({ + type: JsonStreamEventType.INIT, + session_id: 'test-session-id', + model: 'test-model', + }); + expect(lines[1]).toMatchObject({ + type: JsonStreamEventType.MESSAGE, + role: 'user', + content: '/testcommand', + }); + expect(lines[2]).toMatchObject({ + type: JsonStreamEventType.MESSAGE, + role: 'assistant', + content: 'Slash command message', + }); + expect(lines[3]).toMatchObject({ + type: JsonStreamEventType.RESULT, + status: 'success', + }); + }); + it('should handle cancellation (Ctrl+C)', async () => { // Mock isTTY and setRawMode safely const originalIsTTY = process.stdin.isTTY; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 95257702a0f..02ef768be69 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -80,6 +80,52 @@ export async function runNonInteractive({ const { stdout: workingStdout } = createWorkingStdio(); const textOutput = new TextOutput(workingStdout); + const writeSlashCommandMessage = ( + messageType: 'info' | 'error', + content: string, + streamFormatter: StreamJsonFormatter | null, + ) => { + const timestamp = new Date().toISOString(); + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp, + role: 'user', + content: input, + }); + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp, + role: 'assistant', + content, + }); + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp, + status: 'success', + stats: streamFormatter.convertToStreamStats(metrics, durationMs), + }); + return; + } + + if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + textOutput.write( + formatter.format(config.getSessionId(), content, stats), + ); + return; + } + + const output = `[${messageType.toUpperCase()}] ${content}\n`; + if (messageType === 'error') { + process.stderr.write(output); + } else { + textOutput.write(output); + } + }; const handleUserFeedback = (payload: UserFeedbackPayload) => { const prefix = payload.severity.toUpperCase(); @@ -246,12 +292,17 @@ export async function runNonInteractive({ config, settings, ); - // If a slash command is found and returns a prompt, use it. - // Otherwise, if it was a slash command, we are done. - if (slashCommandResult) { + if (slashCommandResult.kind === 'submit_prompt') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - query = slashCommandResult as Part[]; - } else { + query = slashCommandResult.content as Part[]; + } else if (slashCommandResult.kind === 'message') { + writeSlashCommandMessage( + slashCommandResult.messageType, + slashCommandResult.content, + streamFormatter, + ); + return; + } else if (slashCommandResult.kind === 'handled') { return; } } diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 8e764942efc..e23481e5de1 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -21,11 +21,28 @@ import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js' import type { LoadedSettings } from './config/settings.js'; import type { SessionStatsState } from './ui/contexts/SessionContext.js'; +export type NonInteractiveSlashCommandResult = + | { + kind: 'submit_prompt'; + content: PartListUnion; + } + | { + kind: 'message'; + messageType: 'info' | 'error'; + content: string; + } + | { + kind: 'handled'; + } + | { + kind: 'unhandled'; + }; + /** * Processes a slash command in a non-interactive environment. * - * @returns A Promise that resolves to `PartListUnion` if a valid command is - * found and results in a prompt, or `undefined` otherwise. + * @returns A structured result indicating whether the slash command was + * handled, should fall back to prompt submission, or produced output. * @throws {FatalInputError} if the command result is not supported in * non-interactive mode. */ @@ -34,10 +51,10 @@ export const handleSlashCommand = async ( abortController: AbortController, config: Config, settings: LoadedSettings, -): Promise => { +): Promise => { const trimmed = rawQuery.trim(); if (!trimmed.startsWith('/')) { - return; + return { kind: 'unhandled' }; } const commandService = await CommandService.create( @@ -52,67 +69,70 @@ export const handleSlashCommand = async ( const { commandToExecute, args } = parseSlashCommand(rawQuery, commands); - if (commandToExecute) { - if (commandToExecute.action) { - // Not used by custom commands but may be in the future. - const sessionStats: SessionStatsState = { - sessionId: config?.getSessionId(), - sessionStartTime: new Date(), - metrics: uiTelemetryService.getMetrics(), - lastPromptTokenCount: 0, - promptCount: 1, - }; + if (!commandToExecute) { + return { kind: 'unhandled' }; + } + + if (commandToExecute.action) { + // Not used by custom commands but may be in the future. + const sessionStats: SessionStatsState = { + sessionId: config?.getSessionId(), + sessionStartTime: new Date(), + metrics: uiTelemetryService.getMetrics(), + lastPromptTokenCount: 0, + promptCount: 1, + }; - const logger = new Logger(config?.getSessionId() || '', config?.storage); + const logger = new Logger(config?.getSessionId() || '', config?.storage); - const context: CommandContext = { - services: { - config, - settings, - git: undefined, - logger, - }, - ui: createNonInteractiveUI(), - session: { - stats: sessionStats, - sessionShellAllowlist: new Set(), - }, - invocation: { - raw: trimmed, - name: commandToExecute.name, - args, - }, - }; + const context: CommandContext = { + services: { + config, + settings, + git: undefined, + logger, + }, + ui: createNonInteractiveUI(), + session: { + stats: sessionStats, + sessionShellAllowlist: new Set(), + }, + invocation: { + raw: trimmed, + name: commandToExecute.name, + args, + }, + }; - const result = await commandToExecute.action(context, args); + const result = await commandToExecute.action(context, args); - if (result) { - switch (result.type) { - case 'submit_prompt': - return result.content; - case 'message': { - const prefix = result.messageType?.toUpperCase() || 'INFO'; - process.stdout.write(`[${prefix}] ${result.content}\n`); - return; - } - case 'confirm_shell_commands': - // This result indicates a command attempted to confirm shell commands. - // However note that currently, ShellTool is excluded in non-interactive - // mode unless 'YOLO mode' is active, so confirmation actually won't - // occur because of YOLO mode. - // This ensures that if a command *does* request confirmation (e.g. - // in the future with more granular permissions), it's handled appropriately. - throw new FatalInputError( - 'Exiting due to a confirmation prompt requested by the command.', - ); - default: - throw new FatalInputError( - 'Exiting due to command result that is not supported in non-interactive mode.', - ); - } + if (result) { + switch (result.type) { + case 'submit_prompt': + return { kind: 'submit_prompt', content: result.content }; + case 'message': + return { + kind: 'message', + messageType: result.messageType, + content: result.content, + }; + case 'confirm_shell_commands': + // This result indicates a command attempted to confirm shell commands. + // However note that currently, ShellTool is excluded in non-interactive + // mode unless 'YOLO mode' is active, so confirmation actually won't + // occur because of YOLO mode. + // This ensures that if a command *does* request confirmation (e.g. + // in the future with more granular permissions), it's handled appropriately. + throw new FatalInputError( + 'Exiting due to a confirmation prompt requested by the command.', + ); + default: + throw new FatalInputError( + 'Exiting due to command result that is not supported in non-interactive mode.', + ); } } } - return; + return { kind: 'handled' }; }; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 70fefe42ff8..92cab98cdf8 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -3344,28 +3344,28 @@ describe('InputPrompt', () => { name: 'first line, first char', relX: 0, relY: 0, - mouseCol: 5, + mouseCol: 4, mouseRow: 2, }, { name: 'first line, middle char', relX: 6, relY: 0, - mouseCol: 11, + mouseCol: 10, mouseRow: 2, }, { name: 'second line, first char', relX: 0, relY: 1, - mouseCol: 5, + mouseCol: 4, mouseRow: 3, }, { name: 'second line, end char', relX: 5, relY: 1, - mouseCol: 10, + mouseCol: 9, mouseRow: 3, }, ])( @@ -3392,7 +3392,7 @@ describe('InputPrompt', () => { }); // Simulate left mouse press at calculated coordinates. - // Without left border: inner box is at x=3, y=1 based on padding(1)+prompt(2) and border-top(1). + // Without left border: inner box is at x=2, y=1 based on padding(1)+prompt(1) and border-top(1). await act(async () => { stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`); }); @@ -3624,9 +3624,9 @@ describe('InputPrompt', () => { expect(stdout.lastFrame()).toContain('hello world'); }); - // With plain borders: 1(border) + 1(padding) + 3(prompt) = 5 offset (x=5, col=6) + // With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5) await act(async () => { - stdin.write(`\x1b[<0;6;2M`); // Click at col 6, row 2 + stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2 }); await waitFor(() => { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 1feeeff6a28..525e0549bb8 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -77,7 +77,7 @@ import { onVoiceTranscript, } from '../contexts/VoiceContext.js'; import Spinner from 'ink-spinner'; -import { shouldDismissShortcutsHelpOnHotkey, useIsHelpDismissKey } from '../utils/shortcutsHelp.js'; +import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js'; import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; @@ -1543,7 +1543,7 @@ export const InputPrompt: React.FC = ({ // (recording must always be visible regardless of approval mode) if (voiceState.isRecording) { statusColor = theme.status.error; - statusText = '🎤 Recording... (Space Space or Esc to stop)'; + statusText = 'Recording... (Space Space or Esc to stop)'; } else if (voiceState.isTranscribing) { statusColor = theme.status.warning; statusText = 'Transcribing...'; @@ -1638,16 +1638,14 @@ export const InputPrompt: React.FC = ({ ) : showYoloStyling ? ( '* ' ) : voiceState.isRecording ? ( - '🎤' + '● ' ) : voiceState.isTranscribing ? ( <> {' '} ) : ( - // Use two spaces for single-width icons to match double-width emoji - // and prevent layout shift when toggling voice. '> ' - )}{' '} + )} {buffer.text.length === 0 && effectivePlaceholder ? ( diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 911357847fa..f6313d3f91e 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -2,14 +2,14 @@ exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > second message + > second message ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) Type your message or @path/to/file + (r:) Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll @@ -19,7 +19,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) Type your message or @path/to/file + (r:) Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll @@ -29,7 +29,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) commit + (r:) commit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ git commit -m "feat: add search" in src/app " @@ -37,7 +37,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) commit + (r:) commit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ git commit -m "feat: add search" in src/app " @@ -45,63 +45,63 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Image ...reenshot2x.png] + > [Image ...reenshot2x.png] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > @/path/to/screenshots/screenshot2x.png + > @/path/to/screenshots/screenshot2x.png ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] + > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] + > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] + > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Type your message or @path/to/file + > Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - ! Type your message or @path/to/file + ! Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - * Type your message or @path/to/file + * Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Type your message or @path/to/file + > Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; diff --git a/packages/core/src/services/voice/LocalWhisperBackend.test.ts b/packages/core/src/services/voice/LocalWhisperBackend.test.ts new file mode 100644 index 00000000000..4247f5e894e --- /dev/null +++ b/packages/core/src/services/voice/LocalWhisperBackend.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LocalWhisperBackend } from './LocalWhisperBackend.js'; + +const { mockReadFile, mockStat, mockRm, mockEmitVoiceTranscript } = vi.hoisted( + () => ({ + mockReadFile: vi.fn(), + mockStat: vi.fn(), + mockRm: vi.fn(), + mockEmitVoiceTranscript: vi.fn(), + }), +); + +vi.mock('node:fs/promises', () => ({ + mkdtemp: vi.fn(), + readFile: mockReadFile, + rm: mockRm, + stat: mockStat, +})); + +vi.mock('../../utils/events.js', () => ({ + coreEvents: { + emitVoiceTranscript: mockEmitVoiceTranscript, + }, +})); + +function createRecordingProcessMock() { + return { + kill: vi.fn(), + on: vi.fn((event: string, callback: () => void) => { + if (event === 'close') { + callback(); + } + }), + }; +} + +function createWavBuffer(samples: number[]): Buffer { + const pcmBuffer = Buffer.alloc(samples.length * 2); + for (const [index, sample] of samples.entries()) { + pcmBuffer.writeInt16LE(sample, index * 2); + } + + const header = Buffer.alloc(44); + header.write('RIFF', 0); + header.writeUInt32LE(pcmBuffer.length + 36, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(16000, 24); + header.writeUInt32LE(32000, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write('data', 36); + header.writeUInt32LE(pcmBuffer.length, 40); + + return Buffer.concat([header, pcmBuffer]); +} + +describe('LocalWhisperBackend', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStat.mockResolvedValue({ size: 128 }); + mockRm.mockResolvedValue(undefined); + }); + + it('skips transcription for silent wav recordings when threshold is enabled', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new LocalWhisperBackend({ + onStateChange, + silenceThreshold: 80, + }); + const recordingProcess = createRecordingProcessMock(); + const transcribeSpy = vi + .spyOn(backend as never, 'transcribe') + .mockResolvedValue('should not be used'); + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { audioFile: string }).audioFile = + '/tmp/recording.wav'; + (backend as unknown as { tempDir: string }).tempDir = '/tmp/voice'; + mockReadFile.mockResolvedValue(createWavBuffer([0, 2, -1, 1])); + + await backend.stop(); + + expect(transcribeSpy).not.toHaveBeenCalled(); + expect(mockEmitVoiceTranscript).not.toHaveBeenCalled(); + expect(onStateChange).toHaveBeenCalledWith({ + isRecording: false, + isTranscribing: true, + error: null, + }); + expect(onStateChange).toHaveBeenLastCalledWith({ + isRecording: false, + isTranscribing: false, + error: null, + }); + }); + + it('does not skip transcription when silence detection is disabled', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new LocalWhisperBackend({ + onStateChange, + silenceThreshold: 0, + }); + const recordingProcess = createRecordingProcessMock(); + const transcribeSpy = vi + .spyOn(backend as never, 'transcribe') + .mockResolvedValue('transcribed text'); + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { audioFile: string }).audioFile = + '/tmp/recording.wav'; + (backend as unknown as { tempDir: string }).tempDir = '/tmp/voice'; + mockReadFile.mockResolvedValue(createWavBuffer([0, 1, -1, 2])); + + await backend.stop(); + + expect(transcribeSpy).toHaveBeenCalledWith('/tmp/recording.wav'); + expect(mockEmitVoiceTranscript).toHaveBeenCalledWith('transcribed text'); + }); +}); diff --git a/packages/core/src/services/voice/LocalWhisperBackend.ts b/packages/core/src/services/voice/LocalWhisperBackend.ts index b86fd43de33..383d9fe4a7f 100644 --- a/packages/core/src/services/voice/LocalWhisperBackend.ts +++ b/packages/core/src/services/voice/LocalWhisperBackend.ts @@ -146,6 +146,16 @@ export class LocalWhisperBackend implements VoiceBackend { if (!stats || stats.size === 0) throw new Error('No audio recorded (file is empty)'); + const audioBuffer = await readFile(this.audioFile); + if (this.isSilentWav(audioBuffer)) { + void this.options.onStateChange({ + isRecording: false, + isTranscribing: false, + error: null, + }); + return; + } + const transcript = await this.transcribe(this.audioFile); coreEvents.emitVoiceTranscript(transcript); void this.options.onStateChange({ @@ -220,4 +230,56 @@ export class LocalWhisperBackend implements VoiceBackend { this.audioFile = null; } } + + private isSilentWav(wavBuffer: Buffer): boolean { + const threshold = this.options.silenceThreshold ?? 80; + if (threshold === 0) return false; + + const pcmData = this.extractWavDataChunk(wavBuffer); + if (!pcmData || pcmData.length < 2) { + return false; + } + + const samples = Math.floor(pcmData.length / 2); + if (samples === 0) return true; + + let sumSquares = 0; + for (let i = 0; i < samples * 2; i += 2) { + const sample = pcmData.readInt16LE(i); + sumSquares += sample * sample; + } + + const rms = Math.sqrt(sumSquares / samples); + return rms < threshold; + } + + private extractWavDataChunk(wavBuffer: Buffer): Buffer | null { + if ( + wavBuffer.length < 12 || + wavBuffer.toString('ascii', 0, 4) !== 'RIFF' || + wavBuffer.toString('ascii', 8, 12) !== 'WAVE' + ) { + return null; + } + + let offset = 12; + while (offset + 8 <= wavBuffer.length) { + const chunkId = wavBuffer.toString('ascii', offset, offset + 4); + const chunkSize = wavBuffer.readUInt32LE(offset + 4); + const chunkStart = offset + 8; + const chunkEnd = chunkStart + chunkSize; + + if (chunkEnd > wavBuffer.length) { + return null; + } + + if (chunkId === 'data') { + return wavBuffer.subarray(chunkStart, chunkEnd); + } + + offset = chunkEnd + (chunkSize % 2); + } + + return null; + } } From bc057023a9bf076010e56b102dc8e76b17fdd0ca Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 10 Mar 2026 07:39:24 -0400 Subject: [PATCH 16/37] docs(cli): refresh voice input references --- docs/cli/settings.md | 2 +- docs/reference/commands.md | 25 +++++++++++++++++++++++++ docs/reference/configuration.md | 2 +- docs/reference/keyboard-shortcuts.md | 6 +++--- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index fd7a96e6285..9e533dd373c 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -80,7 +80,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | --------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | | Enable Voice Input | `voice.enabled` | Enable voice input support. | `false` | -| Transcription Backend | `voice.provider` | Transcription backend: "gemini" (default, zero-install) or "whisper" (local). | `undefined` | +| Transcription Backend | `voice.provider` | Transcription backend: "gemini" (default, zero-install) or "whisper" (local). | `"gemini"` | | Whisper Binary Path | `voice.whisperPath` | Path to the whisper executable. Only used when provider is "whisper". | `undefined` | | Silence Detection Threshold | `voice.silenceThreshold` | RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values allow quieter speech such as whispering. 0 disables silence detection. | `80` | diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c7c25cba1e2..54e9726dd64 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -232,6 +232,31 @@ Slash commands provide meta-level control over the CLI itself. - **Description:** List configured MCP servers and tools with descriptions and schemas. +### `/voice` + +- **Description:** Manage voice input configuration and inspect current voice + settings. +- **Shortcuts:** Press **Space Space** on an empty prompt to start or stop + recording. Press **Esc** while recording to cancel. +- **Sub-commands:** + - **`enable`**: + - **Description:** Enable voice input. + - **`disable`**: + - **Description:** Disable voice input. + - **`provider [gemini|whisper]`**: + - **Description:** Set the transcription backend. + - **`sensitivity <0-1000>`**: + - **Description:** Set the silence detection threshold. `0` disables silence + filtering. + - **`set-path `**: + - **Description:** Set the path to the Whisper binary when using the local + Whisper backend. + - **`help`**: + - **Description:** Show voice command help. + - **Default behavior:** + - **Description:** Running `/voice` with no sub-command shows the current + voice settings. + ### `/memory` - **Description:** Manage the AI's instructional context (hierarchical memory diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9247f79746e..0c74c3bd1f1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -359,7 +359,7 @@ their corresponding top-level category object in your `settings.json` file. - **`voice.provider`** (string): - **Description:** Transcription backend: "gemini" (default, zero-install) or "whisper" (local). - - **Default:** `undefined` + - **Default:** `"gemini"` - **`voice.whisperPath`** (string): - **Description:** Path to the whisper executable. Only used when provider is diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index cfaca880ed9..2760d01555d 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -95,9 +95,9 @@ available combinations. #### Voice Input -| Action | Keys | -| --------------------------------------------------------- | ---- | -| Toggle voice input recording (press space twice rapidly). | | +| Action | Keys | +| --------------------------------------------------------- | ------------- | +| Toggle voice input recording (press space twice rapidly). | `Space Space` | #### App Controls From 020b1da5a7d42270676c5ef2939a1f0b47be28b7 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 10 Mar 2026 20:53:47 -0400 Subject: [PATCH 17/37] fix(cli): resolve keybindings merge drift --- packages/cli/src/ui/key/keyBindings.test.ts | 7 +- packages/cli/src/ui/key/keyBindings.ts | 88 ++++++++++++--------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts index 3057bf85b6d..67ce6a973c0 100644 --- a/packages/cli/src/ui/key/keyBindings.test.ts +++ b/packages/cli/src/ui/key/keyBindings.test.ts @@ -107,11 +107,16 @@ describe('keyBindings config', () => { describe('defaultKeyBindings', () => { it('should have bindings for all commands', () => { const commands = Object.values(Command); + const commandsWithoutStaticBindings = new Set([Command.VOICE_INPUT]); for (const command of commands) { expect(defaultKeyBindings[command]).toBeDefined(); expect(Array.isArray(defaultKeyBindings[command])).toBe(true); - expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0); + if (commandsWithoutStaticBindings.has(command)) { + expect(defaultKeyBindings[command]).toHaveLength(0); + } else { + expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0); + } } }); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index 99737a99d85..b29c5eab53a 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -73,16 +73,6 @@ export enum Command { OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', PASTE_CLIPBOARD = 'input.paste', - BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape', - BACKGROUND_SHELL_SELECT = 'backgroundShellSelect', - TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell', - TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList', - KILL_BACKGROUND_SHELL = 'backgroundShell.kill', - UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus', - UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus', - SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning', - SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning', - // App Controls SHOW_ERROR_DETAILS = 'app.showErrorDetails', SHOW_FULL_TODOS = 'app.showFullTodos', @@ -98,6 +88,17 @@ export enum Command { CLEAR_SCREEN = 'app.clearScreen', RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', + SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning', + + // Background Shell Controls + BACKGROUND_SHELL_ESCAPE = 'background.escape', + BACKGROUND_SHELL_SELECT = 'background.select', + TOGGLE_BACKGROUND_SHELL = 'background.toggle', + TOGGLE_BACKGROUND_SHELL_LIST = 'background.toggleList', + KILL_BACKGROUND_SHELL = 'background.kill', + UNFOCUS_BACKGROUND_SHELL = 'background.unfocus', + UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList', + SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning', // Voice Input VOICE_INPUT = 'input.voice', @@ -357,15 +358,6 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.TOGGLE_COPY_MODE]: [new KeyBinding('ctrl+s')], [Command.TOGGLE_YOLO]: [new KeyBinding('ctrl+y')], [Command.CYCLE_APPROVAL_MODE]: [new KeyBinding('shift+tab')], - [Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')], - [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')], - [Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')], - [Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')], - [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')], - [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')], - [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')], - [Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('enter')], - [Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')], [Command.SHOW_MORE_LINES]: [new KeyBinding('ctrl+o')], [Command.EXPAND_PASTE]: [new KeyBinding('ctrl+o')], [Command.FOCUS_SHELL_INPUT]: [new KeyBinding('tab')], @@ -373,6 +365,17 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.CLEAR_SCREEN]: [new KeyBinding('ctrl+l')], [Command.RESTART_APP]: [new KeyBinding('r'), new KeyBinding('shift+r')], [Command.SUSPEND_APP]: [new KeyBinding('ctrl+z')], + [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')], + + // Background Shell Controls + [Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')], + [Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('enter')], + [Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')], + [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')], + [Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')], + [Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')], + [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')], + [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')], // Voice Input — triggered by double-space on empty input (see InputPrompt.tsx) [Command.VOICE_INPUT]: [], @@ -469,10 +472,6 @@ export const commandCategories: readonly CommandCategory[] = [ Command.PASTE_CLIPBOARD, ], }, - { - title: 'Voice Input', - commands: [Command.VOICE_INPUT], - }, { title: 'App Controls', commands: [ @@ -485,22 +484,31 @@ export const commandCategories: readonly CommandCategory[] = [ Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, Command.EXPAND_PASTE, + Command.FOCUS_SHELL_INPUT, + Command.UNFOCUS_SHELL_INPUT, + Command.CLEAR_SCREEN, + Command.RESTART_APP, + Command.SUSPEND_APP, + Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, + ], + }, + { + title: 'Background Shell Controls', + commands: [ + Command.BACKGROUND_SHELL_ESCAPE, + Command.BACKGROUND_SHELL_SELECT, Command.TOGGLE_BACKGROUND_SHELL, Command.TOGGLE_BACKGROUND_SHELL_LIST, Command.KILL_BACKGROUND_SHELL, - Command.BACKGROUND_SHELL_SELECT, - Command.BACKGROUND_SHELL_ESCAPE, Command.UNFOCUS_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL_LIST, Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, - Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, - Command.FOCUS_SHELL_INPUT, - Command.UNFOCUS_SHELL_INPUT, - Command.CLEAR_SCREEN, - Command.RESTART_APP, - Command.SUSPEND_APP, ], }, + { + title: 'Voice Input', + commands: [Command.VOICE_INPUT], + }, ]; /** @@ -586,9 +594,18 @@ export const commandDescriptions: Readonly> = { 'Expand and collapse blocks of content when not in alternate buffer mode.', [Command.EXPAND_PASTE]: 'Expand or collapse a paste placeholder when cursor is over placeholder.', + [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.', + [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', + [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', + [Command.RESTART_APP]: 'Restart the application.', + [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', + [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: + 'Show warning when trying to move focus away from shell input.', + + // Background Shell Controls + [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.', [Command.BACKGROUND_SHELL_SELECT]: 'Confirm selection in background shell list.', - [Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.', [Command.TOGGLE_BACKGROUND_SHELL]: 'Toggle current background shell visibility.', [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.', @@ -599,13 +616,6 @@ export const commandDescriptions: Readonly> = { 'Move focus from background shell list to Gemini.', [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Show warning when trying to move focus away from background shell.', - [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: - 'Show warning when trying to move focus away from shell input.', - [Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.', - [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', - [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', - [Command.RESTART_APP]: 'Restart the application.', - [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', // Voice Input [Command.VOICE_INPUT]: From e92cc6e9963415c6bd0a7862b3e28b368edb6da2 Mon Sep 17 00:00:00 2001 From: fayerman-source Date: Wed, 11 Mar 2026 05:07:11 -0400 Subject: [PATCH 18/37] fix: address voice input review feedback and sync docs --- docs/reference/keyboard-shortcuts.md | 6 ----- packages/cli/src/config/settingsSchema.ts | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 8 +++--- packages/cli/src/ui/hooks/useVoiceInput.ts | 21 +++++++++++----- packages/core/src/utils/events.ts | 20 +++++++-------- scripts/generate-keybindings-doc.ts | 25 +++++++++++++------ 6 files changed, 47 insertions(+), 35 deletions(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index e4a443a5477..3529ead3ec7 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -93,12 +93,6 @@ available combinations. | Open the current prompt or the plan in an external editor. | `Ctrl+X` | | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | -#### Voice Input - -| Action | Keys | -| --------------------------------------------------------- | ------------- | -| Toggle voice input recording (press space twice rapidly). | `Space Space` | - #### App Controls | Action | Keys | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 19090b61931..27b7257bf44 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -817,7 +817,7 @@ const SETTINGS_SCHEMA = { label: 'Transcription Backend', category: 'General', requiresRestart: false, - default: undefined as string | undefined, + default: 'gemini', description: 'Transcription backend: "gemini" (default, zero-install) or "whisper" (local).', showInDialog: true, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 525e0549bb8..d355a0b1f82 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -360,17 +360,17 @@ export const InputPrompt: React.FC = ({ toggleRecording, cancelRecording, } = useVoiceContext(); + const insertText = buffer.insert; // Handle voice transcript via event listener (not context) to avoid re-renders useEffect(() => { const handleTranscript = (transcript: string) => { // Insert transcribed text at cursor position with trailing space for next input - buffer.insert(transcript + ' '); + insertText(transcript + ' '); }; - const unsubscribe = onVoiceTranscript(handleTranscript); - return unsubscribe; - }, [buffer]); + return onVoiceTranscript(handleTranscript); + }, [insertText]); const handleSubmitAndClear = useCallback( (submittedValue: string) => { diff --git a/packages/cli/src/ui/hooks/useVoiceInput.ts b/packages/cli/src/ui/hooks/useVoiceInput.ts index d99d64f02bd..b1bc711d0bb 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.ts +++ b/packages/cli/src/ui/hooks/useVoiceInput.ts @@ -79,6 +79,11 @@ export function useVoiceInput(voiceConfig?: VoiceInputConfig) { const backendRef = useRef(null); const isTogglingRef = useRef(false); + const stateRef = useRef(state); + + useEffect(() => { + stateRef.current = state; + }, [state]); // Initialize (or re-initialize) the backend when config changes useEffect(() => { @@ -97,17 +102,21 @@ export function useVoiceInput(voiceConfig?: VoiceInputConfig) { silenceThreshold: voiceConfig?.silenceThreshold, }; + let activeBackend: VoiceBackend | null = null; if (voiceConfig?.provider === 'whisper') { - backendRef.current = new LocalWhisperBackend(options, { + activeBackend = new LocalWhisperBackend(options, { whisperPath: voiceConfig.whisperPath, }); } else if (voiceConfig?.config) { - backendRef.current = new GeminiRestBackend(options, voiceConfig.config); + activeBackend = new GeminiRestBackend(options, voiceConfig.config); } + backendRef.current = activeBackend; return () => { - void backendRef.current?.cleanup(); - backendRef.current = null; + void activeBackend?.cleanup(); + if (backendRef.current === activeBackend) { + backendRef.current = null; + } }; }, [ voiceConfig?.provider, @@ -142,7 +151,7 @@ export function useVoiceInput(voiceConfig?: VoiceInputConfig) { if (isTogglingRef.current) return; isTogglingRef.current = true; try { - if (state.isRecording) { + if (stateRef.current.isRecording) { await stopRecording(); } else { await startRecording(); @@ -152,7 +161,7 @@ export function useVoiceInput(voiceConfig?: VoiceInputConfig) { } finally { isTogglingRef.current = false; } - }, [state.isRecording, startRecording, stopRecording]); + }, [startRecording, stopRecording]); const isEnabled = !!( voiceConfig?.config || voiceConfig?.provider === 'whisper' diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index c02dd775c15..4750f78dbd9 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -186,20 +186,20 @@ export enum CoreEvent { ConsentRequest = 'consent-request', McpProgress = 'mcp-progress', AgentsDiscovered = 'agents-discovered', -RequestEditorSelection = 'request-editor-selection', -EditorSelected = 'editor-selected', -SlashCommandConflicts = 'slash-command-conflicts', -QuotaChanged = 'quota-changed', -TelemetryKeychainAvailability = 'telemetry-keychain-availability', -TelemetryTokenStorageType = 'telemetry-token-storage-type', -VoiceTranscript = 'voice-transcript', + RequestEditorSelection = 'request-editor-selection', + EditorSelected = 'editor-selected', + SlashCommandConflicts = 'slash-command-conflicts', + QuotaChanged = 'quota-changed', + TelemetryKeychainAvailability = 'telemetry-keychain-availability', + TelemetryTokenStorageType = 'telemetry-token-storage-type', + VoiceTranscript = 'voice-transcript', } /** -* Payload for the 'editor-selected' event. -*/ + * Payload for the 'editor-selected' event. + */ export interface EditorSelectedPayload { -editor?: EditorType; + editor?: EditorType; } export interface CoreEvents extends ExtensionEvents { [CoreEvent.UserFeedback]: [UserFeedbackPayload]; diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index ee908ff25d0..b3bdf486072 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -8,7 +8,10 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readFile, writeFile } from 'node:fs/promises'; -import type { KeyBinding } from '../packages/cli/src/ui/key/keyBindings.js'; +import { + Command, + type KeyBinding, +} from '../packages/cli/src/ui/key/keyBindings.js'; import { commandCategories, commandDescriptions, @@ -78,13 +81,19 @@ export async function main(argv = process.argv.slice(2)) { } export function buildDefaultDocSections(): readonly KeybindingDocSection[] { - return commandCategories.map((category) => ({ - title: category.title, - commands: category.commands.map((command) => ({ - description: commandDescriptions[command], - bindings: defaultKeyBindingConfig.get(command) ?? [], - })), - })); + const commandsExcludedFromAutogen = new Set([Command.VOICE_INPUT]); + + return commandCategories + .map((category) => ({ + title: category.title, + commands: category.commands + .filter((command) => !commandsExcludedFromAutogen.has(command)) + .map((command) => ({ + description: commandDescriptions[command], + bindings: defaultKeyBindingConfig.get(command) ?? [], + })), + })) + .filter((category) => category.commands.length > 0); } export function renderDocumentation( From f59415e4a34345860ee278123a666f1c8bd35edf Mon Sep 17 00:00:00 2001 From: fayerman-source Date: Wed, 11 Mar 2026 21:10:41 -0400 Subject: [PATCH 19/37] fix(voice): resolve regressions in transcription insertion and process termination - Fix lost 'this' context in buffer.insert for voice transcripts - Use SIGTERM for graceful audio process termination and buffer flushing - Add 500ms timeout to closePromise to prevent UI lockup on stop/cancel - Surface actual ALSA/system errors instead of generic empty file errors - Document WSL2 microphone pass-through limitations --- docs/reference/configuration.md | 8 +- packages/cli/src/config/settingsSchema.ts | 9 +- .../voiceWhisperPtyRepro.test.ts | 84 ++++++++++ packages/cli/src/ui/AppContainer.tsx | 11 ++ .../cli/src/ui/commands/voiceCommand.test.ts | 89 +++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 8 +- .../__snapshots__/InputPrompt.test.tsx.snap | 15 ++ .../cli/src/ui/hooks/useVoiceInput.test.ts | 49 ++++++ packages/cli/src/ui/hooks/useVoiceInput.ts | 22 ++- .../services/voice/GeminiRestBackend.test.ts | 144 ++++++++++++++++++ .../src/services/voice/GeminiRestBackend.ts | 59 +++++-- .../voice/LocalWhisperBackend.test.ts | 120 ++++++++++++++- .../src/services/voice/LocalWhisperBackend.ts | 69 +++++++-- schemas/settings.schema.json | 6 +- 14 files changed, 647 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/integration-tests/voiceWhisperPtyRepro.test.ts create mode 100644 packages/cli/src/ui/commands/voiceCommand.test.ts create mode 100644 packages/core/src/services/voice/GeminiRestBackend.test.ts diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ba97c295afb..13f6c0c29ca 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -352,14 +352,20 @@ their corresponding top-level category object in your `settings.json` file. #### `voice` +**Note:** Voice input is not natively supported in WSL2 (Windows Subsystem for +Linux) due to lack of microphone device pass-through. If you are using Windows, +please run Gemini CLI natively in PowerShell or Command Prompt to use voice +input. + - **`voice.enabled`** (boolean): - **Description:** Enable voice input support. - **Default:** `false` -- **`voice.provider`** (string): +- **`voice.provider`** (enum): - **Description:** Transcription backend: "gemini" (default, zero-install) or "whisper" (local). - **Default:** `"gemini"` + - **Values:** `"gemini"`, `"whisper"` - **`voice.whisperPath`** (string): - **Description:** Path to the whisper executable. Only used when provider is diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 27b7257bf44..31573f02a4c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -800,7 +800,8 @@ const SETTINGS_SCHEMA = { category: 'General', requiresRestart: false, default: {}, - description: 'Settings for voice input.', + description: + 'Settings for voice input. Note: Voice input is not natively supported in WSL2 (Windows Subsystem for Linux).', showInDialog: false, properties: { enabled: { @@ -813,7 +814,7 @@ const SETTINGS_SCHEMA = { showInDialog: true, }, provider: { - type: 'string', + type: 'enum', label: 'Transcription Backend', category: 'General', requiresRestart: false, @@ -821,6 +822,10 @@ const SETTINGS_SCHEMA = { description: 'Transcription backend: "gemini" (default, zero-install) or "whisper" (local).', showInDialog: true, + options: [ + { value: 'gemini', label: 'Gemini (Cloud)' }, + { value: 'whisper', label: 'Whisper (Local)' }, + ], }, whisperPath: { type: 'string', diff --git a/packages/cli/src/integration-tests/voiceWhisperPtyRepro.test.ts b/packages/cli/src/integration-tests/voiceWhisperPtyRepro.test.ts new file mode 100644 index 00000000000..8f05a3398a3 --- /dev/null +++ b/packages/cli/src/integration-tests/voiceWhisperPtyRepro.test.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { TestRig } from '@google/gemini-cli-test-utils'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import stripAnsi from 'strip-ansi'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe.skipIf(process.platform === 'win32')('Voice Whisper PTY repro', () => { + let rig: TestRig | undefined; + + afterEach(async () => { + await rig?.cleanup(); + }); + + it('repro: whisper can remain stuck in "Speak now..." after Esc in a real PTY session', async () => { + const fakeResponsesPath = path.join( + __dirname, + '../test-utils/fixtures/simple.responses', + ); + + rig = new TestRig(); + rig.setup('voice-whisper-pty-repro', { + fakeResponsesPath, + }); + + const ignoreSigintScript = rig.createScript( + 'ignore-sigint.js', + [ + "process.on('SIGINT', () => {});", + 'setInterval(() => {}, 1000);', + '', + ].join('\n'), + ); + + rig.createScript( + 'sox', + [ + '#!/usr/bin/env bash', + `exec "${process.execPath}" "${ignoreSigintScript}" "$@"`, + '', + ].join('\n'), + ); + + // Make the fake recorder executable. + const fs = await import('node:fs/promises'); + await fs.chmod(path.join(rig.testDir!, 'sox'), 0o755); + + const run = await rig.runInteractive({ + env: { + PATH: `${rig.testDir}:${process.env['PATH'] ?? ''}`, + GEMINI_API_KEY: 'test-key', + }, + }); + + const submitCommand = async (command: string) => { + await run.sendKeys(command); + await new Promise((resolve) => setTimeout(resolve, 75)); + await run.sendKeys('\r'); + }; + + await submitCommand('/voice provider whisper'); + await run.expectText('Voice transcription backend set to: whisper', 10000); + + await submitCommand('/voice enable'); + await run.expectText('Voice input enabled', 10000); + + await run.sendText(' '); + await new Promise((resolve) => setTimeout(resolve, 120)); + await run.sendText(' '); + await run.expectText('Speak now...', 10000); + + await run.sendText('\u001B'); + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(stripAnsi(run.output)).toContain('Speak now...'); + }, 60000); +}); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f190b83be7d..c678c8c7196 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1688,6 +1688,17 @@ Logging in with Google... Restarting Gemini CLI to continue. debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key)); } + if (voice.state.isRecording || voice.state.isTranscribing) { + if (key.name === 'escape') { + void voice.cancelRecording(); + return true; + } + if (keyMatchers[Command.QUIT](key)) { + void voice.cancelRecording(); + return true; + } + } + if (shortcutsHelpVisible && isHelpDismissKey(key)) { setShortcutsHelpVisible(false); } diff --git a/packages/cli/src/ui/commands/voiceCommand.test.ts b/packages/cli/src/ui/commands/voiceCommand.test.ts new file mode 100644 index 00000000000..a5e6c9e64d4 --- /dev/null +++ b/packages/cli/src/ui/commands/voiceCommand.test.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { voiceCommand } from './voiceCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { CommandContext } from './types.js'; +import { MessageType } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; + +describe('voiceCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext({ + ui: { + addItem: vi.fn(), + }, + } as unknown as CommandContext); + }); + + it('enables voice input via /voice enable', async () => { + if (!voiceCommand.action) { + throw new Error('voice command has no action'); + } + + const result = await voiceCommand.action(mockContext, 'enable'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'voice.enabled', + true, + ); + expect(result).toEqual({ + type: 'message', + messageType: MessageType.INFO, + content: + 'Voice input enabled. Double-tap Space on an empty prompt to start recording.', + }); + }); + + it('switches to whisper via /voice provider whisper', async () => { + if (!voiceCommand.action) { + throw new Error('voice command has no action'); + } + + const result = await voiceCommand.action(mockContext, 'provider whisper'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'voice.provider', + 'whisper', + ); + expect(result).toEqual({ + type: 'message', + messageType: MessageType.INFO, + content: 'Voice transcription backend set to: whisper', + }); + }); + + it('shows current voice status for bare /voice', async () => { + if (!voiceCommand.action) { + throw new Error('voice command has no action'); + } + + mockContext.services.settings.merged.voice = { + enabled: true, + provider: 'whisper', + whisperPath: '/usr/local/bin/whisper', + silenceThreshold: 0, + } as never; + + await voiceCommand.action(mockContext, ''); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.VOICE_STATUS, + enabled: true, + provider: 'whisper', + sensitivityLabel: 'disabled', + whisperPath: '/usr/local/bin/whisper', + timestamp: expect.any(Date), + }), + ); + }); +}); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d355a0b1f82..27b428f1a7b 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -360,17 +360,15 @@ export const InputPrompt: React.FC = ({ toggleRecording, cancelRecording, } = useVoiceContext(); - const insertText = buffer.insert; - // Handle voice transcript via event listener (not context) to avoid re-renders useEffect(() => { const handleTranscript = (transcript: string) => { // Insert transcribed text at cursor position with trailing space for next input - insertText(transcript + ' '); + buffer.insert(transcript + ' '); }; return onVoiceTranscript(handleTranscript); - }, [insertText]); + }, [buffer]); const handleSubmitAndClear = useCallback( (submittedValue: string) => { @@ -892,7 +890,7 @@ export const InputPrompt: React.FC = ({ if (keyMatchers[Command.QUIT](key)) { if (voiceState.isRecording) { - void toggleRecording(); + void cancelRecording(); return true; } } diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index f6313d3f91e..70906db35b2 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -78,6 +78,21 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub " `; +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` +" > [Pasted Text: 10 lines] +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` +" > [Pasted Text: 10 lines] +" +`; + +exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` +" > [Pasted Text: 10 lines] +" +`; + exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/hooks/useVoiceInput.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.test.ts index 9324b0ddba3..91db6f4cd56 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.test.ts +++ b/packages/cli/src/ui/hooks/useVoiceInput.test.ts @@ -183,6 +183,29 @@ describe('useVoiceInput', () => { expect(result.current.state.isRecording).toBe(false); }); + it('recovers if backend start throws after setting recording state', async () => { + mockGeminiStart.mockImplementation(async () => { + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + throw new Error('Recorder startup failed'); + }); + + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + await act(async () => { + await result.current.startRecording(); + }); + + expect(result.current.state).toEqual({ + isRecording: false, + isTranscribing: false, + error: 'Recorder startup failed', + }); + }); + it('delegates cancelRecording to the active backend', async () => { mockGeminiCancel.mockResolvedValue(undefined); const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); @@ -200,4 +223,30 @@ describe('useVoiceInput', () => { expect(mockGeminiCancel).not.toHaveBeenCalled(); expect(mockWhisperCancel).not.toHaveBeenCalled(); }); + + it('cancelRecording immediately clears stuck recording state', async () => { + mockGeminiCancel.mockResolvedValue(undefined); + + const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); + + await act(async () => { + void capturedGeminiOptions?.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); + }); + + expect(result.current.state.isRecording).toBe(true); + + await act(async () => { + await result.current.cancelRecording(); + }); + + expect(result.current.state).toEqual({ + isRecording: false, + isTranscribing: false, + error: null, + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useVoiceInput.ts b/packages/cli/src/ui/hooks/useVoiceInput.ts index b1bc711d0bb..61709cc4812 100644 --- a/packages/cli/src/ui/hooks/useVoiceInput.ts +++ b/packages/cli/src/ui/hooks/useVoiceInput.ts @@ -133,7 +133,18 @@ export function useVoiceInput(voiceConfig?: VoiceInputConfig) { return; } debugLogger.debug('useVoiceInput: startRecording'); - await backendRef.current.start(); + try { + await backendRef.current.start(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLogger.error('useVoiceInput: startRecording failed', error); + setState({ + isRecording: false, + isTranscribing: false, + error: message, + }); + coreEvents.emitFeedback('error', message); + } }, []); const stopRecording = useCallback(async () => { @@ -143,6 +154,15 @@ export function useVoiceInput(voiceConfig?: VoiceInputConfig) { }, []); const cancelRecording = useCallback(async () => { + const clearedState = { + isRecording: false, + isTranscribing: false, + error: null, + } satisfies VoiceInputState; + + stateRef.current = clearedState; + setState(clearedState); + if (!backendRef.current) return; await backendRef.current.cancel(); }, []); diff --git a/packages/core/src/services/voice/GeminiRestBackend.test.ts b/packages/core/src/services/voice/GeminiRestBackend.test.ts new file mode 100644 index 00000000000..04d8bbf95bc --- /dev/null +++ b/packages/core/src/services/voice/GeminiRestBackend.test.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GeminiRestBackend } from './GeminiRestBackend.js'; + +const { mockEmitVoiceTranscript } = vi.hoisted(() => ({ + mockEmitVoiceTranscript: vi.fn(), +})); + +vi.mock('../../utils/events.js', () => ({ + coreEvents: { + emitVoiceTranscript: mockEmitVoiceTranscript, + }, +})); + +function createRecordingProcessMock(onClose: () => void) { + let closeHandler: (() => void) | null = null; + + return { + kill: vi.fn(() => closeHandler?.()), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'close') { + closeHandler = () => { + onClose(); + callback(); + }; + } + }), + }; +} + +describe('GeminiRestBackend', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('waits for recorder close before reading buffered audio', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new GeminiRestBackend( + { + onStateChange, + silenceThreshold: 0, + }, + { + getContentGenerator: vi.fn(), + } as never, + ); + + const recordingProcess = createRecordingProcessMock(() => { + ( + backend as unknown as { + audioChunks: Buffer[]; + } + ).audioChunks.push(Buffer.from([1, 0, 2, 0])); + }); + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { audioChunks: Buffer[] }).audioChunks = []; + + const transcribeSpy = vi + .spyOn(backend as never, 'transcribe') + .mockResolvedValue('hello world'); + + await backend.stop(); + + expect(recordingProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(transcribeSpy).toHaveBeenCalledOnce(); + expect(mockEmitVoiceTranscript).toHaveBeenCalledWith('hello world'); + }); + + it('repro: stop remains pending if the recorder never emits close', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new GeminiRestBackend( + { + onStateChange, + silenceThreshold: 0, + }, + { + getContentGenerator: vi.fn(), + } as never, + ); + + const recordingProcess = { + kill: vi.fn(), + once: vi.fn(), + }; + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { audioChunks: Buffer[] }).audioChunks = [ + Buffer.from([1, 0]), + ]; + + let settled = false; + void backend.stop().then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(recordingProcess.once).toHaveBeenCalledWith( + 'close', + expect.any(Function), + ); + expect(recordingProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(settled).toBe(false); + }); + + it('cancel returns immediately even if the recorder never emits close', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new GeminiRestBackend( + { + onStateChange, + silenceThreshold: 0, + }, + { + getContentGenerator: vi.fn(), + } as never, + ); + + const recordingProcess = { + kill: vi.fn(), + once: vi.fn(), + }; + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + + await backend.cancel(); + + expect(recordingProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(onStateChange).toHaveBeenCalledWith({ + isRecording: false, + isTranscribing: false, + error: null, + }); + }); +}); diff --git a/packages/core/src/services/voice/GeminiRestBackend.ts b/packages/core/src/services/voice/GeminiRestBackend.ts index 5c46eaf8481..384b8a324be 100644 --- a/packages/core/src/services/voice/GeminiRestBackend.ts +++ b/packages/core/src/services/voice/GeminiRestBackend.ts @@ -33,6 +33,7 @@ const CHANNELS = 1; export class GeminiRestBackend implements VoiceBackend { private recordingProcess: ReturnType | null = null; private audioChunks: Buffer[] = []; + private stderrChunks: Buffer[] = []; private isStopping = false; constructor( @@ -46,15 +47,12 @@ export class GeminiRestBackend implements VoiceBackend { try { this.isStopping = false; this.audioChunks = []; - void this.options.onStateChange({ - isRecording: true, - isTranscribing: false, - error: null, - }); + this.stderrChunks = []; + let recordingProcess: ReturnType | null = null; const soxPath = await resolveExecutable('sox'); if (soxPath) { - this.recordingProcess = spawn(soxPath, [ + recordingProcess = spawn(soxPath, [ '-d', '-b', '16', @@ -71,7 +69,7 @@ export class GeminiRestBackend implements VoiceBackend { } else { const arecordPath = await resolveExecutable('arecord'); if (arecordPath) { - this.recordingProcess = spawn(arecordPath, [ + recordingProcess = spawn(arecordPath, [ '-f', 'S16_LE', '-r', @@ -92,12 +90,18 @@ export class GeminiRestBackend implements VoiceBackend { } } + this.recordingProcess = recordingProcess; + this.recordingProcess.stdout?.on('data', (chunk: Buffer) => { if (!this.isStopping) { this.audioChunks.push(chunk); } }); + this.recordingProcess.stderr?.on('data', (chunk: Buffer) => { + this.stderrChunks.push(chunk); + }); + this.recordingProcess.on('error', (err) => { void this.options.onStateChange({ isRecording: false, @@ -105,12 +109,19 @@ export class GeminiRestBackend implements VoiceBackend { error: `Recording error: ${err.message}`, }); }); + + void this.options.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); } catch (err) { void this.options.onStateChange({ isRecording: false, isTranscribing: false, error: err instanceof Error ? err.message : String(err), }); + throw err; } } @@ -119,8 +130,9 @@ export class GeminiRestBackend implements VoiceBackend { this.isStopping = true; const proc = this.recordingProcess; this.recordingProcess = null; - proc.kill('SIGINT'); + proc.kill('SIGTERM'); this.audioChunks = []; + this.stderrChunks = []; void this.options.onStateChange({ isRecording: false, isTranscribing: false, @@ -131,14 +143,33 @@ export class GeminiRestBackend implements VoiceBackend { async stop(): Promise { if (!this.recordingProcess) return; - this.isStopping = true; const proc = this.recordingProcess; this.recordingProcess = null; - proc.kill('SIGINT'); + const closePromise = new Promise((resolve) => { + proc.once('close', () => resolve()); + setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch (_e) { + // ignore + } + resolve(); + }, 500); + }); + proc.kill('SIGTERM'); try { + // Wait for the recorder to exit so any final stdout audio chunks flush + // before we build the in-memory WAV payload. + await closePromise; + const audioBuffer = Buffer.concat(this.audioChunks); - if (audioBuffer.length === 0) throw new Error('No audio captured'); + if (audioBuffer.length === 0) { + const stderrStr = Buffer.concat(this.stderrChunks) + .toString('utf8') + .trim(); + throw new Error(`No audio captured. ${stderrStr}`); + } // Reject silent recordings before showing the transcribing state, so // the ⏳ indicator only appears when an actual API call will be made. @@ -147,7 +178,8 @@ export class GeminiRestBackend implements VoiceBackend { void this.options.onStateChange({ isRecording: false, isTranscribing: false, - error: null, + error: + 'Audio discarded (too quiet). Try speaking louder or adjust threshold: /voice sensitivity', }); return; } @@ -267,9 +299,10 @@ export class GeminiRestBackend implements VoiceBackend { async cleanup(): Promise { if (this.recordingProcess) { - this.recordingProcess.kill('SIGINT'); + this.recordingProcess.kill('SIGTERM'); this.recordingProcess = null; } this.audioChunks = []; + this.stderrChunks = []; } } diff --git a/packages/core/src/services/voice/LocalWhisperBackend.test.ts b/packages/core/src/services/voice/LocalWhisperBackend.test.ts index 4247f5e894e..d3316ca9c60 100644 --- a/packages/core/src/services/voice/LocalWhisperBackend.test.ts +++ b/packages/core/src/services/voice/LocalWhisperBackend.test.ts @@ -30,13 +30,16 @@ vi.mock('../../utils/events.js', () => ({ })); function createRecordingProcessMock() { + const registerCloseHandler = vi.fn((event: string, callback: () => void) => { + if (event === 'close') { + callback(); + } + }); + return { kill: vi.fn(), - on: vi.fn((event: string, callback: () => void) => { - if (event === 'close') { - callback(); - } - }), + on: registerCloseHandler, + once: registerCloseHandler, }; } @@ -101,7 +104,8 @@ describe('LocalWhisperBackend', () => { expect(onStateChange).toHaveBeenLastCalledWith({ isRecording: false, isTranscribing: false, - error: null, + error: + 'Audio discarded (too quiet). Try speaking louder or adjust threshold: /voice sensitivity', }); }); @@ -128,4 +132,108 @@ describe('LocalWhisperBackend', () => { expect(transcribeSpy).toHaveBeenCalledWith('/tmp/recording.wav'); expect(mockEmitVoiceTranscript).toHaveBeenCalledWith('transcribed text'); }); + + it('registers the close handler before killing the recorder', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new LocalWhisperBackend({ + onStateChange, + silenceThreshold: 0, + }); + + let closeHandler: (() => void) | null = null; + const recordingProcess = { + kill: vi.fn(() => closeHandler?.()), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'close') { + closeHandler = callback; + } + }), + }; + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { audioFile: string }).audioFile = + '/tmp/recording.wav'; + (backend as unknown as { tempDir: string }).tempDir = '/tmp/voice'; + + mockReadFile.mockResolvedValue(createWavBuffer([0, 1, -1, 2])); + const transcribeSpy = vi + .spyOn(backend as never, 'transcribe') + .mockResolvedValue('transcribed text'); + + await backend.stop(); + + expect(recordingProcess.once).toHaveBeenCalledWith( + 'close', + expect.any(Function), + ); + expect(recordingProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(transcribeSpy).toHaveBeenCalledWith('/tmp/recording.wav'); + }); + + it('repro: stop remains pending if the recorder never emits close', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new LocalWhisperBackend({ + onStateChange, + silenceThreshold: 0, + }); + + const recordingProcess = { + kill: vi.fn(), + once: vi.fn(), + }; + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { audioFile: string }).audioFile = + '/tmp/recording.wav'; + (backend as unknown as { tempDir: string }).tempDir = '/tmp/voice'; + + let settled = false; + void backend.stop().then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(recordingProcess.once).toHaveBeenCalledWith( + 'close', + expect.any(Function), + ); + expect(recordingProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(settled).toBe(false); + }); + + it('repro: cancel remains pending if the recorder never emits close', async () => { + const onStateChange = vi.fn().mockResolvedValue(undefined); + const backend = new LocalWhisperBackend({ + onStateChange, + silenceThreshold: 0, + }); + + const recordingProcess = { + kill: vi.fn(), + once: vi.fn(), + }; + + (backend as unknown as { recordingProcess: unknown }).recordingProcess = + recordingProcess; + (backend as unknown as { tempDir: string }).tempDir = '/tmp/voice'; + + let settled = false; + void backend.cancel().then(() => { + settled = true; + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(recordingProcess.once).toHaveBeenCalledWith( + 'close', + expect.any(Function), + ); + expect(recordingProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(settled).toBe(false); + }); }); diff --git a/packages/core/src/services/voice/LocalWhisperBackend.ts b/packages/core/src/services/voice/LocalWhisperBackend.ts index 383d9fe4a7f..74aab18f450 100644 --- a/packages/core/src/services/voice/LocalWhisperBackend.ts +++ b/packages/core/src/services/voice/LocalWhisperBackend.ts @@ -32,6 +32,7 @@ export class LocalWhisperBackend implements VoiceBackend { private recordingProcess: ReturnType | null = null; private tempDir: string | null = null; private audioFile: string | null = null; + private stderrChunks: Buffer[] = []; constructor( private readonly options: VoiceBackendOptions, @@ -45,18 +46,14 @@ export class LocalWhisperBackend implements VoiceBackend { } try { - void this.options.onStateChange({ - isRecording: true, - isTranscribing: false, - error: null, - }); - + this.stderrChunks = []; this.tempDir = await mkdtemp(join(tmpdir(), 'gemini-voice-')); this.audioFile = join(this.tempDir, `recording.${RECORDING_FORMAT}`); + let recordingProcess: ReturnType | null = null; const soxPath = await resolveExecutable('sox'); if (soxPath) { - this.recordingProcess = spawn(soxPath, [ + recordingProcess = spawn(soxPath, [ '-d', '-b', '16', @@ -73,7 +70,7 @@ export class LocalWhisperBackend implements VoiceBackend { } else { const arecordPath = await resolveExecutable('arecord'); if (arecordPath) { - this.recordingProcess = spawn(arecordPath, [ + recordingProcess = spawn(arecordPath, [ '-f', 'S16_LE', '-r', @@ -91,6 +88,12 @@ export class LocalWhisperBackend implements VoiceBackend { } } + this.recordingProcess = recordingProcess; + + this.recordingProcess.stderr?.on('data', (chunk: Buffer) => { + this.stderrChunks.push(chunk); + }); + this.recordingProcess.on('error', (err) => { void this.options.onStateChange({ isRecording: false, @@ -98,12 +101,20 @@ export class LocalWhisperBackend implements VoiceBackend { error: `Recording error: ${err.message}`, }); }); + + void this.options.onStateChange({ + isRecording: true, + isTranscribing: false, + error: null, + }); } catch (err) { void this.options.onStateChange({ isRecording: false, isTranscribing: false, error: err instanceof Error ? err.message : String(err), }); + await this.cleanup(); + throw err; } } @@ -111,12 +122,24 @@ export class LocalWhisperBackend implements VoiceBackend { if (!this.recordingProcess) return; const proc = this.recordingProcess; this.recordingProcess = null; - proc.kill('SIGINT'); + const closePromise = new Promise((resolve) => { + proc.once('close', () => resolve()); + setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch (_e) { + // ignore + } + resolve(); + }, 500); + }); + proc.kill('SIGTERM'); void this.options.onStateChange({ isRecording: false, isTranscribing: false, error: null, }); + await closePromise; await this.cleanup(); } @@ -125,13 +148,22 @@ export class LocalWhisperBackend implements VoiceBackend { const proc = this.recordingProcess; this.recordingProcess = null; - proc.kill('SIGINT'); + const closePromise = new Promise((resolve) => { + proc.once('close', () => resolve()); + setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch (_e) { + // ignore + } + resolve(); + }, 500); + }); + proc.kill('SIGTERM'); // Wait for the recording process to fully close (stdio streams flushed) // before reading the audio file. - await new Promise((resolve) => { - proc.on('close', resolve); - }); + await closePromise; await this.options.onStateChange({ isRecording: false, @@ -143,15 +175,20 @@ export class LocalWhisperBackend implements VoiceBackend { if (!this.audioFile) throw new Error('No audio file'); const stats = await stat(this.audioFile).catch(() => null); - if (!stats || stats.size === 0) - throw new Error('No audio recorded (file is empty)'); + if (!stats || stats.size === 0) { + const stderrStr = Buffer.concat(this.stderrChunks) + .toString('utf8') + .trim(); + throw new Error(`No audio recorded (file is empty). ${stderrStr}`); + } const audioBuffer = await readFile(this.audioFile); if (this.isSilentWav(audioBuffer)) { void this.options.onStateChange({ isRecording: false, isTranscribing: false, - error: null, + error: + 'Audio discarded (too quiet). Try speaking louder or adjust threshold: /voice sensitivity', }); return; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index c4ed203fe35..7270afde037 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -508,8 +508,10 @@ "provider": { "title": "Transcription Backend", "description": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).", - "markdownDescription": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).\n\n- Category: `General`\n- Requires restart: `no`", - "type": "string" + "markdownDescription": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `gemini`", + "default": "gemini", + "type": "string", + "enum": ["gemini", "whisper"] }, "whisperPath": { "title": "Whisper Binary Path", From a48c73d8c3f01e25b372d2a23fffed89258c2d9f Mon Sep 17 00:00:00 2001 From: fayerman-source Date: Wed, 11 Mar 2026 21:23:31 -0400 Subject: [PATCH 20/37] chore: remove accidentally committed broken ink snapshots and restore all mocks in voice tests --- .../__snapshots__/RewindViewer.test.tsx.snap | 148 ------------------ .../BaseSelectionList.test.tsx.snap | 111 ------------- .../services/voice/GeminiRestBackend.test.ts | 6 +- .../voice/LocalWhisperBackend.test.ts | 6 +- 4 files changed, 10 insertions(+), 261 deletions(-) diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index ff54d49c4d5..06f7d49105f 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -110,43 +110,6 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] " `; -exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; - exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -171,43 +134,6 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` " `; -exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; - exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -232,43 +158,6 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] " `; -exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; - exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ @@ -293,43 +182,6 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = " `; -exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; - exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap index d22a6cad6e4..040b6babeca 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap @@ -16,43 +16,6 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro " `; -exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; - exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = ` "▲ 4. Item 4 @@ -62,43 +25,6 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro " `; -exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; - exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = ` "▲ ● 1. Item 1 @@ -107,40 +33,3 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro ▼ " `; - -exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 2`] = ` -" - ERROR Text string " " must be rendered inside component - - file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 - - - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) - -completeWor - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:9082:42) - -runWithFiberIn - EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:1738:13) - -completeUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve - lopment.js:12962:19) - -performUnitOfW - rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12843:11) - -workLoopSyn - (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm - ent.js:12644:41) - -renderRootSy - c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop - ment.js:12624:11) - -performWorkOnR - ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel - opment.js:12135:44) - -performSyncWorkOn - oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de - velopment.js:2446:7) - -flushSyncWorkAcrossRoo - s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil - er.development.js:2294:21) -" -`; diff --git a/packages/core/src/services/voice/GeminiRestBackend.test.ts b/packages/core/src/services/voice/GeminiRestBackend.test.ts index 04d8bbf95bc..a022fc443b6 100644 --- a/packages/core/src/services/voice/GeminiRestBackend.test.ts +++ b/packages/core/src/services/voice/GeminiRestBackend.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { GeminiRestBackend } from './GeminiRestBackend.js'; const { mockEmitVoiceTranscript } = vi.hoisted(() => ({ @@ -38,6 +38,10 @@ describe('GeminiRestBackend', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('waits for recorder close before reading buffered audio', async () => { const onStateChange = vi.fn().mockResolvedValue(undefined); const backend = new GeminiRestBackend( diff --git a/packages/core/src/services/voice/LocalWhisperBackend.test.ts b/packages/core/src/services/voice/LocalWhisperBackend.test.ts index d3316ca9c60..004e0a67951 100644 --- a/packages/core/src/services/voice/LocalWhisperBackend.test.ts +++ b/packages/core/src/services/voice/LocalWhisperBackend.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LocalWhisperBackend } from './LocalWhisperBackend.js'; const { mockReadFile, mockStat, mockRm, mockEmitVoiceTranscript } = vi.hoisted( @@ -74,6 +74,10 @@ describe('LocalWhisperBackend', () => { mockRm.mockResolvedValue(undefined); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('skips transcription for silent wav recordings when threshold is enabled', async () => { const onStateChange = vi.fn().mockResolvedValue(undefined); const backend = new LocalWhisperBackend({ From 45460447b0881a2b95161f84f82a8e6ca1122066 Mon Sep 17 00:00:00 2001 From: fayerman-source Date: Wed, 11 Mar 2026 21:54:23 -0400 Subject: [PATCH 21/37] fix(voice): handle spaces in whisper path and ensure non-interactive output - Refactor /voice set-path to correctly capture paths with spaces - Add /voice set-path to subcommand tree for discovery/autocomplete - Add text summary to VOICE_HELP and VOICE_STATUS for non-interactive mode visibility --- .../cli/src/ui/commands/voiceCommand.test.ts | 22 +++++ packages/cli/src/ui/commands/voiceCommand.ts | 90 ++++++++++--------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/ui/commands/voiceCommand.test.ts b/packages/cli/src/ui/commands/voiceCommand.test.ts index a5e6c9e64d4..afc655691a2 100644 --- a/packages/cli/src/ui/commands/voiceCommand.test.ts +++ b/packages/cli/src/ui/commands/voiceCommand.test.ts @@ -61,6 +61,28 @@ describe('voiceCommand', () => { }); }); + it('sets whisper path correctly via /voice set-path including spaces', async () => { + if (!voiceCommand.action) { + throw new Error('voice command has no action'); + } + + const result = await voiceCommand.action( + mockContext, + 'set-path C:\\Program Files\\whisper.exe', + ); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'voice.whisperPath', + 'C:\\Program Files\\whisper.exe', + ); + expect(result).toEqual({ + type: 'message', + messageType: MessageType.INFO, + content: 'Whisper binary path set to: C:\\Program Files\\whisper.exe', + }); + }); + it('shows current voice status for bare /voice', async () => { if (!voiceCommand.action) { throw new Error('voice command has no action'); diff --git a/packages/cli/src/ui/commands/voiceCommand.ts b/packages/cli/src/ui/commands/voiceCommand.ts index efe2f221645..6ed9f80d7d9 100644 --- a/packages/cli/src/ui/commands/voiceCommand.ts +++ b/packages/cli/src/ui/commands/voiceCommand.ts @@ -20,12 +20,14 @@ const voiceHelpCommand: SlashCommand = { const item: Omit = { type: MessageType.VOICE_HELP, timestamp: new Date(), + text: 'Voice Input Help:\n- Space Space (on empty prompt): Start/Stop recording\n- Esc (while recording): Cancel\n- /voice: Show current settings', }; context.ui.addItem(item); }, }; const voiceEnableCommand: SlashCommand = { + // ... existing voiceEnableCommand ... name: 'enable', description: 'Enable voice input (double-tap Space on empty input to record)', kind: CommandKind.BUILT_IN, @@ -45,6 +47,7 @@ const voiceEnableCommand: SlashCommand = { }; const voiceDisableCommand: SlashCommand = { + // ... existing voiceDisableCommand ... name: 'disable', description: 'Disable voice input', kind: CommandKind.BUILT_IN, @@ -63,6 +66,7 @@ const voiceDisableCommand: SlashCommand = { }; const voiceProviderCommand: SlashCommand = { + // ... existing voiceProviderCommand ... name: 'provider', description: 'Set transcription backend: gemini (default) or whisper', kind: CommandKind.BUILT_IN, @@ -89,6 +93,7 @@ const voiceProviderCommand: SlashCommand = { }; const voiceSensitivityCommand: SlashCommand = { + // ... existing voiceSensitivityCommand ... name: 'sensitivity', description: 'Set silence threshold (0=off, 80=default, 300+=loud only)', kind: CommandKind.BUILT_IN, @@ -128,6 +133,32 @@ const voiceSensitivityCommand: SlashCommand = { }, }; +const voiceSetPathCommand: SlashCommand = { + name: 'set-path', + description: 'Set the path to the Whisper binary', + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + const path = args?.trim(); + if (!path) { + return { + type: 'message', + messageType: MessageType.ERROR, + content: 'Usage: /voice set-path ', + }; + } + context.services.settings.setValue( + SettingScope.User, + 'voice.whisperPath', + path, + ); + return { + type: 'message', + messageType: MessageType.INFO, + content: `Whisper binary path set to: ${path}`, + }; + }, +}; + export const voiceCommand: SlashCommand = { name: 'voice', description: 'Configure voice input settings', @@ -138,52 +169,21 @@ export const voiceCommand: SlashCommand = { voiceDisableCommand, voiceProviderCommand, voiceSensitivityCommand, + voiceSetPathCommand, ], action: async (context, args) => { const trimmedArgs = args?.trim() || ''; - const parts = trimmedArgs.split(/\s+/); - const subCommand = parts[0]?.toLowerCase(); + if (trimmedArgs) { + const parts = trimmedArgs.split(/\s+/); + const subCommandName = parts[0]?.toLowerCase(); + const subCommandArgs = trimmedArgs.slice(parts[0].length).trim(); - if (subCommand === 'enable') { - return voiceEnableCommand.action!(context, parts.slice(1).join(' ')); - } - - if (subCommand === 'disable') { - return voiceDisableCommand.action!(context, parts.slice(1).join(' ')); - } - - if (subCommand === 'provider') { - return voiceProviderCommand.action!(context, parts.slice(1).join(' ')); - } - - if (subCommand === 'sensitivity') { - return voiceSensitivityCommand.action!(context, parts.slice(1).join(' ')); - } - - // /voice set-path — functional but not in autocomplete hints - if (subCommand === 'set-path') { - const path = parts[1]; - if (!path) { - return { - type: 'message', - messageType: MessageType.ERROR, - content: 'Usage: /voice set-path ', - }; - } - context.services.settings.setValue( - SettingScope.User, - 'voice.whisperPath', - path, + const subCommand = voiceCommand.subCommands?.find( + (sc) => sc.name === subCommandName, ); - return { - type: 'message', - messageType: MessageType.INFO, - content: `Whisper binary path set to: ${path}`, - }; - } - - if (subCommand === 'help') { - return voiceHelpCommand.action!(context, ''); + if (subCommand?.action) { + return subCommand.action(context, subCommandArgs); + } } // Default: show current voice settings status @@ -201,6 +201,13 @@ export const voiceCommand: SlashCommand = { ? `${threshold} (moderate)` : `${threshold} (loud speech only)`; + const statusText = + `Voice Settings:\n` + + `- Enabled: ${enabled}\n` + + `- Provider: ${provider}\n` + + `- Sensitivity: ${sensitivityLabel}\n` + + `- Whisper Path: ${whisperPath}`; + const statusItem: Omit = { type: MessageType.VOICE_STATUS, timestamp: new Date(), @@ -208,6 +215,7 @@ export const voiceCommand: SlashCommand = { provider, sensitivityLabel, whisperPath, + text: statusText, }; context.ui.addItem(statusItem); return; From 656531b50d2495214852a2aa7451f86c0bd2a612 Mon Sep 17 00:00:00 2001 From: fayerman-source Date: Wed, 11 Mar 2026 22:06:53 -0400 Subject: [PATCH 22/37] fix(cli): print voice slash command output in headless mode --- packages/cli/src/nonInteractiveCli.test.ts | 40 +++++++++++++++++++ .../src/ui/noninteractive/nonInteractiveUi.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index a2db84aec44..cffcf69f0ec 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -25,6 +25,7 @@ import { } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; +import { voiceCommand } from './ui/commands/voiceCommand.js'; import { describe, it, @@ -1392,6 +1393,45 @@ describe('runNonInteractive', () => { expect(getWrittenOutput()).toBe('Acknowledged\n'); }); + it('should print /voice status in non-interactive mode', async () => { + mockSettings.merged.voice = { + enabled: true, + provider: 'whisper', + whisperPath: '/usr/local/bin/whisper', + silenceThreshold: 0, + } as never; + mockGetCommands.mockReturnValue([voiceCommand]); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/voice', + prompt_id: 'prompt-id-voice-status', + }); + + expect(getWrittenOutput()).toContain('Voice Settings:'); + expect(getWrittenOutput()).toContain('- Provider: whisper'); + expect(getWrittenOutput()).toContain( + '- Whisper Path: /usr/local/bin/whisper', + ); + }); + + it('should print /voice help in non-interactive mode', async () => { + mockGetCommands.mockReturnValue([voiceCommand]); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/voice help', + prompt_id: 'prompt-id-voice-help', + }); + + expect(getWrittenOutput()).toContain('Voice Input Help:'); + expect(getWrittenOutput()).toContain( + 'Space Space (on empty prompt): Start/Stop recording', + ); + }); + it('should instantiate CommandService with correct loaders for slash commands', async () => { // This test indirectly checks that handleSlashCommand is using the right loaders. const { FileCommandLoader } = await import( diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index f18fee2d17f..de692a34128 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -20,7 +20,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { process.stderr.write(`Error: ${item.text}\n`); } else if (item.type === 'warning') { process.stderr.write(`Warning: ${item.text}\n`); - } else if (item.type === 'info') { + } else { process.stdout.write(`${item.text}\n`); } } From 96bee19503872123afe326b237fcece041302451 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 17 Mar 2026 21:57:25 -0400 Subject: [PATCH 23/37] fix(voice): make cancellation non-blocking and add SIGKILL fallback --- .../src/services/voice/GeminiRestBackend.ts | 18 ++++++++++++++++++ .../src/services/voice/LocalWhisperBackend.ts | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/voice/GeminiRestBackend.ts b/packages/core/src/services/voice/GeminiRestBackend.ts index 384b8a324be..98e0469b1f5 100644 --- a/packages/core/src/services/voice/GeminiRestBackend.ts +++ b/packages/core/src/services/voice/GeminiRestBackend.ts @@ -130,6 +130,20 @@ export class GeminiRestBackend implements VoiceBackend { this.isStopping = true; const proc = this.recordingProcess; this.recordingProcess = null; + + // Ensure the process is terminated, even if it ignores SIGTERM. + const closePromise = new Promise((resolve) => { + proc.once('close', () => resolve()); + setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch (_e) { + // ignore + } + resolve(); + }, 500); + }); + proc.kill('SIGTERM'); this.audioChunks = []; this.stderrChunks = []; @@ -138,11 +152,15 @@ export class GeminiRestBackend implements VoiceBackend { isTranscribing: false, error: null, }); + + // Don't block the cancel call. + void closePromise.then(() => {}); } async stop(): Promise { if (!this.recordingProcess) return; + this.isStopping = true; const proc = this.recordingProcess; this.recordingProcess = null; const closePromise = new Promise((resolve) => { diff --git a/packages/core/src/services/voice/LocalWhisperBackend.ts b/packages/core/src/services/voice/LocalWhisperBackend.ts index 74aab18f450..5c3a5465d49 100644 --- a/packages/core/src/services/voice/LocalWhisperBackend.ts +++ b/packages/core/src/services/voice/LocalWhisperBackend.ts @@ -122,6 +122,7 @@ export class LocalWhisperBackend implements VoiceBackend { if (!this.recordingProcess) return; const proc = this.recordingProcess; this.recordingProcess = null; + const closePromise = new Promise((resolve) => { proc.once('close', () => resolve()); setTimeout(() => { @@ -133,14 +134,16 @@ export class LocalWhisperBackend implements VoiceBackend { resolve(); }, 500); }); + proc.kill('SIGTERM'); void this.options.onStateChange({ isRecording: false, isTranscribing: false, error: null, }); - await closePromise; - await this.cleanup(); + + // Don't block the cancel call. Perform cleanup in the background. + void closePromise.then(() => this.cleanup()); } async stop(): Promise { From c0763b5370ed73de5893c46e0356d27e1bc72eef Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Tue, 17 Mar 2026 21:57:55 -0400 Subject: [PATCH 24/37] fix(cli): missing FinishReason members --- packages/cli/src/ui/hooks/useGeminiStream.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c394b866ad7..7e013bcb7f5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1083,6 +1083,9 @@ export const useGeminiStream = ( 'Response stopped due to prohibited image content.', [FinishReason.NO_IMAGE]: 'Response stopped because no image was generated.', + [FinishReason.IMAGE_RECITATION]: + 'Response stopped due to image recitation policy.', + [FinishReason.IMAGE_OTHER]: 'Response stopped for other image reasons.', }; const message = finishReasonMessages[finishReason]; From 3078d2af3d178aab33754a398d2adb04247cb5e1 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Thu, 19 Mar 2026 19:52:53 -0400 Subject: [PATCH 25/37] fix(cli): resolve FinishReason build error and missing bundle dependencies --- PR_STATUS_MEMO.md | 394 ++ actual_pr.diff | 3299 ++++++++++++++ evals_output.log | 40 + ...-cli-0.30.0-nightly.20260210.a2174751d.tgz | Bin 0 -> 6798405 bytes package-lock.json | 34 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 3 - pr_18499.diff | 3805 +++++++++++++++++ run-voice-tests.sh | 8 + scripts/copy_bundle_assets.js | 8 + 9 files changed, 7585 insertions(+), 6 deletions(-) create mode 100644 PR_STATUS_MEMO.md create mode 100644 actual_pr.diff create mode 100644 evals_output.log create mode 100644 google-gemini-cli-0.30.0-nightly.20260210.a2174751d.tgz create mode 100644 pr_18499.diff create mode 100755 run-voice-tests.sh diff --git a/PR_STATUS_MEMO.md b/PR_STATUS_MEMO.md new file mode 100644 index 00000000000..2fa0a5e3694 --- /dev/null +++ b/PR_STATUS_MEMO.md @@ -0,0 +1,394 @@ +# PR #18499 Status Memo + +**Last Updated**: 2026-02-13 22:44 EST + +## Current Status: ✅ READY FOR REVIEW + +### PR Details + +- **Number**: #18499 +- **Title**: "feat: Add native voice input support with Whisper integration" +- **Linked Issue**: #18067 ("Feature Proposal: Unified Native Voice Input + Architecture") +- **Branch**: `fayerman-source:feat/voice-input-clean` → `google-gemini:main` +- **State**: OPEN, MERGEABLE +- **Labels**: `priority/p2`, `area/core`, `help wanted` +- **Demo**: https://youtu.be/pDo0v6eTCfw +- **Latest Commit**: `c6c73294c` (Feb 13, 10:09 PM) + +### Completion Checklist + +- ✅ Issue created first (#18067, Feb 1) +- ✅ PR properly linked ("Closes #18067") +- ✅ Security vulnerabilities fixed (command injection) +- ✅ All code review feedback addressed +- ✅ Tests passing (comprehensive test suite added) +- ✅ Merge conflicts resolved (Feb 10, Feb 12, Feb 13) +- ✅ Documentation included +- ✅ Follows CONTRIBUTING.md guidelines +- ✅ CLA signed +- ✅ Feature tested and working locally + +### Technical Completeness + +- ✅ Event-based architecture (no render loops) +- ✅ Race condition protection +- ✅ Cross-platform support (sox/arecord) +- ✅ Multiple Whisper backends supported +- ✅ Security hardened (execFile, path validation) +- ✅ Multilingual auto-detection +- ✅ Comprehensive error handling + +### Timeline + +- **Feb 1**: Issue #18067 created +- **Feb 1**: Initial PR submitted +- **Feb 7**: Security fixes applied (commit ce4b23c) +- **Feb 7**: Code review iterations completed +- **Feb 7**: Last maintainer activity (7 days ago) +- **Feb 10**: Merge conflicts resolved (commit c6a9409) +- **Feb 11**: Bot review posted (found 2 high-severity issues) +- **Feb 12**: Merge conflicts resolved again (commit 5881c3e) +- **Feb 13**: Merge conflicts resolved third time (commit c6c73294) +- **Feb 13**: Local installation configured via npm link + +## Recent Session (Feb 13, 10:00 PM - 10:44 PM) + +### Problem Discovered + +- PR was reverted to old buggy version (commit `4bed5dd07`) +- Infinite render loop bug reappeared +- Build errors from missing exports + +### Actions Taken + +1. **Restored correct version** from reflog (commit `5881c3e43`) +2. **Merged latest upstream/main** (177 new commits since Feb 7) +3. **Resolved 4 conflicts**: + - `keyBindings.ts` - Updated SUSPEND_APP description, kept voice input + - `AppContainer.tsx` - Added VoiceContext wrapper + forceRerenderKey + - `InputPrompt.tsx` - Added voice imports + double-tab functionality + - Snapshot file - Accepted upstream version +4. **Fixed build errors** - Rebuilt all packages +5. **Investigated Debug Console warning** - "5 frames rendered while idle" + - Warning appeared initially, disappeared after restart + - Likely transient development artifact + - Feature works correctly (Alt+R tested) +6. **Configured local installation** - `npm link` for global `gemini` command + +### Current Local Setup + +- **Branch**: `feat/voice-input-clean` (matches GitHub exactly) +- **Commit**: `c6c73294c` (same as PR) +- **Build**: Clean, up-to-date +- **Whisper**: Installed in `~/.gemini/whisper-venv/bin/whisper` +- **Settings**: `~/.gemini/settings.json` has `voice.whisperPath` configured +- **Global command**: `gemini` available via npm link + +### Debug Console Warning Analysis + +**Warning**: "5 frames rendered while the app was idle in the past second" + +**Investigation findings**: + +- Warning comes from `DebugProfiler.tsx` (upstream code) +- Triggers when 5+ frames render without user action +- Voice state changes (recording → transcribing → idle) happen without user + input +- InputPrompt re-renders to show voice status (🎤 Recording, Transcribing) +- Warning appeared once, then disappeared after restart +- **Conclusion**: Likely transient development artifact, not a real bug + +**Why it's not a problem**: + +- Feature works correctly +- Warning didn't reappear after restart +- InputPrompt needs to re-render to show voice status (intentional) +- PR code is identical to local code (verified) + +## What "help wanted" Label Means + +**Confirmed from CONTRIBUTING.md**: + +> "In the near future, we will explicitly mark issues looking for contributions +> using the `help-wanted` label." + +**Interpretation**: + +- Feature is wanted by maintainers +- Community should drive it (not Google staff priority) +- Original author maintains through review process +- Does NOT mean "recruit more contributors" + +**Context**: PR is ONLY recent PR with "help wanted" label = positive signal + +## Review Process Expectations + +**From CONTRIBUTING.md**: + +- No explicit timeline specified +- P2 priority = not urgent +- Large repo (93.9k stars) = longer review cycles +- Community PRs reviewed "when bandwidth allows" + +**Normal Timeline**: 1-2 weeks for P2 community features + +## Action Plan + +### Immediate (Feb 13-17) + +- ✅ Merge conflicts resolved (third time) +- ✅ Local installation configured +- ⏳ Wait for maintainer review (4 more days) +- 📊 Monitor PR daily for comments +- ⚡ Be ready to respond quickly + +### If No Response by Feb 17 + +- 💬 Polite ping in PR comments: + ``` + Hi team! This PR is ready for review - all security issues addressed, + tests passing, conflicts resolved. Happy to make any changes needed. + Let me know if you need anything else! + ``` + +### If No Response by Feb 24 (2 weeks) + +- 🔍 Check for Discord/Slack community +- 📝 Ask about review timeline +- 📖 Re-check CONTRIBUTING.md for updates + +### Only If Maintainers Request + +- Cross-platform testing help +- Additional feature work +- Documentation improvements + +## What NOT To Do + +❌ Don't recruit additional contributors (premature, creates confusion) ❌ Don't +post in forums asking for help (PR is technically complete) ❌ Don't make +unsolicited changes (wait for maintainer feedback) ❌ Don't ping before Feb 17 +(respect normal review cycle) + +## Key Insight + +**The PR is not blocked technically - it's in the normal review queue.** + +"Help wanted" + P2 + area/core = "Good feature, community should own it, we'll +review when bandwidth allows" + +**Your job**: Respond quickly to maintainer feedback when it arrives. + +## Branch History + +### Why Two Branches? + +- `feat/voice-input` - Original working branch with voice commits +- `feat/voice-input-clean` - Clean branch for PR (created for clean commit + history) + +**Current PR uses**: `feat/voice-input-clean` + +### Merge Conflict Pattern + +Conflicts keep occurring in `AppContainer.tsx` because: + +- It's a hot file (many PRs modify it) +- Specifically the `useCallback` dependency array around line 1677 +- Upstream is very active (177 commits merged since Feb 7) + +**Resolution strategy**: Merge upstream/main regularly, resolve conflicts +quickly + +## Local Development Setup + +### Prerequisites + +- Node.js ~20.19.0 (development) or >=20 (production) +- Whisper installed in venv: `~/.gemini/whisper-venv/bin/whisper` +- Settings configured: `~/.gemini/settings.json` + +### Build Commands + +```bash +cd /home/fayerman/gemini-cli + +# Clean build +npm run clean +npm install +npm run build + +# Start development +npm start + +# Or use global command (after npm link) +gemini +``` + +### Testing Voice Input + +1. Start gemini: `gemini` or `npm start` +2. Press `Alt+R` to start recording +3. Speak something +4. Press `Alt+R` to stop and transcribe +5. Transcript appears in input + +**Alternative shortcuts**: `Ctrl+Q` or `/voice` command + +### Installing on Other Machines + +**Option 1: npm link (development)** + +```bash +cd /path/to/gemini-cli +npm install +npm run build +npm link +``` + +**Option 2: Install from fork** + +```bash +npm install -g git+https://github.com/fayerman-source/gemini-cli.git#feat/voice-input-clean +``` + +**Option 3: Tarball (portable)** + +```bash +cd /home/fayerman/gemini-cli +npm pack +# Creates: google-gemini-cli-0.30.0-nightly.20260210.a2174751d.tgz + +# On other machines: +npm install -g /path/to/google-gemini-cli-0.30.0-nightly.20260210.a2174751d.tgz +``` + +### Updating After Changes + +```bash +cd /home/fayerman/gemini-cli +npm run build +# Changes automatically reflected (npm link uses symlink) +``` + +## Troubleshooting + +### "5 frames rendered while idle" Warning + +- **Symptom**: Debug Console auto-opens with error +- **Cause**: DebugProfiler detecting voice state changes +- **Solution**: Restart gemini - warning is transient +- **Status**: Not a real bug, feature works correctly + +### Build Errors (Missing Exports) + +- **Symptom**: "No matching export for CoreToolCallStatus" +- **Cause**: Packages not rebuilt after merge +- **Solution**: `npm run clean && npm install && npm run build` + +### Whisper Not Found + +- **Symptom**: Voice input fails, "whisper not found" +- **Cause**: Venv not activated or path not configured +- **Solution**: Check `~/.gemini/settings.json` has correct `voice.whisperPath` + +### Merge Conflicts + +- **Symptom**: PR shows "CONFLICTING" +- **Cause**: Upstream moved forward +- **Solution**: + ```bash + git fetch upstream + git merge upstream/main + # Resolve conflicts + git push origin feat/voice-input-clean + ``` + +## Quick Reference + +**Check PR Status**: + +```bash +gh pr view 18499 --repo google-gemini/gemini-cli --json mergeable,reviewDecision,state +``` + +**View Recent Activity**: + +```bash +gh pr view 18499 --repo google-gemini/gemini-cli +``` + +**Check for Comments**: + +```bash +gh pr view 18499 --repo google-gemini/gemini-cli --comments +``` + +**Verify Local Matches Remote**: + +```bash +cd /home/fayerman/gemini-cli +git diff origin/feat/voice-input-clean HEAD +# Should be empty +``` + +## Files Modified in PR + +### Core Implementation + +- `packages/cli/src/ui/hooks/useVoiceInput.ts` - Voice hook with event-based + delivery +- `packages/cli/src/ui/contexts/VoiceContext.tsx` - Context provider +- `packages/cli/src/ui/components/InputPrompt.tsx` - Voice indicator integration +- `packages/cli/src/ui/commands/voiceCommand.ts` - `/voice` slash command +- `packages/cli/src/settings/setting-definitions.ts` - `whisperPath` setting +- `packages/cli/src/ui/keyMatchers.ts` - `Alt+R` and `Ctrl+Q` keybindings + +### Documentation + +- `docs/cli/keyboard-shortcuts.md` - Voice input shortcuts +- `docs/cli/settings.md` - Voice settings +- `docs/get-started/configuration.md` - Voice configuration + +### Tests + +- `packages/cli/src/ui/hooks/useVoiceInput.test.ts` - Comprehensive test suite +- `packages/cli/src/ui/contexts/VoiceContext.test.tsx` - Context tests + +### Configuration + +- `packages/cli/src/config/keyBindings.ts` - Key binding definitions +- `packages/cli/src/config/settingsSchema.ts` - Settings schema +- `schemas/settings.schema.json` - JSON schema + +## Bot Review Findings (Feb 11) + +**Status**: 2 high-severity issues identified by gemini-code-assist bot + +**Issues**: + +1. Platform portability concern in `useVoiceInput` hook +2. Potential race condition in core hook + +**Bot's Assessment**: "This is a strong contribution... well-architected voice +input feature" + +**Status**: Not yet addressed - waiting for maintainer review to confirm if +these need fixing + +## Social Media Strategy (Post-Merge) + +**Ready but not posted** - waiting for merge: + +- Twitter thread (8 tweets, X/8 format) with beginner setup guide +- LinkedIn post with problem→solution format +- All posts include actionable steps and links to repo/demo + +**User's principle**: Posts must provide immediate user benefit with actionable +steps + +--- + +**BLOCKING ISSUE**: None - PR is ready for review. Just waiting for maintainer +bandwidth. diff --git a/actual_pr.diff b/actual_pr.diff new file mode 100644 index 00000000000..fbc819f0f3b --- /dev/null +++ b/actual_pr.diff @@ -0,0 +1,3299 @@ +diff --git a/docs/cli/settings.md b/docs/cli/settings.md +index 37508fc04eb..a66cd5f519b 100644 +--- a/docs/cli/settings.md ++++ b/docs/cli/settings.md +@@ -75,6 +75,15 @@ they appear in the UI. + | Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` | + | Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` | + ++### Voice ++ ++| UI Label | Setting | Description | Default | ++| --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ++| Voice Input | `voice.enabled` | Enable voice input. When enabled, double-tap Space on an empty prompt to start/stop recording. | `false` | ++| Transcription Backend | `voice.provider` | Transcription backend to use: `gemini` (zero-install, uses existing Gemini API auth) or `whisper` (local binary). | `"gemini"` | ++| Silence Detection | `voice.silenceThreshold` | RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values capture quieter speech (e.g. whispering). `0` disables silence detection. | `80` | ++| Whisper Binary Path | `voice.whisperPath` | Path to the Whisper executable. Only used when `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper`). | `undefined` | ++ + ### IDE + + | UI Label | Setting | Description | Default | +diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md +index 82ee987eb2e..0fc590c41ce 100644 +--- a/docs/reference/configuration.md ++++ b/docs/reference/configuration.md +@@ -340,6 +340,33 @@ their corresponding top-level category object in your `settings.json` file. + - **Default:** `false` + - **Requires restart:** Yes + ++#### `voice` ++ ++- **`voice.enabled`** (boolean): ++ - **Description:** Enable voice input. When enabled, double-tap Space on an ++ empty prompt to start/stop recording. Use `/voice enable` or ++ `/voice disable` to toggle. ++ - **Default:** `false` ++ ++- **`voice.provider`** (string: `"gemini"` | `"whisper"`): ++ - **Description:** Transcription backend. `gemini` uses the Gemini API with ++ your existing auth (zero additional setup). `whisper` uses a locally ++ installed Whisper binary for offline/faster transcription. ++ - **Default:** `"gemini"` ++ ++- **`voice.silenceThreshold`** (number, 0–1000): ++ - **Description:** RMS energy threshold for silence detection. Audio below ++ this level is discarded without an API call. Lower values capture quieter ++ speech (e.g. whispering). Set to `0` to disable silence detection and always ++ transcribe. Use `/voice sensitivity ` to adjust at runtime. ++ - **Default:** `80` ++ ++- **`voice.whisperPath`** (string): ++ - **Description:** Path to the Whisper executable. Only used when ++ `voice.provider` is `"whisper"` (e.g. `/usr/local/bin/whisper` or ++ `~/.local/bin/whisper`). Use `/voice set-path ` to set at runtime. ++ - **Default:** `undefined` ++ + #### `ide` + + - **`ide.enabled`** (boolean): +diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md +index e5691c43ee2..a1409f3936c 100644 +--- a/docs/reference/keyboard-shortcuts.md ++++ b/docs/reference/keyboard-shortcuts.md +@@ -94,6 +94,12 @@ available combinations. + | Open the current prompt or the plan in an external editor. | `Ctrl + X` | + | Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | + ++#### Voice Input ++ ++| Action | Keys | ++| ---------------------------------------------------- | ------------- | ++| Start voice recording (double-space on empty input). | `Space Space` | ++ + #### App Controls + + | Action | Keys | +diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts +index 3122acef1d9..4d7107be946 100644 +--- a/packages/cli/src/config/keyBindings.ts ++++ b/packages/cli/src/config/keyBindings.ts +@@ -97,6 +97,9 @@ export enum Command { + CLEAR_SCREEN = 'app.clearScreen', + RESTART_APP = 'app.restart', + SUSPEND_APP = 'app.suspend', ++ ++ // Voice Input ++ VOICE_INPUT = 'input.voice', + } + + /** +@@ -297,6 +300,9 @@ export const defaultKeyBindings: KeyBindingConfig = { + [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], + [Command.RESTART_APP]: [{ key: 'r' }], + [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], ++ ++ // Voice Input — triggered by double-space on empty input (see InputPrompt.tsx) ++ [Command.VOICE_INPUT]: [], + }; + + interface CommandCategory { +@@ -391,6 +397,10 @@ export const commandCategories: readonly CommandCategory[] = [ + Command.PASTE_CLIPBOARD, + ], + }, ++ { ++ title: 'Voice Input', ++ commands: [Command.VOICE_INPUT], ++ }, + { + title: 'App Controls', + commands: [ +@@ -525,4 +535,8 @@ export const commandDescriptions: Readonly> = { + [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', + [Command.RESTART_APP]: 'Restart the application.', + [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.', ++ ++ // Voice Input ++ [Command.VOICE_INPUT]: ++ 'Toggle voice input recording (press space twice rapidly).', + }; +diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts +index fb0520d3349..15ba6fbf5e7 100644 +--- a/packages/cli/src/config/settingsSchema.ts ++++ b/packages/cli/src/config/settingsSchema.ts +@@ -774,6 +774,58 @@ const SETTINGS_SCHEMA = { + }, + }, + ++ voice: { ++ type: 'object', ++ label: 'Voice Input', ++ category: 'General', ++ requiresRestart: false, ++ default: {}, ++ description: 'Settings for voice input.', ++ showInDialog: false, ++ properties: { ++ enabled: { ++ type: 'boolean', ++ label: 'Enable Voice Input', ++ category: 'General', ++ requiresRestart: false, ++ default: false, ++ description: 'Enable voice input support.', ++ showInDialog: true, ++ }, ++ provider: { ++ type: 'string', ++ label: 'Transcription Backend', ++ category: 'General', ++ requiresRestart: false, ++ default: undefined as string | undefined, ++ description: ++ 'Transcription backend: "gemini" (default, zero-install) or "whisper" (local).', ++ showInDialog: true, ++ }, ++ whisperPath: { ++ type: 'string', ++ label: 'Whisper Binary Path', ++ category: 'General', ++ requiresRestart: false, ++ default: undefined as string | undefined, ++ description: ++ 'Path to the whisper executable. Only used when provider is "whisper".', ++ showInDialog: true, ++ }, ++ silenceThreshold: { ++ type: 'number', ++ label: 'Silence Detection Threshold', ++ category: 'General', ++ requiresRestart: false, ++ default: 80, ++ description: ++ 'RMS energy threshold (0–1000) below which audio is discarded as silence. ' + ++ 'Lower values allow quieter speech such as whispering. 0 disables silence detection.', ++ showInDialog: true, ++ }, ++ }, ++ }, ++ + ide: { + type: 'object', + label: 'IDE', +diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts +index c2cab72353b..95257702a0f 100644 +--- a/packages/cli/src/nonInteractiveCli.ts ++++ b/packages/cli/src/nonInteractiveCli.ts +@@ -247,11 +247,12 @@ export async function runNonInteractive({ + settings, + ); + // If a slash command is found and returns a prompt, use it. +- // Otherwise, slashCommandResult falls through to the default prompt +- // handling. ++ // Otherwise, if it was a slash command, we are done. + if (slashCommandResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + query = slashCommandResult as Part[]; ++ } else { ++ return; + } + } + +diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts +index e09db71312b..8e764942efc 100644 +--- a/packages/cli/src/nonInteractiveCliCommands.ts ++++ b/packages/cli/src/nonInteractiveCliCommands.ts +@@ -90,6 +90,11 @@ export const handleSlashCommand = async ( + switch (result.type) { + case 'submit_prompt': + return result.content; ++ case 'message': { ++ const prefix = result.messageType?.toUpperCase() || 'INFO'; ++ process.stdout.write(`[${prefix}] ${result.content}\n`); ++ return; ++ } + case 'confirm_shell_commands': + // This result indicates a command attempted to confirm shell commands. + // However note that currently, ShellTool is excluded in non-interactive +diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts +index 31673e921a0..1cdb18ed26d 100644 +--- a/packages/cli/src/services/BuiltinCommandLoader.ts ++++ b/packages/cli/src/services/BuiltinCommandLoader.ts +@@ -58,6 +58,7 @@ import { shellsCommand } from '../ui/commands/shellsCommand.js'; + import { vimCommand } from '../ui/commands/vimCommand.js'; + import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; + import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; ++import { voiceCommand } from '../ui/commands/voiceCommand.js'; + + /** + * Loads the core, hard-coded slash commands that are an integral part +@@ -73,7 +74,10 @@ export class BuiltinCommandLoader implements ICommandLoader { + * @param _signal An AbortSignal (unused for this synchronous loader). + * @returns A promise that resolves to an array of `SlashCommand` objects. + */ +- async loadCommands(_signal: AbortSignal): Promise { ++ async loadCommands(signal: AbortSignal): Promise { ++ if (signal.aborted) { ++ return []; ++ } + const handle = startupProfiler.start('load_builtin_commands'); + + const isNightlyBuild = await isNightly(process.cwd()); +@@ -185,6 +189,7 @@ export class BuiltinCommandLoader implements ICommandLoader { + vimCommand, + setupGithubCommand, + terminalSetupCommand, ++ voiceCommand, + ]; + handle?.end(); + return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); +diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx +index 86c46e79e51..a3578ae578a 100644 +--- a/packages/cli/src/test-utils/render.tsx ++++ b/packages/cli/src/test-utils/render.tsx +@@ -42,6 +42,8 @@ import { + type OverflowActions, + type OverflowState, + } from '../ui/contexts/OverflowContext.js'; ++import { VoiceContext } from '../ui/contexts/VoiceContext.js'; ++import type { VoiceInputReturn } from '../ui/hooks/useVoiceInput.js'; + + import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; + import { FakePersistentState } from './persistentStateFake.js'; +@@ -563,6 +565,19 @@ export const mockAppState: AppState = { + startupWarnings: [], + }; + ++const mockVoiceReturn: VoiceInputReturn = { ++ isEnabled: true, ++ state: { ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }, ++ startRecording: vi.fn(async () => {}), ++ stopRecording: vi.fn(async () => {}), ++ cancelRecording: vi.fn(async () => {}), ++ toggleRecording: vi.fn(async () => {}), ++}; ++ + const mockUIActions: UIActions = { + handleThemeSelect: vi.fn(), + closeThemeDialog: vi.fn(), +@@ -646,6 +661,7 @@ export const renderWithProviders = ( + uiActions, + persistentState, + appState = mockAppState, ++ voice = mockVoiceReturn, + }: { + shellFocus?: boolean; + settings?: LoadedSettings; +@@ -660,6 +676,7 @@ export const renderWithProviders = ( + set?: typeof persistentStateMock.set; + }; + appState?: AppState; ++ voice?: VoiceInputReturn; + } = {}, + ): RenderInstance & { + simulateClick: ( +@@ -768,32 +785,34 @@ export const renderWithProviders = ( + config={finalConfig} + toolCalls={allToolCalls} + > +- +- +- +- +- +- +- +- {component} +- +- +- +- +- +- +- ++ ++ ++ ++ ++ ++ ++ ++ ++ {component} ++ ++ ++ ++ ++ ++ ++ ++ + + + +diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx +index d656169c518..27aed563374 100644 +--- a/packages/cli/src/ui/AppContainer.tsx ++++ b/packages/cli/src/ui/AppContainer.tsx +@@ -22,6 +22,7 @@ import { + } from 'ink'; + import { App } from './App.js'; + import { AppContext } from './contexts/AppContext.js'; ++import { VoiceContext } from './contexts/VoiceContext.js'; + import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; + import { + UIActionsContext, +@@ -117,6 +118,7 @@ import { useVim } from './hooks/vim.js'; + import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; + import { type InitializationResult } from '../core/initializer.js'; + import { useFocus } from './hooks/useFocus.js'; ++import { useVoiceInput, type VoiceInputConfig } from './hooks/useVoiceInput.js'; + import { useKeypress, type Key } from './hooks/useKeypress.js'; + import { KeypressPriority } from './contexts/KeypressContext.js'; + import { keyMatchers, Command } from './keyMatchers.js'; +@@ -224,6 +226,28 @@ export const AppContainer = (props: AppContainerProps) => { + const { reset } = useOverflowActions()!; + const notificationsEnabled = isNotificationsEnabled(settings); + ++ const rawProvider = settings.merged.voice?.provider; ++ const voiceConfig = useMemo( ++ () => ({ ++ provider: ++ rawProvider === 'gemini' || rawProvider === 'whisper' ++ ? rawProvider ++ : undefined, ++ whisperPath: settings.merged.voice?.whisperPath, ++ silenceThreshold: settings.merged.voice?.silenceThreshold, ++ config, ++ }), ++ [ ++ rawProvider, ++ settings.merged.voice?.whisperPath, ++ settings.merged.voice?.silenceThreshold, ++ config, ++ ], ++ ); ++ const voice = useVoiceInput( ++ settings.merged.voice?.enabled ? voiceConfig : undefined, ++ ); ++ + const historyManager = useHistory({ + chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), + }); +@@ -921,6 +945,7 @@ Logging in with Google... Restarting Gemini CLI to continue. + }, + toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible), + setText: stableSetText, ++ toggleVoice: voice.toggleRecording, + }), + [ + setAuthState, +@@ -940,6 +965,7 @@ Logging in with Google... Restarting Gemini CLI to continue. + toggleDebugProfiler, + setShortcutsHelpVisible, + stableSetText, ++ voice, + ], + ); + +@@ -1741,6 +1767,9 @@ Logging in with Google... Restarting Gemini CLI to continue. + } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { + setShowFullTodos((prev) => !prev); + return true; ++ } else if (keyMatchers[Command.VOICE_INPUT](key)) { ++ void voice.toggleRecording(); ++ return true; + } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { + setRenderMarkdown((prev) => { + const newValue = !prev; +@@ -1869,6 +1898,9 @@ Logging in with Google... Restarting Gemini CLI to continue. + settings.merged.general.devtools, + showErrorDetails, + triggerExpandHint, ++ tabFocusTimeoutRef, ++ handleWarning, ++ voice, + ], + ); + +@@ -2605,7 +2637,9 @@ Logging in with Google... Restarting Gemini CLI to continue. + > + + +- ++ ++ ++ + + + +diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts +index 842a680a14c..fd9e9616166 100644 +--- a/packages/cli/src/ui/commands/extensionsCommand.ts ++++ b/packages/cli/src/ui/commands/extensionsCommand.ts +@@ -209,7 +209,7 @@ async function restartAction( + const s = extensionsToRestart.length > 1 ? 's' : ''; + + const restartingMessage = { +- type: MessageType.INFO, ++ type: 'info' as const, + text: `Restarting ${extensionsToRestart.length} extension${s}...`, + color: theme.text.primary, + }; +diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts +index 2cbb9da9a7e..912ec1d1c11 100644 +--- a/packages/cli/src/ui/commands/types.ts ++++ b/packages/cli/src/ui/commands/types.ts +@@ -92,6 +92,7 @@ export interface CommandContext { + removeComponent: () => void; + toggleBackgroundShell: () => void; + toggleShortcutsHelp: () => void; ++ toggleVoice: () => void; + }; + // Session-specific data + session: { +diff --git a/packages/cli/src/ui/commands/voiceCommand.ts b/packages/cli/src/ui/commands/voiceCommand.ts +new file mode 100644 +index 00000000000..efe2f221645 +--- /dev/null ++++ b/packages/cli/src/ui/commands/voiceCommand.ts +@@ -0,0 +1,215 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { CommandKind, type SlashCommand } from './types.js'; ++import { ++ MessageType, ++ type HistoryItemVoiceHelp, ++ type HistoryItemVoiceStatus, ++} from '../types.js'; ++import { SettingScope } from '../../config/settings.js'; ++ ++const voiceHelpCommand: SlashCommand = { ++ name: 'help', ++ description: 'Show all voice configuration commands and shortcuts', ++ kind: CommandKind.BUILT_IN, ++ action: async (context) => { ++ const item: Omit = { ++ type: MessageType.VOICE_HELP, ++ timestamp: new Date(), ++ }; ++ context.ui.addItem(item); ++ }, ++}; ++ ++const voiceEnableCommand: SlashCommand = { ++ name: 'enable', ++ description: 'Enable voice input (double-tap Space on empty input to record)', ++ kind: CommandKind.BUILT_IN, ++ action: async (context) => { ++ context.services.settings.setValue( ++ SettingScope.User, ++ 'voice.enabled', ++ true, ++ ); ++ return { ++ type: 'message', ++ messageType: MessageType.INFO, ++ content: ++ 'Voice input enabled. Double-tap Space on an empty prompt to start recording.', ++ }; ++ }, ++}; ++ ++const voiceDisableCommand: SlashCommand = { ++ name: 'disable', ++ description: 'Disable voice input', ++ kind: CommandKind.BUILT_IN, ++ action: async (context) => { ++ context.services.settings.setValue( ++ SettingScope.User, ++ 'voice.enabled', ++ false, ++ ); ++ return { ++ type: 'message', ++ messageType: MessageType.INFO, ++ content: 'Voice input disabled.', ++ }; ++ }, ++}; ++ ++const voiceProviderCommand: SlashCommand = { ++ name: 'provider', ++ description: 'Set transcription backend: gemini (default) or whisper', ++ kind: CommandKind.BUILT_IN, ++ action: async (context, args) => { ++ const provider = args?.trim().toLowerCase(); ++ if (provider !== 'gemini' && provider !== 'whisper') { ++ return { ++ type: 'message', ++ messageType: MessageType.ERROR, ++ content: 'Usage: /voice provider [gemini|whisper]', ++ }; ++ } ++ context.services.settings.setValue( ++ SettingScope.User, ++ 'voice.provider', ++ provider, ++ ); ++ return { ++ type: 'message', ++ messageType: MessageType.INFO, ++ content: `Voice transcription backend set to: ${provider}`, ++ }; ++ }, ++}; ++ ++const voiceSensitivityCommand: SlashCommand = { ++ name: 'sensitivity', ++ description: 'Set silence threshold (0=off, 80=default, 300+=loud only)', ++ kind: CommandKind.BUILT_IN, ++ action: async (context, args) => { ++ const raw = args?.trim(); ++ const value = raw !== undefined && raw !== '' ? Number(raw) : NaN; ++ if (isNaN(value) || value < 0 || value > 1000) { ++ return { ++ type: 'message', ++ messageType: MessageType.ERROR, ++ content: ++ 'Usage: /voice sensitivity <0-1000>\n' + ++ ' 0 = disable silence detection (captures all audio)\n' + ++ ' 1-80 = sensitive (whispered speech)\n' + ++ ' 80 = default\n' + ++ ' 300+ = only loud speech', ++ }; ++ } ++ context.services.settings.setValue( ++ SettingScope.User, ++ 'voice.silenceThreshold', ++ value, ++ ); ++ const hint = ++ value === 0 ++ ? 'Silence detection disabled.' ++ : value <= 80 ++ ? 'Sensitive — whispered speech will be captured.' ++ : value <= 200 ++ ? 'Moderate — quiet speech captured.' ++ : 'High — only louder speech captured.'; ++ return { ++ type: 'message', ++ messageType: MessageType.INFO, ++ content: `Voice sensitivity threshold set to ${value}. ${hint}`, ++ }; ++ }, ++}; ++ ++export const voiceCommand: SlashCommand = { ++ name: 'voice', ++ description: 'Configure voice input settings', ++ kind: CommandKind.BUILT_IN, ++ subCommands: [ ++ voiceHelpCommand, ++ voiceEnableCommand, ++ voiceDisableCommand, ++ voiceProviderCommand, ++ voiceSensitivityCommand, ++ ], ++ action: async (context, args) => { ++ const trimmedArgs = args?.trim() || ''; ++ const parts = trimmedArgs.split(/\s+/); ++ const subCommand = parts[0]?.toLowerCase(); ++ ++ if (subCommand === 'enable') { ++ return voiceEnableCommand.action!(context, parts.slice(1).join(' ')); ++ } ++ ++ if (subCommand === 'disable') { ++ return voiceDisableCommand.action!(context, parts.slice(1).join(' ')); ++ } ++ ++ if (subCommand === 'provider') { ++ return voiceProviderCommand.action!(context, parts.slice(1).join(' ')); ++ } ++ ++ if (subCommand === 'sensitivity') { ++ return voiceSensitivityCommand.action!(context, parts.slice(1).join(' ')); ++ } ++ ++ // /voice set-path — functional but not in autocomplete hints ++ if (subCommand === 'set-path') { ++ const path = parts[1]; ++ if (!path) { ++ return { ++ type: 'message', ++ messageType: MessageType.ERROR, ++ content: 'Usage: /voice set-path ', ++ }; ++ } ++ context.services.settings.setValue( ++ SettingScope.User, ++ 'voice.whisperPath', ++ path, ++ ); ++ return { ++ type: 'message', ++ messageType: MessageType.INFO, ++ content: `Whisper binary path set to: ${path}`, ++ }; ++ } ++ ++ if (subCommand === 'help') { ++ return voiceHelpCommand.action!(context, ''); ++ } ++ ++ // Default: show current voice settings status ++ const voiceSettings = context.services.settings.merged.voice ?? {}; ++ const enabled = voiceSettings.enabled ?? false; ++ const provider = voiceSettings.provider ?? 'gemini'; ++ const whisperPath = voiceSettings.whisperPath ?? '(not set)'; ++ const threshold: number = voiceSettings.silenceThreshold ?? 80; ++ const sensitivityLabel = ++ threshold === 0 ++ ? 'disabled' ++ : threshold <= 80 ++ ? `${threshold} (whisper-sensitive)` ++ : threshold <= 300 ++ ? `${threshold} (moderate)` ++ : `${threshold} (loud speech only)`; ++ ++ const statusItem: Omit = { ++ type: MessageType.VOICE_STATUS, ++ timestamp: new Date(), ++ enabled, ++ provider, ++ sensitivityLabel, ++ whisperPath, ++ }; ++ context.ui.addItem(statusItem); ++ return; ++ }, ++}; +diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx +index 51c879e772b..3720ade35e4 100644 +--- a/packages/cli/src/ui/components/Composer.tsx ++++ b/packages/cli/src/ui/components/Composer.tsx +@@ -435,6 +435,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { + + {uiState.isInputActive && ( + = ({ + {itemForDisplay.type === 'help' && commands && ( + + )} ++ {itemForDisplay.type === 'voice_help' && } ++ {itemForDisplay.type === 'voice_status' && ( ++ ++ )} + {itemForDisplay.type === 'stats' && ( + = ({ + const pasteTimeoutRef = useRef(null); + const innerBoxRef = useRef(null); + const hasUserNavigatedSuggestions = useRef(false); ++ // Double-space voice trigger: track last space press time (only fires on empty input) ++ const lastSpacePressRef = useRef(0); + + const [reverseSearchActive, setReverseSearchActive] = useState(false); + const [commandSearchActive, setCommandSearchActive] = useState(false); +@@ -341,6 +348,26 @@ export const InputPrompt: React.FC = ({ + [], + ); + ++ // Voice input hook - MUST be before handleSubmit ++ // NOTE: Transcript is delivered via events, not context, to avoid infinite render loops ++ const { ++ isEnabled: voiceEnabled, ++ state: voiceState, ++ toggleRecording, ++ cancelRecording, ++ } = useVoiceContext(); ++ ++ // Handle voice transcript via event listener (not context) to avoid re-renders ++ useEffect(() => { ++ const handleTranscript = (transcript: string) => { ++ // Insert transcribed text at cursor position with trailing space for next input ++ buffer.insert(transcript + ' '); ++ }; ++ ++ const unsubscribe = onVoiceTranscript(handleTranscript); ++ return unsubscribe; ++ }, [buffer]); ++ + const handleSubmitAndClear = useCallback( + (submittedValue: string) => { + let processedValue = submittedValue; +@@ -654,6 +681,20 @@ export const InputPrompt: React.FC = ({ + return true; + } + ++ // Hide the shortcuts panel if voice is active or if other keys are pressed ++ if (shortcutsHelpVisible) { ++ setShortcutsHelpVisible(false); ++ } ++ ++ // Cancel active voice transcription if escape is hit ++ if (key.name === 'escape') { ++ if (voiceState.isRecording || voiceState.isTranscribing) { ++ void cancelRecording(); ++ resetEscapeState(); ++ return true; ++ } ++ } ++ + if ( + key.name === 'escape' && + (streamingState === StreamingState.Responding || +@@ -698,7 +739,6 @@ export const InputPrompt: React.FC = ({ + } else { + resetPlainTabPress(); + } +- + if (key.name === 'paste') { + if (shortcutsHelpVisible) { + setShortcutsHelpVisible(false); +@@ -794,6 +834,11 @@ export const InputPrompt: React.FC = ({ + } + + if (keyMatchers[Command.ESCAPE](key)) { ++ if (voiceState.isRecording) { ++ void cancelRecording(); ++ return true; ++ } ++ + const cancelSearch = ( + setActive: (active: boolean) => void, + resetCompletion: () => void, +@@ -842,12 +887,58 @@ export const InputPrompt: React.FC = ({ + return true; + } + ++ if (keyMatchers[Command.QUIT](key)) { ++ if (voiceState.isRecording) { ++ void toggleRecording(); ++ return true; ++ } ++ } ++ + if (keyMatchers[Command.CLEAR_SCREEN](key)) { + setBannerVisible(false); + onClearScreen(); + return true; + } + ++ // Reset double-space timer on any non-space key ++ if (key.sequence !== ' ') { ++ lastSpacePressRef.current = 0; ++ } ++ ++ // Double-space triggers voice recording. ++ if (voiceEnabled && key.sequence === ' ') { ++ const now = Date.now(); ++ const delta = now - lastSpacePressRef.current; ++ ++ // If they are just holding down the spacebar, the OS will send repeated keys ++ // very quickly (e.g. every 30ms). We don't want to trigger voice on continuous hold. ++ if (delta > 50 && delta < 300) { ++ lastSpacePressRef.current = 0; ++ ++ // The first space was already inserted into the buffer on the previous keypress ++ // (because we let it pass through to keep typing feeling responsive). ++ // We must manually delete that first space now before starting recording. ++ if (buffer.text.length > 0 && buffer.text.endsWith(' ')) { ++ buffer.setText(buffer.text.slice(0, -1)); ++ } ++ ++ void toggleRecording(); ++ return true; // Consume the second space ++ } ++ ++ lastSpacePressRef.current = now; ++ ++ // Consume the first space only if the buffer is empty to avoid leading whitespace. ++ // If not empty, let it through so normal typing isn't delayed. ++ if (buffer.text.length === 0) { ++ return true; ++ } ++ } ++ if (voiceEnabled && keyMatchers[Command.VOICE_INPUT](key)) { ++ void toggleRecording(); ++ return true; ++ } ++ + if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { + setReverseSearchActive(true); + setTextBeforeReverseSearch(buffer.text); +@@ -1259,6 +1350,12 @@ export const InputPrompt: React.FC = ({ + shouldShowSuggestions, + isShellSuggestionsVisible, + forceShowShellSuggestions, ++ history, ++ voiceEnabled, ++ toggleRecording, ++ cancelRecording, ++ voiceState.isRecording, ++ voiceState.isTranscribing, + ], + ); + +@@ -1431,6 +1528,24 @@ export const InputPrompt: React.FC = ({ + statusText = 'Accepting edits'; + } + ++ // Voice input status ++ // ⚠️ Voice state intentionally overrides mode indicators ++ // (recording must always be visible regardless of approval mode) ++ if (voiceState.isRecording) { ++ statusColor = theme.status.error; ++ statusText = '🎤 Recording... (Space Space or Esc to stop)'; ++ } else if (voiceState.isTranscribing) { ++ statusColor = theme.status.warning; ++ statusText = 'Transcribing...'; ++ } ++ ++ // Dynamic placeholder reflecting voice state ++ const effectivePlaceholder = voiceState.isRecording ++ ? 'Speak now...' ++ : voiceState.isTranscribing ++ ? 'Transcribing your speech...' ++ : placeholder; ++ + const suggestionsNode = shouldShowSuggestions ? ( + + = ({ + (r:){' '} +
+ ) : ( +- '!' ++ '! ' + ) + ) : commandSearchActive ? ( + (r:) + ) : showYoloStyling ? ( +- '*' ++ '* ' ++ ) : voiceState.isRecording ? ( ++ '🎤' ++ ) : voiceState.isTranscribing ? ( ++ <> ++ ++ {' '} ++ + ) : ( +- '>' ++ // Use two spaces for single-width icons to match double-width emoji ++ // and prevent layout shift when toggling voice. ++ '> ' + )}{' '} +
+ +- {buffer.text.length === 0 && placeholder ? ( ++ {buffer.text.length === 0 && effectivePlaceholder ? ( + showCursor ? ( + +- {chalk.inverse(placeholder.slice(0, 1))} ++ {chalk.inverse(effectivePlaceholder.slice(0, 1))} + +- {placeholder.slice(1)} ++ {effectivePlaceholder.slice(1)} + + + ) : ( +- {placeholder} ++ {effectivePlaceholder} + ) + ) : ( + linesToRender +diff --git a/packages/cli/src/ui/components/VoiceHelp.tsx b/packages/cli/src/ui/components/VoiceHelp.tsx +new file mode 100644 +index 00000000000..684a736515a +--- /dev/null ++++ b/packages/cli/src/ui/components/VoiceHelp.tsx +@@ -0,0 +1,104 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import type React from 'react'; ++import { Box, Text } from 'ink'; ++import { theme } from '../semantic-colors.js'; ++ ++export const VoiceHelp: React.FC = () => ( ++ ++ ++ Voice Input: ++ ++ ++ ++ ++ ++ Commands: ++ ++ {[ ++ ['/voice', 'Show current voice settings'], ++ ['/voice enable', 'Enable voice input'], ++ ['/voice disable', 'Disable voice input'], ++ ['/voice provider [gemini|whisper]', 'Set transcription backend'], ++ ['/voice sensitivity <0-1000>', 'Set silence detection threshold'], ++ ['/voice set-path ', 'Set path to Whisper binary'], ++ ['/voice help', 'Show this help'], ++ ].map(([cmd, desc]) => ( ++ ++ ++ {' '} ++ {cmd} ++ ++ {' - ' + desc} ++ ++ ))} ++ ++ ++ ++ ++ Recording Shortcuts: ++ ++ ++ ++ Space Space ++ ++ {' (on empty input) - Start/stop recording'} ++ ++ ++ ++ Esc ++ ++ {' - Cancel recording (discards audio, no transcription)'} ++ ++ ++ ++ ++ ++ Silence Sensitivity Guide: ++ ++ {[ ++ ['0', 'Disable silence detection (captures all audio)'], ++ ['1–80', 'Sensitive — whispered speech (default: 80)'], ++ ['80–300', 'Moderate — quiet speech'], ++ ['300+', 'High — only louder speech'], ++ ].map(([val, desc]) => ( ++ ++ ++ {' '} ++ {val} ++ ++ {' ' + desc} ++ ++ ))} ++ ++ ++ ++ ++ Transcription Backends: ++ ++ ++ ++ {' '} ++ gemini ++ ++ {' Zero-install. Uses your existing Gemini API auth. (default)'} ++ ++ ++ ++ {' '} ++ whisper ++ ++ {' Local Whisper binary (faster-whisper or openai-whisper).'} ++ ++ ++); +diff --git a/packages/cli/src/ui/components/VoiceStatus.tsx b/packages/cli/src/ui/components/VoiceStatus.tsx +new file mode 100644 +index 00000000000..5a60d074241 +--- /dev/null ++++ b/packages/cli/src/ui/components/VoiceStatus.tsx +@@ -0,0 +1,59 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import type React from 'react'; ++import { Box, Text } from 'ink'; ++import { theme } from '../semantic-colors.js'; ++import type { HistoryItemVoiceStatus } from '../types.js'; ++ ++interface Props { ++ item: HistoryItemVoiceStatus; ++} ++ ++export const VoiceStatus: React.FC = ({ item }) => ( ++ ++ ++ Voice Input Settings: ++ ++ ++ ++ ++ {[ ++ ['Enabled', item.enabled ? 'yes' : 'no'], ++ ['Provider', item.provider], ++ ['Sensitivity', item.sensitivityLabel], ++ ['Whisper path', item.whisperPath], ++ ].map(([label, value]) => ( ++ ++ ++ {' '} ++ {label} ++ ++ {' ' + value} ++ ++ ))} ++ ++ ++ ++ ++ Shortcuts:{' '} ++ ++ Space Space ++ ++ {' (empty input) to record · '} ++ ++ /voice help ++ ++ {' for all commands'} ++ ++ ++); +diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +index 96440266340..61414fe2f20 100644 +--- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap ++++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +@@ -150,6 +150,43 @@ Enter to select · ↑/↓ to navigate · Esc to cancel + " + `; + ++exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` + "What should we name this component? + +@@ -276,3 +313,40 @@ README → (not answered) + Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel + " + `; ++ ++exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; +diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +index f40887b3b9c..5a2819702e0 100644 +--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap ++++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +@@ -78,27 +78,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub + " + `; + +-exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` +-"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +- > [Pasted Text: 10 lines] +-▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +-" +-`; +- +-exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` +-"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +- > [Pasted Text: 10 lines] +-▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +-" +-`; +- +-exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` +-"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +- > [Pasted Text: 10 lines] +-▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +-" +-`; +- + exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` + "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Type your message or @path/to/file +diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +index 06f7d49105f..ff54d49c4d5 100644 +--- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap ++++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +@@ -110,6 +110,43 @@ exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 1`] + " + `; + ++exports[`RewindViewer > Navigation > handles 'down' navigation > after-down 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` + "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ +@@ -134,6 +171,43 @@ exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 1`] = ` + " + `; + ++exports[`RewindViewer > Navigation > handles 'up' navigation > after-up 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] = ` + "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ +@@ -158,6 +232,43 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 1`] + " + `; + ++exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-down 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = ` + "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ +@@ -182,6 +293,43 @@ exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 1`] = + " + `; + ++exports[`RewindViewer > Navigation > handles cyclic navigation > cyclic-up 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`RewindViewer > Rendering > renders 'a single interaction' 1`] = ` + "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ +diff --git a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap +index 040b6babeca..d22a6cad6e4 100644 +--- a/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap ++++ b/packages/cli/src/ui/components/shared/__snapshots__/BaseSelectionList.test.tsx.snap +@@ -16,6 +16,43 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro + " + `; + ++exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = ` + "▲ + 4. Item 4 +@@ -25,6 +62,43 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro + " + `; + ++exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; ++ + exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = ` + "▲ + ● 1. Item 1 +@@ -33,3 +107,40 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro + ▼ + " + `; ++ ++exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 2`] = ` ++" ++ ERROR Text string " " must be rendered inside component ++ ++ file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10 ++ ++ - createTextInstance (file:///home/fayerman/gemini-cli/node_modules/ink/src/reconciler.ts:230:10) ++ -completeWor ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:9082:42) ++ -runWithFiberIn ++ EV (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:1738:13) ++ -completeUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.deve ++ lopment.js:12962:19) ++ -performUnitOfW ++ rk (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12843:11) ++ -workLoopSyn ++ (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developm ++ ent.js:12644:41) ++ -renderRootSy ++ c (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develop ++ ment.js:12624:11) ++ -performWorkOnR ++ ot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.devel ++ opment.js:12135:44) ++ -performSyncWorkOn ++ oot (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.de ++ velopment.js:2446:7) ++ -flushSyncWorkAcrossRoo ++ s_impl (/home/fayerman/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil ++ er.development.js:2294:21) ++" ++`; +diff --git a/packages/cli/src/ui/contexts/VoiceContext.test.tsx b/packages/cli/src/ui/contexts/VoiceContext.test.tsx +new file mode 100644 +index 00000000000..60e93fd33f4 +--- /dev/null ++++ b/packages/cli/src/ui/contexts/VoiceContext.test.tsx +@@ -0,0 +1,38 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { describe, it, expect, vi } from 'vitest'; ++import { renderHook } from '../../test-utils/render.js'; ++import { VoiceContext, useVoiceContext } from './VoiceContext.js'; ++import type { VoiceInputReturn } from '../hooks/useVoiceInput.js'; ++import type React from 'react'; ++ ++describe('VoiceContext', () => { ++ it('should provide voice input state', () => { ++ const mockVoiceInput: VoiceInputReturn = { ++ isEnabled: true, ++ state: { ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }, ++ startRecording: vi.fn(), ++ stopRecording: vi.fn(), ++ cancelRecording: vi.fn(), ++ toggleRecording: vi.fn(), ++ }; ++ ++ const wrapper = ({ children }: { children: React.ReactNode }) => ( ++ ++ {children} ++ ++ ); ++ ++ const { result } = renderHook(() => useVoiceContext(), { wrapper }); ++ ++ expect(result.current).toBe(mockVoiceInput); ++ }); ++}); +diff --git a/packages/cli/src/ui/contexts/VoiceContext.tsx b/packages/cli/src/ui/contexts/VoiceContext.tsx +new file mode 100644 +index 00000000000..69136fb2a42 +--- /dev/null ++++ b/packages/cli/src/ui/contexts/VoiceContext.tsx +@@ -0,0 +1,23 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { createContext, useContext } from 'react'; ++import type { VoiceInputReturn } from '../hooks/useVoiceInput.js'; ++ ++export const VoiceContext = createContext(null); ++ ++export const useVoiceContext = () => { ++ const context = useContext(VoiceContext); ++ if (!context) { ++ throw new Error( ++ 'useVoiceContext must be used within a VoiceContext.Provider', ++ ); ++ } ++ return context; ++}; ++ ++// Re-export event subscription for convenience ++export { onVoiceTranscript } from '../hooks/useVoiceInput.js'; +diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +index 6190d163f78..3a3a1936e08 100644 +--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx ++++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +@@ -240,6 +240,7 @@ describe('useSlashCommandProcessor', () => { + toggleBackgroundShell: vi.fn(), + toggleShortcutsHelp: vi.fn(), + setText: vi.fn(), ++ toggleVoice: vi.fn(), + }, + new Map(), // extensionsUpdateState + true, // isConfigInitialized +diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts +index c3f178ad1bb..55188ad6954 100644 +--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts ++++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts +@@ -86,6 +86,7 @@ interface SlashCommandProcessorActions { + toggleBackgroundShell: () => void; + toggleShortcutsHelp: () => void; + setText: (text: string) => void; ++ toggleVoice: () => void; + } + + /** +@@ -172,6 +173,20 @@ export const useSlashCommandProcessor = ( + type: 'help', + timestamp: message.timestamp, + }; ++ } else if (message.type === MessageType.VOICE_HELP) { ++ historyItemContent = { ++ type: 'voice_help', ++ timestamp: message.timestamp, ++ }; ++ } else if (message.type === MessageType.VOICE_STATUS) { ++ historyItemContent = { ++ type: 'voice_status', ++ timestamp: message.timestamp, ++ enabled: message.enabled, ++ provider: message.provider, ++ sensitivityLabel: message.sensitivityLabel, ++ whisperPath: message.whisperPath, ++ }; + } else if (message.type === MessageType.STATS) { + historyItemContent = { + type: 'stats', +@@ -243,6 +258,7 @@ export const useSlashCommandProcessor = ( + removeComponent: () => setCustomDialog(null), + toggleBackgroundShell: actions.toggleBackgroundShell, + toggleShortcutsHelp: actions.toggleShortcutsHelp, ++ toggleVoice: actions.toggleVoice, + }, + session: { + stats: session.stats, +diff --git a/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts +new file mode 100644 +index 00000000000..31690567eb1 +--- /dev/null ++++ b/packages/cli/src/ui/hooks/useVoiceInput.log-volume.test.ts +@@ -0,0 +1,116 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; ++import { act } from 'react'; ++import { renderHook } from '../../test-utils/render.js'; ++import { createMockConfig } from '../../test-utils/mockConfig.js'; ++import { useVoiceInput } from './useVoiceInput.js'; ++import { debugLogger } from '@google/gemini-cli-core'; ++import type { VoiceBackendOptions } from '@google/gemini-cli-core'; ++ ++// --------------------------------------------------------------------------- ++// Mock @google/gemini-cli-core: provide mock backends so tests run without ++// real audio recording infrastructure (sox/arecord, Gemini API). ++// --------------------------------------------------------------------------- ++let capturedGeminiOptions: VoiceBackendOptions | null = null; ++ ++vi.mock('@google/gemini-cli-core', async (importOriginal) => { ++ const original = ++ await importOriginal(); ++ return { ++ ...original, ++ GeminiRestBackend: vi ++ .fn() ++ .mockImplementation((opts: VoiceBackendOptions) => { ++ capturedGeminiOptions = opts; ++ return { ++ start: vi.fn(), ++ stop: vi.fn(), ++ cleanup: vi.fn(), ++ }; ++ }), ++ LocalWhisperBackend: vi.fn().mockImplementation(() => ({ ++ start: vi.fn(), ++ stop: vi.fn(), ++ cleanup: vi.fn(), ++ })), ++ }; ++}); ++ ++const mockConfig = createMockConfig({ ++ getContentGenerator: vi.fn().mockReturnValue({}), ++}); ++ ++describe('useVoiceInput Log Volume', () => { ++ let logSpy: ReturnType; ++ let warnSpy: ReturnType; ++ let errorSpy: ReturnType; ++ ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ capturedGeminiOptions = null; ++ ++ logSpy = vi.spyOn(debugLogger, 'log'); ++ warnSpy = vi.spyOn(debugLogger, 'warn'); ++ errorSpy = vi.spyOn(debugLogger, 'error'); ++ // We intentionally DO NOT spy on 'debug' — those logs are safe/hidden. ++ }); ++ ++ afterEach(() => { ++ logSpy.mockRestore(); ++ warnSpy.mockRestore(); ++ errorSpy.mockRestore(); ++ }); ++ ++ it('should remain SILENT (no visible logs) during normal recording start/stop', async () => { ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ ++ // --- Action 1: Start Recording --- ++ await act(async () => { ++ await result.current.startRecording(); ++ }); ++ ++ // Starting recording should produce ZERO visible logs. ++ expect(logSpy).not.toHaveBeenCalled(); ++ expect(warnSpy).not.toHaveBeenCalled(); ++ expect(errorSpy).not.toHaveBeenCalled(); ++ ++ // --- Action 2: Stop Recording --- ++ await act(async () => { ++ await result.current.stopRecording(); ++ }); ++ ++ // Stopping should also be silent. ++ expect(logSpy).not.toHaveBeenCalled(); ++ expect(warnSpy).not.toHaveBeenCalled(); ++ expect(errorSpy).not.toHaveBeenCalled(); ++ }); ++ ++ it('should remain SILENT even when the backend emits rapid state changes', async () => { ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ ++ await act(async () => { ++ await result.current.startRecording(); ++ }); ++ ++ // Simulate the backend emitting many intermediate state notifications ++ // (e.g. progress callbacks). None of these should produce visible logs. ++ await act(async () => { ++ for (let i = 0; i < 100; i++) { ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ } ++ }); ++ ++ expect(logSpy).not.toHaveBeenCalled(); ++ expect(warnSpy).not.toHaveBeenCalled(); ++ expect(errorSpy).not.toHaveBeenCalled(); ++ }); ++}); +diff --git a/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx b/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx +new file mode 100644 +index 00000000000..435e8c4adac +--- /dev/null ++++ b/packages/cli/src/ui/hooks/useVoiceInput.replication.test.tsx +@@ -0,0 +1,150 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++import { act, useContext, useEffect } from 'react'; ++import { renderHook } from '../../test-utils/render.js'; ++import { createMockConfig } from '../../test-utils/mockConfig.js'; ++import { waitFor } from '../../test-utils/async.js'; ++import { useVoiceInput, onVoiceTranscript } from './useVoiceInput.js'; ++import { VoiceContext } from '../contexts/VoiceContext.js'; ++import type { VoiceBackendOptions } from '@google/gemini-cli-core'; ++import { coreEvents } from '@google/gemini-cli-core'; ++ ++// --------------------------------------------------------------------------- ++// Mock @google/gemini-cli-core: provide mock backends so tests run without ++// real audio recording infrastructure (sox/arecord, Gemini API). ++// Keep real coreEvents so transcript events flow through properly. ++// --------------------------------------------------------------------------- ++let capturedGeminiOptions: VoiceBackendOptions | null = null; ++const mockGeminiStart = vi.fn(); ++const mockGeminiStop = vi.fn(); ++ ++vi.mock('@google/gemini-cli-core', async (importOriginal) => { ++ const original = ++ await importOriginal(); ++ ++ // Extend coreEvents with emitVoiceTranscript if not already present. ++ const VOICE_TRANSCRIPT_EVENT = 'voice-transcript'; ++ const extendedCoreEvents = Object.assign(original.coreEvents, { ++ emitVoiceTranscript: (transcript: string) => { ++ (original.coreEvents as import('node:events').EventEmitter).emit( ++ VOICE_TRANSCRIPT_EVENT, ++ transcript, ++ ); ++ }, ++ }); ++ const extendedCoreEvent = { ++ ...original.CoreEvent, ++ VoiceTranscript: VOICE_TRANSCRIPT_EVENT, ++ }; ++ ++ return { ++ ...original, ++ coreEvents: extendedCoreEvents, ++ CoreEvent: extendedCoreEvent, ++ GeminiRestBackend: vi ++ .fn() ++ .mockImplementation((opts: VoiceBackendOptions) => { ++ capturedGeminiOptions = opts; ++ return { ++ start: mockGeminiStart, ++ stop: mockGeminiStop, ++ cleanup: vi.fn(), ++ }; ++ }), ++ LocalWhisperBackend: vi.fn().mockImplementation(() => ({ ++ start: vi.fn(), ++ stop: vi.fn(), ++ cleanup: vi.fn(), ++ })), ++ }; ++}); ++ ++const mockConfig = createMockConfig({ ++ getContentGenerator: vi.fn().mockReturnValue({}), ++}); ++ ++describe('Voice Input Full Cycle Replication', () => { ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ capturedGeminiOptions = null; ++ }); ++ ++ it('should deliver transcript via event without causing excessive re-renders', async () => { ++ let consumerRenders = 0; ++ let transcriptReceived: string | null = null; ++ ++ // Provider: useVoiceInput hook ++ const { result: providerResult } = renderHook(() => ++ useVoiceInput({ config: mockConfig }), ++ ); ++ ++ // Consumer: simulates InputPrompt's event-based transcript handling ++ renderHook( ++ () => { ++ consumerRenders++; ++ useContext(VoiceContext); ++ ++ useEffect(() => { ++ const unsubscribe = onVoiceTranscript((transcript) => { ++ transcriptReceived = transcript; ++ }); ++ return unsubscribe; ++ }, []); ++ }, ++ { ++ wrapper: ({ children }) => ( ++ ++ {children} ++ ++ ), ++ }, ++ ); ++ ++ // Reset render counter after initial setup ++ consumerRenders = 0; ++ ++ // Step 1: Start recording ++ await act(async () => { ++ await providerResult.current.toggleRecording(); ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ }); ++ ++ const rendersBefore = consumerRenders; ++ ++ // Step 2: Stop recording — backend emits transcript via coreEvents ++ await act(async () => { ++ await providerResult.current.toggleRecording(); ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: false, ++ isTranscribing: true, ++ error: null, ++ }); ++ // Simulate backend emitting transcript after transcription completes ++ coreEvents.emitVoiceTranscript('this is a test'); ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ }); ++ ++ // Wait for transcript event to propagate without using fixed timeouts ++ await waitFor(() => { ++ expect(transcriptReceived).toBe('this is a test'); ++ }); ++ ++ // With event-based delivery, the consumer renders very few times. ++ // It should not re-render for every state change in the backend. ++ const rendersAfter = consumerRenders; ++ expect(rendersAfter - rendersBefore).toBeLessThan(5); ++ }); ++}); +diff --git a/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts +new file mode 100644 +index 00000000000..6f03b36e248 +--- /dev/null ++++ b/packages/cli/src/ui/hooks/useVoiceInput.stress.test.ts +@@ -0,0 +1,119 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++import { act } from 'react'; ++import { renderHook } from '../../test-utils/render.js'; ++import { createMockConfig } from '../../test-utils/mockConfig.js'; ++import { useVoiceInput } from './useVoiceInput.js'; ++import type { VoiceBackendOptions } from '@google/gemini-cli-core'; ++ ++// --------------------------------------------------------------------------- ++// Mock @google/gemini-cli-core: provide mock backends so tests run without ++// real audio recording infrastructure (sox/arecord, Gemini API). ++// --------------------------------------------------------------------------- ++let capturedGeminiOptions: VoiceBackendOptions | null = null; ++const mockGeminiStart = vi.fn(); ++const mockGeminiStop = vi.fn(); ++ ++vi.mock('@google/gemini-cli-core', async (importOriginal) => { ++ const original = ++ await importOriginal(); ++ return { ++ ...original, ++ GeminiRestBackend: vi ++ .fn() ++ .mockImplementation((opts: VoiceBackendOptions) => { ++ capturedGeminiOptions = opts; ++ return { ++ start: mockGeminiStart, ++ stop: mockGeminiStop, ++ cleanup: vi.fn(), ++ }; ++ }), ++ LocalWhisperBackend: vi.fn().mockImplementation(() => ({ ++ start: vi.fn(), ++ stop: vi.fn(), ++ cleanup: vi.fn(), ++ })), ++ }; ++}); ++ ++const mockConfig = createMockConfig({ ++ getContentGenerator: vi.fn().mockReturnValue({}), ++}); ++ ++describe('useVoiceInput Stress Tests', () => { ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ capturedGeminiOptions = null; ++ }); ++ ++ it('should not cause excessive re-renders when receiving rapid state notifications', async () => { ++ let renderCount = 0; ++ const { result } = renderHook(() => { ++ renderCount++; ++ return useVoiceInput({ config: mockConfig }); ++ }); ++ ++ // Reset count after initial render ++ renderCount = 0; ++ ++ await act(async () => { ++ await result.current.startRecording(); ++ }); ++ ++ const baseRenders = renderCount; ++ ++ // Simulate 100 rapid state notifications with the same value. ++ // React batching should coalesce these into at most 1 additional render. ++ await act(async () => { ++ for (let i = 0; i < 100; i++) { ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ } ++ }); ++ ++ // 100 identical state updates should not cause 100 re-renders. ++ expect(renderCount - baseRenders).toBeLessThanOrEqual(2); ++ }); ++ ++ it('should not cause excessive re-renders when rapid toggleRecording calls are ignored', async () => { ++ let renderCount = 0; ++ const { result } = renderHook(() => { ++ renderCount++; ++ return useVoiceInput({ config: mockConfig }); ++ }); ++ ++ // Start recording ++ await act(async () => { ++ await result.current.startRecording(); ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ }); ++ ++ const rendersAfterStart = renderCount; ++ ++ // Simulate rapid toggleRecording calls while already recording. ++ // The isTogglingRef guard in the hook should drop concurrent calls. ++ await act(async () => { ++ const calls = []; ++ for (let i = 0; i < 50; i++) { ++ calls.push(result.current.toggleRecording()); ++ } ++ await Promise.all(calls); ++ }); ++ ++ // We expect a small, bounded number of renders — not 50. ++ expect(renderCount - rendersAfterStart).toBeLessThanOrEqual(5); ++ }); ++}); +diff --git a/packages/cli/src/ui/hooks/useVoiceInput.test.ts b/packages/cli/src/ui/hooks/useVoiceInput.test.ts +new file mode 100644 +index 00000000000..9324b0ddba3 +--- /dev/null ++++ b/packages/cli/src/ui/hooks/useVoiceInput.test.ts +@@ -0,0 +1,203 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++import { act } from 'react'; ++import { renderHook } from '../../test-utils/render.js'; ++import { createMockConfig } from '../../test-utils/mockConfig.js'; ++import { useVoiceInput, onVoiceTranscript } from './useVoiceInput.js'; ++import type { VoiceBackendOptions } from '@google/gemini-cli-core'; ++import { coreEvents } from '@google/gemini-cli-core'; ++ ++// --------------------------------------------------------------------------- ++// Partially mock @google/gemini-cli-core: override backends, keep real ++// coreEvents/CoreEvent/debugLogger so transcript events flow through properly. ++// --------------------------------------------------------------------------- ++const mockGeminiStart = vi.fn(); ++const mockGeminiStop = vi.fn(); ++const mockGeminiCancel = vi.fn(); ++const mockGeminiCleanup = vi.fn(); ++ ++const mockWhisperStart = vi.fn(); ++const mockWhisperStop = vi.fn(); ++const mockWhisperCancel = vi.fn(); ++const mockWhisperCleanup = vi.fn(); ++ ++// Captured options so tests can invoke onStateChange ++let capturedGeminiOptions: VoiceBackendOptions | null = null; ++let capturedWhisperOptions: VoiceBackendOptions | null = null; ++ ++vi.mock('@google/gemini-cli-core', async (importOriginal) => { ++ const original = ++ await importOriginal(); ++ ++ return { ++ ...original, ++ GeminiRestBackend: vi ++ .fn() ++ .mockImplementation((opts: VoiceBackendOptions) => { ++ capturedGeminiOptions = opts; ++ return { ++ start: mockGeminiStart, ++ stop: mockGeminiStop, ++ cancel: mockGeminiCancel, ++ cleanup: mockGeminiCleanup, ++ }; ++ }), ++ LocalWhisperBackend: vi ++ .fn() ++ .mockImplementation((opts: VoiceBackendOptions) => { ++ capturedWhisperOptions = opts; ++ return { ++ start: mockWhisperStart, ++ stop: mockWhisperStop, ++ cancel: mockWhisperCancel, ++ cleanup: mockWhisperCleanup, ++ }; ++ }), ++ }; ++}); ++ ++// Minimal mock Config with a working ContentGenerator stub ++const mockConfig = createMockConfig({ ++ getContentGenerator: vi.fn().mockReturnValue({}), ++}); ++ ++describe('useVoiceInput', () => { ++ beforeEach(() => { ++ vi.clearAllMocks(); ++ capturedGeminiOptions = null; ++ capturedWhisperOptions = null; ++ }); ++ ++ it('initializes with default state', () => { ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ expect(result.current.state).toEqual({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ }); ++ ++ it('uses GeminiRestBackend by default', () => { ++ renderHook(() => useVoiceInput({ config: mockConfig })); ++ expect(capturedGeminiOptions).not.toBeNull(); ++ expect(capturedWhisperOptions).toBeNull(); ++ }); ++ ++ it('uses LocalWhisperBackend when provider is "whisper"', () => { ++ renderHook(() => ++ useVoiceInput({ provider: 'whisper', config: mockConfig }), ++ ); ++ expect(capturedWhisperOptions).not.toBeNull(); ++ expect(capturedGeminiOptions).toBeNull(); ++ }); ++ ++ it('delegates startRecording to the active backend', async () => { ++ mockGeminiStart.mockResolvedValue(undefined); ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ await act(async () => { ++ await result.current.startRecording(); ++ }); ++ expect(mockGeminiStart).toHaveBeenCalledOnce(); ++ }); ++ ++ it('delegates stopRecording to the active backend', async () => { ++ mockGeminiStop.mockResolvedValue(undefined); ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ await act(async () => { ++ await result.current.stopRecording(); ++ }); ++ expect(mockGeminiStop).toHaveBeenCalledOnce(); ++ }); ++ ++ it('reflects state changes from the backend', async () => { ++ mockGeminiStart.mockImplementation(async () => { ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ }); ++ ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ ++ await act(async () => { ++ await result.current.startRecording(); ++ }); ++ ++ expect(result.current.state.isRecording).toBe(true); ++ }); ++ ++ it('delivers transcript via coreEvents, not state', async () => { ++ mockGeminiStop.mockImplementation(async () => { ++ // Backends emit via coreEvents rather than a callback ++ coreEvents.emitVoiceTranscript('Hello world'); ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ }); ++ ++ const transcripts: string[] = []; ++ const unsubscribe = onVoiceTranscript((t) => transcripts.push(t)); ++ ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ ++ await act(async () => { ++ await result.current.stopRecording(); ++ }); ++ ++ expect(transcripts).toContain('Hello world'); ++ expect(result.current.state).toEqual({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ ++ unsubscribe(); ++ }); ++ ++ it('surfaces errors from the backend in state', async () => { ++ mockGeminiStart.mockImplementation(async () => { ++ void capturedGeminiOptions?.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: 'Neither sox nor arecord found', ++ }); ++ }); ++ ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ ++ await act(async () => { ++ await result.current.startRecording(); ++ }); ++ ++ expect(result.current.state.error).toContain( ++ 'Neither sox nor arecord found', ++ ); ++ expect(result.current.state.isRecording).toBe(false); ++ }); ++ ++ it('delegates cancelRecording to the active backend', async () => { ++ mockGeminiCancel.mockResolvedValue(undefined); ++ const { result } = renderHook(() => useVoiceInput({ config: mockConfig })); ++ await act(async () => { ++ await result.current.cancelRecording(); ++ }); ++ expect(mockGeminiCancel).toHaveBeenCalledOnce(); ++ }); ++ ++ it('cancelRecording is a no-op when no backend is initialized', async () => { ++ const { result } = renderHook(() => useVoiceInput()); ++ await act(async () => { ++ await result.current.cancelRecording(); ++ }); ++ expect(mockGeminiCancel).not.toHaveBeenCalled(); ++ expect(mockWhisperCancel).not.toHaveBeenCalled(); ++ }); ++}); +diff --git a/packages/cli/src/ui/hooks/useVoiceInput.ts b/packages/cli/src/ui/hooks/useVoiceInput.ts +new file mode 100644 +index 00000000000..d99d64f02bd +--- /dev/null ++++ b/packages/cli/src/ui/hooks/useVoiceInput.ts +@@ -0,0 +1,179 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { ++ debugLogger, ++ coreEvents, ++ CoreEvent, ++ GeminiRestBackend, ++ LocalWhisperBackend, ++} from '@google/gemini-cli-core'; ++import type { ++ VoiceBackend, ++ VoiceInputState, ++ Config, ++} from '@google/gemini-cli-core'; ++import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; ++ ++export type { ++ VoiceInputState, ++ VoiceBackend, ++ VoiceInputReturn, ++} from '@google/gemini-cli-core'; ++ ++/** ++ * Subscribe to voice transcript events emitted by the active backend. ++ * Uses the coreEvents bus (CoreEvent.VoiceTranscript) to avoid React ++ * re-render cascades. ++ */ ++export function onVoiceTranscript( ++ callback: (transcript: string) => void, ++): () => void { ++ coreEvents.on(CoreEvent.VoiceTranscript, callback); ++ return () => { ++ coreEvents.off(CoreEvent.VoiceTranscript, callback); ++ }; ++} ++ ++export interface VoiceInputConfig { ++ /** ++ * Which transcription backend to use. ++ * - 'gemini' (default): zero-install, uses the CLI's existing Gemini API auth. ++ * - 'whisper': local Whisper binary (faster-whisper or openai-whisper). ++ */ ++ provider?: 'gemini' | 'whisper'; ++ /** Path to a custom Whisper binary. Only used when provider is 'whisper'. */ ++ whisperPath?: string; ++ /** The CLI Config instance, used by the Gemini backend for auth. */ ++ config: Config; ++ /** ++ * RMS energy threshold for silence detection (0–1000). Audio below this ++ * level is discarded without an API call. 0 disables silence detection. ++ * Default: 80 (allows whispered speech in quiet environments). ++ */ ++ silenceThreshold?: number; ++} ++ ++/** ++ * Hook for voice input using system audio recording and a pluggable ++ * transcription backend (from packages/core). ++ * ++ * Backends: ++ * - GeminiRestBackend (default): records raw PCM in-memory, builds a WAV ++ * buffer, and transcribes via the Gemini API using the CLI's existing ++ * ContentGenerator. Works with both API key and OAuth auth. ++ * - LocalWhisperBackend: records a WAV file and transcribes with a locally ++ * installed Whisper binary. Used when provider is set to 'whisper'. ++ * ++ * Transcripts are delivered via CoreEvent.VoiceTranscript on coreEvents. ++ */ ++export function useVoiceInput(voiceConfig?: VoiceInputConfig) { ++ const [state, setState] = useState({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ ++ const backendRef = useRef(null); ++ const isTogglingRef = useRef(false); ++ ++ // Initialize (or re-initialize) the backend when config changes ++ useEffect(() => { ++ const options = { ++ onStateChange: async (newState: VoiceInputState) => { ++ setState(newState); ++ if (newState.error) { ++ coreEvents.emitFeedback('error', newState.error); ++ } ++ // Yield one macrotask after signalling isTranscribing:true so Ink ++ // can flush the state update and render ⏳ before the network call. ++ if (newState.isTranscribing) { ++ await new Promise((resolve) => setImmediate(resolve)); ++ } ++ }, ++ silenceThreshold: voiceConfig?.silenceThreshold, ++ }; ++ ++ if (voiceConfig?.provider === 'whisper') { ++ backendRef.current = new LocalWhisperBackend(options, { ++ whisperPath: voiceConfig.whisperPath, ++ }); ++ } else if (voiceConfig?.config) { ++ backendRef.current = new GeminiRestBackend(options, voiceConfig.config); ++ } ++ ++ return () => { ++ void backendRef.current?.cleanup(); ++ backendRef.current = null; ++ }; ++ }, [ ++ voiceConfig?.provider, ++ voiceConfig?.whisperPath, ++ voiceConfig?.config, ++ voiceConfig?.silenceThreshold, ++ ]); ++ ++ const startRecording = useCallback(async () => { ++ if (!backendRef.current) { ++ debugLogger.debug( ++ 'useVoiceInput: startRecording — no backend (voice disabled?)', ++ ); ++ return; ++ } ++ debugLogger.debug('useVoiceInput: startRecording'); ++ await backendRef.current.start(); ++ }, []); ++ ++ const stopRecording = useCallback(async () => { ++ if (!backendRef.current) return; ++ debugLogger.debug('useVoiceInput: stopRecording'); ++ await backendRef.current.stop(); ++ }, []); ++ ++ const cancelRecording = useCallback(async () => { ++ if (!backendRef.current) return; ++ await backendRef.current.cancel(); ++ }, []); ++ ++ const toggleRecording = useCallback(async () => { ++ if (isTogglingRef.current) return; ++ isTogglingRef.current = true; ++ try { ++ if (state.isRecording) { ++ await stopRecording(); ++ } else { ++ await startRecording(); ++ } ++ } catch (e) { ++ debugLogger.error('useVoiceInput: toggle error', e); ++ } finally { ++ isTogglingRef.current = false; ++ } ++ }, [state.isRecording, startRecording, stopRecording]); ++ ++ const isEnabled = !!( ++ voiceConfig?.config || voiceConfig?.provider === 'whisper' ++ ); ++ ++ return useMemo( ++ () => ({ ++ isEnabled, ++ state, ++ startRecording, ++ stopRecording, ++ cancelRecording, ++ toggleRecording, ++ }), ++ [ ++ isEnabled, ++ state, ++ startRecording, ++ stopRecording, ++ cancelRecording, ++ toggleRecording, ++ ], ++ ); ++} +diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts +index 763754ec951..41035905ddd 100644 +--- a/packages/cli/src/ui/keyMatchers.test.ts ++++ b/packages/cli/src/ui/keyMatchers.test.ts +@@ -288,7 +288,11 @@ describe('keyMatchers', () => { + }, + { + command: Command.PASTE_CLIPBOARD, +- positive: [createKey('v', { ctrl: true })], ++ positive: [ ++ createKey('v', { ctrl: true }), ++ createKey('v', { cmd: true }), ++ createKey('v', { alt: true }), ++ ], + negative: [createKey('v'), createKey('c', { ctrl: true })], + }, + +@@ -352,6 +356,11 @@ describe('keyMatchers', () => { + createKey('l', { ctrl: true }), + ], + }, ++ { ++ command: Command.VOICE_INPUT, ++ positive: [createKey('r', { alt: true })], ++ negative: [createKey('v'), createKey('v', { ctrl: true })], ++ }, + // Shell commands + { + command: Command.REVERSE_SEARCH, +diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +index 00efd3f7fcd..f18fee2d17f 100644 +--- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts ++++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +@@ -43,5 +43,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { + removeComponent: () => {}, + toggleBackgroundShell: () => {}, + toggleShortcutsHelp: () => {}, ++ toggleVoice: () => {}, + }; + } +diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts +index c9910179a5d..ad23e95f4df 100644 +--- a/packages/cli/src/ui/types.ts ++++ b/packages/cli/src/ui/types.ts +@@ -185,6 +185,19 @@ export type HistoryItemHelp = HistoryItemBase & { + timestamp: Date; + }; + ++export type HistoryItemVoiceHelp = HistoryItemBase & { ++ type: 'voice_help'; ++ timestamp: Date; ++}; ++ ++export type HistoryItemVoiceStatus = HistoryItemBase & { ++ type: 'voice_status'; ++ timestamp: Date; ++ enabled: boolean; ++ provider: string; ++ sensitivityLabel: string; ++ whisperPath: string; ++}; + export interface HistoryItemQuotaBase extends HistoryItemBase { + selectedAuthType?: string; + userEmail?: string; +@@ -364,6 +377,8 @@ export type HistoryItemWithoutId = + | HistoryItemWarning + | HistoryItemAbout + | HistoryItemHelp ++ | HistoryItemVoiceHelp ++ | HistoryItemVoiceStatus + | HistoryItemToolGroup + | HistoryItemStats + | HistoryItemModelStats +@@ -403,6 +418,8 @@ export enum MessageType { + MCP_STATUS = 'mcp_status', + CHAT_LIST = 'chat_list', + HINT = 'hint', ++ VOICE_HELP = 'voice_help', ++ VOICE_STATUS = 'voice_status', + } + + // Simplified message structure for internal feedback +@@ -430,6 +447,19 @@ export type Message = + timestamp: Date; + content?: string; // Optional content, not really used for HELP + } ++ | { ++ type: MessageType.VOICE_HELP; ++ timestamp: Date; ++ content?: string; ++ } ++ | { ++ type: MessageType.VOICE_STATUS; ++ timestamp: Date; ++ enabled: boolean; ++ provider: string; ++ sensitivityLabel: string; ++ whisperPath: string; ++ } + | { + type: MessageType.STATS; + timestamp: Date; +diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts +index c6353256e81..fccf9420df4 100644 +--- a/packages/core/src/index.ts ++++ b/packages/core/src/index.ts +@@ -107,7 +107,6 @@ export * from './utils/extensionLoader.js'; + export * from './utils/package.js'; + export * from './utils/version.js'; + export * from './utils/checkpointUtils.js'; +-export * from './utils/secure-browser-launcher.js'; + export * from './utils/apiConversionUtils.js'; + export * from './utils/channel.js'; + export * from './utils/constants.js'; +@@ -203,12 +202,13 @@ export * from './hooks/index.js'; + // Export hook types + export * from './hooks/types.js'; + +-// Export agent types +-export * from './agents/types.js'; +- + // Export stdio utils + export * from './utils/stdio.js'; + export * from './utils/terminal.js'; + + // Export types from @google/genai + export type { Content, Part, FunctionCall } from '@google/genai'; ++// Voice services ++export * from './services/voice/types.js'; ++export { GeminiRestBackend } from './services/voice/GeminiRestBackend.js'; ++export { LocalWhisperBackend } from './services/voice/LocalWhisperBackend.js'; +diff --git a/packages/core/src/services/voice/GeminiRestBackend.ts b/packages/core/src/services/voice/GeminiRestBackend.ts +new file mode 100644 +index 00000000000..5c46eaf8481 +--- /dev/null ++++ b/packages/core/src/services/voice/GeminiRestBackend.ts +@@ -0,0 +1,275 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { type Config } from '../../config/config.js'; ++import { DEFAULT_GEMINI_FLASH_MODEL } from '../../config/models.js'; ++import { coreEvents } from '../../utils/events.js'; ++import { resolveExecutable } from '../../utils/shell-utils.js'; ++import { LlmRole } from '../../telemetry/llmRole.js'; ++import { spawn } from 'node:child_process'; ++import type { VoiceBackend, VoiceBackendOptions } from './types.js'; ++import type { GenerateContentParameters } from '@google/genai'; ++ ++const SAMPLE_RATE = 16000; ++const CHANNELS = 1; ++ ++/** ++ * Voice backend that uses the Gemini API for transcription. ++ * ++ * Records raw PCM audio in-memory via sox or arecord, then builds a WAV ++ * buffer and sends it to the Gemini API using the CLI's existing ++ * ContentGenerator (works with both API key and OAuth auth). ++ * ++ * Note: The recording process uses `spawn` directly rather than `spawnAsync` ++ * because audio capture requires streaming binary stdout chunk-by-chunk in ++ * real-time. `spawnAsync` buffers all output until the process exits, which ++ * is incompatible with the push-to-talk recording pattern. ++ * ++ * Transcripts are delivered via `coreEvents.emitVoiceTranscript()`. ++ */ ++export class GeminiRestBackend implements VoiceBackend { ++ private recordingProcess: ReturnType | null = null; ++ private audioChunks: Buffer[] = []; ++ private isStopping = false; ++ ++ constructor( ++ private readonly options: VoiceBackendOptions, ++ private readonly config: Config, ++ ) {} ++ ++ async start(): Promise { ++ if (this.recordingProcess) return; ++ ++ try { ++ this.isStopping = false; ++ this.audioChunks = []; ++ void this.options.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ ++ const soxPath = await resolveExecutable('sox'); ++ if (soxPath) { ++ this.recordingProcess = spawn(soxPath, [ ++ '-d', ++ '-b', ++ '16', ++ '-r', ++ SAMPLE_RATE.toString(), ++ '-c', ++ CHANNELS.toString(), ++ '-e', ++ 'signed-integer', ++ '-t', ++ 'raw', ++ '-', ++ ]); ++ } else { ++ const arecordPath = await resolveExecutable('arecord'); ++ if (arecordPath) { ++ this.recordingProcess = spawn(arecordPath, [ ++ '-f', ++ 'S16_LE', ++ '-r', ++ SAMPLE_RATE.toString(), ++ '-c', ++ CHANNELS.toString(), ++ '-t', ++ 'raw', ++ '-D', ++ 'default', ++ ]); ++ } else { ++ throw new Error( ++ 'Neither sox nor arecord found.\n' + ++ ' macOS: brew install sox\n' + ++ ' Linux: sudo apt install sox (or: sudo apt install alsa-utils)', ++ ); ++ } ++ } ++ ++ this.recordingProcess.stdout?.on('data', (chunk: Buffer) => { ++ if (!this.isStopping) { ++ this.audioChunks.push(chunk); ++ } ++ }); ++ ++ this.recordingProcess.on('error', (err) => { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: `Recording error: ${err.message}`, ++ }); ++ }); ++ } catch (err) { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: err instanceof Error ? err.message : String(err), ++ }); ++ } ++ } ++ ++ async cancel(): Promise { ++ if (!this.recordingProcess) return; ++ this.isStopping = true; ++ const proc = this.recordingProcess; ++ this.recordingProcess = null; ++ proc.kill('SIGINT'); ++ this.audioChunks = []; ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ } ++ ++ async stop(): Promise { ++ if (!this.recordingProcess) return; ++ ++ this.isStopping = true; ++ const proc = this.recordingProcess; ++ this.recordingProcess = null; ++ proc.kill('SIGINT'); ++ ++ try { ++ const audioBuffer = Buffer.concat(this.audioChunks); ++ if (audioBuffer.length === 0) throw new Error('No audio captured'); ++ ++ // Reject silent recordings before showing the transcribing state, so ++ // the ⏳ indicator only appears when an actual API call will be made. ++ // RMS of 16-bit LE PCM: background noise <200, audible speech ~500+. ++ if (this.isSilentPcm(audioBuffer)) { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ return; ++ } ++ ++ // Signal transcription only now — the ⏳ will be visible for the ++ // full duration of the Gemini API call. Awaiting allows the UI layer ++ // to flush the state change before the network call begins. ++ await this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: true, ++ error: null, ++ }); ++ ++ const wavBuffer = this.createWavBuffer(audioBuffer, SAMPLE_RATE); ++ const transcript = await this.transcribe(wavBuffer); ++ coreEvents.emitVoiceTranscript(transcript); ++ ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ } catch (err) { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: err instanceof Error ? err.message : String(err), ++ }); ++ } finally { ++ await this.cleanup(); ++ } ++ } ++ ++ /** ++ * Builds a WAV buffer from raw 16-bit LE PCM data. ++ * Gemini's audio/wav MIME type requires a valid RIFF header. ++ */ ++ private createWavBuffer(pcmBuffer: Buffer, sampleRate: number): Buffer { ++ const header = Buffer.alloc(44); ++ const dataSize = pcmBuffer.length; ++ ++ header.write('RIFF', 0); ++ header.writeUInt32LE(dataSize + 36, 4); ++ header.write('WAVE', 8); ++ header.write('fmt ', 12); ++ header.writeUInt32LE(16, 16); // format chunk size ++ header.writeUInt16LE(1, 20); // PCM format ++ header.writeUInt16LE(CHANNELS, 22); ++ header.writeUInt32LE(sampleRate, 24); ++ header.writeUInt32LE(sampleRate * CHANNELS * 2, 28); // byte rate ++ header.writeUInt16LE(CHANNELS * 2, 32); // block align ++ header.writeUInt16LE(16, 34); // bits per sample ++ header.write('data', 36); ++ header.writeUInt32LE(dataSize, 40); ++ ++ return Buffer.concat([header, pcmBuffer]); ++ } ++ ++ private async transcribe(audioBuffer: Buffer): Promise { ++ const generator = this.config.getContentGenerator(); ++ if (!generator) throw new Error('Gemini API not initialized'); ++ ++ const request: GenerateContentParameters = { ++ model: DEFAULT_GEMINI_FLASH_MODEL, ++ contents: [ ++ { ++ role: 'user', ++ parts: [ ++ { ++ text: 'Transcribe the following audio exactly as spoken. Output only the transcribed text with no additional commentary or formatting.', ++ }, ++ { ++ inlineData: { ++ mimeType: 'audio/wav', ++ data: audioBuffer.toString('base64'), ++ }, ++ }, ++ ], ++ }, ++ ], ++ }; ++ ++ const response = await generator.generateContent( ++ request, ++ 'voice-transcription', ++ LlmRole.UTILITY_TOOL, ++ ); ++ ++ const parts = response.candidates?.[0]?.content?.parts; ++ return parts?.[0]?.text?.trim() ?? ''; ++ } ++ ++ /** ++ * Returns true if the raw 16-bit LE PCM buffer is effectively silent. ++ * Computes RMS amplitude and compares against options.silenceThreshold ++ * (default 80). A threshold of 0 disables silence detection entirely. ++ * ++ * RMS guide (16-bit PCM, 16 kHz mono): ++ * ~0-30 near-digital silence ++ * ~30-100 quiet room ambient / electrical noise ++ * ~100-400 whispered speech ++ * ~500+ normal conversational speech ++ */ ++ private isSilentPcm(pcmBuffer: Buffer): boolean { ++ const threshold = this.options.silenceThreshold ?? 80; ++ if (threshold === 0) return false; // disabled ++ const samples = pcmBuffer.length / 2; // 16-bit = 2 bytes per sample ++ if (samples === 0) return true; ++ let sumSquares = 0; ++ for (let i = 0; i < pcmBuffer.length - 1; i += 2) { ++ const sample = pcmBuffer.readInt16LE(i); ++ sumSquares += sample * sample; ++ } ++ const rms = Math.sqrt(sumSquares / samples); ++ return rms < threshold; ++ } ++ ++ async cleanup(): Promise { ++ if (this.recordingProcess) { ++ this.recordingProcess.kill('SIGINT'); ++ this.recordingProcess = null; ++ } ++ this.audioChunks = []; ++ } ++} +diff --git a/packages/core/src/services/voice/LocalWhisperBackend.ts b/packages/core/src/services/voice/LocalWhisperBackend.ts +new file mode 100644 +index 00000000000..b86fd43de33 +--- /dev/null ++++ b/packages/core/src/services/voice/LocalWhisperBackend.ts +@@ -0,0 +1,223 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++import { debugLogger } from '../../utils/debugLogger.js'; ++import { tmpdir } from '../../utils/paths.js'; ++import { resolveExecutable, spawnAsync } from '../../utils/shell-utils.js'; ++import { coreEvents } from '../../utils/events.js'; ++import { spawn } from 'node:child_process'; ++import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; ++import { join } from 'node:path'; ++import type { VoiceBackend, VoiceBackendOptions } from './types.js'; ++ ++const RECORDING_FORMAT = 'wav'; ++const SAMPLE_RATE = 16000; ++const CHANNELS = 1; ++ ++/** ++ * Voice backend that records a WAV file via sox/arecord and transcribes ++ * it using a locally-installed Whisper binary (faster-whisper or openai-whisper). ++ * ++ * Note: The recording process uses `spawn` directly rather than `spawnAsync` ++ * because audio capture requires real-time process control (SIGINT to stop). ++ * `spawnAsync` is used for the Whisper transcription step, which is a ++ * standard command-and-result invocation. ++ * ++ * Transcripts are delivered via `coreEvents.emitVoiceTranscript()`. ++ */ ++export class LocalWhisperBackend implements VoiceBackend { ++ private recordingProcess: ReturnType | null = null; ++ private tempDir: string | null = null; ++ private audioFile: string | null = null; ++ ++ constructor( ++ private readonly options: VoiceBackendOptions, ++ private readonly config: { whisperPath?: string } = {}, ++ ) {} ++ ++ async start(): Promise { ++ if (this.recordingProcess) { ++ debugLogger.log('LocalWhisperBackend: already recording'); ++ return; ++ } ++ ++ try { ++ void this.options.onStateChange({ ++ isRecording: true, ++ isTranscribing: false, ++ error: null, ++ }); ++ ++ this.tempDir = await mkdtemp(join(tmpdir(), 'gemini-voice-')); ++ this.audioFile = join(this.tempDir, `recording.${RECORDING_FORMAT}`); ++ ++ const soxPath = await resolveExecutable('sox'); ++ if (soxPath) { ++ this.recordingProcess = spawn(soxPath, [ ++ '-d', ++ '-b', ++ '16', ++ '-r', ++ SAMPLE_RATE.toString(), ++ '-c', ++ CHANNELS.toString(), ++ '-e', ++ 'signed-integer', ++ '-t', ++ RECORDING_FORMAT, ++ this.audioFile, ++ ]); ++ } else { ++ const arecordPath = await resolveExecutable('arecord'); ++ if (arecordPath) { ++ this.recordingProcess = spawn(arecordPath, [ ++ '-f', ++ 'S16_LE', ++ '-r', ++ SAMPLE_RATE.toString(), ++ '-c', ++ CHANNELS.toString(), ++ '-D', ++ 'default', ++ this.audioFile, ++ ]); ++ } else { ++ throw new Error( ++ 'Neither sox nor arecord found. Please install one of them.', ++ ); ++ } ++ } ++ ++ this.recordingProcess.on('error', (err) => { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: `Recording error: ${err.message}`, ++ }); ++ }); ++ } catch (err) { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: err instanceof Error ? err.message : String(err), ++ }); ++ } ++ } ++ ++ async cancel(): Promise { ++ if (!this.recordingProcess) return; ++ const proc = this.recordingProcess; ++ this.recordingProcess = null; ++ proc.kill('SIGINT'); ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ await this.cleanup(); ++ } ++ ++ async stop(): Promise { ++ if (!this.recordingProcess) return; ++ ++ const proc = this.recordingProcess; ++ this.recordingProcess = null; ++ proc.kill('SIGINT'); ++ ++ // Wait for the recording process to fully close (stdio streams flushed) ++ // before reading the audio file. ++ await new Promise((resolve) => { ++ proc.on('close', resolve); ++ }); ++ ++ await this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: true, ++ error: null, ++ }); ++ ++ try { ++ if (!this.audioFile) throw new Error('No audio file'); ++ ++ const stats = await stat(this.audioFile).catch(() => null); ++ if (!stats || stats.size === 0) ++ throw new Error('No audio recorded (file is empty)'); ++ ++ const transcript = await this.transcribe(this.audioFile); ++ coreEvents.emitVoiceTranscript(transcript); ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: null, ++ }); ++ } catch (err) { ++ void this.options.onStateChange({ ++ isRecording: false, ++ isTranscribing: false, ++ error: err instanceof Error ? err.message : String(err), ++ }); ++ } finally { ++ await this.cleanup(); ++ } ++ } ++ ++ private async transcribe(audioFile: string): Promise { ++ // Reject paths with shell metacharacters that could enable injection ++ const validatePath = (p: string): string => { ++ const sanitized = p.replace(/['"]/g, ''); ++ if (/[;&|`$(){}[\]<>!]/.test(sanitized)) ++ throw new Error('Invalid binary path: contains shell metacharacters'); ++ return sanitized; ++ }; ++ ++ const args = [ ++ audioFile, ++ '--model', ++ 'tiny', ++ '--output_format', ++ 'txt', ++ '--output_dir', ++ this.tempDir!, ++ ]; ++ ++ if (this.config.whisperPath) { ++ await spawnAsync(validatePath(this.config.whisperPath), args); ++ } else { ++ try { ++ await spawnAsync('whisper-faster', args); ++ } catch { ++ try { ++ await spawnAsync('whisper', args); ++ } catch { ++ throw new Error( ++ 'Whisper not found. Please install faster-whisper or openai-whisper, ' + ++ 'or configure the path in settings.', ++ ); ++ } ++ } ++ } ++ ++ const transcriptFile = audioFile.replace('.wav', '.txt'); ++ const raw = await readFile(transcriptFile, 'utf-8'); ++ return raw ++ .split('\n') ++ .map((l) => ++ l ++ .replace(/^\[\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}\.\d{3}\]\s*/, '') ++ .trim(), ++ ) ++ .filter(Boolean) ++ .join(' '); ++ } ++ ++ async cleanup(): Promise { ++ if (this.tempDir) { ++ await rm(this.tempDir, { recursive: true, force: true }).catch(() => {}); ++ this.tempDir = null; ++ this.audioFile = null; ++ } ++ } ++} +diff --git a/packages/core/src/services/voice/types.ts b/packages/core/src/services/voice/types.ts +new file mode 100644 +index 00000000000..b881d58b03a +--- /dev/null ++++ b/packages/core/src/services/voice/types.ts +@@ -0,0 +1,44 @@ ++/** ++ * @license ++ * Copyright 2025 Google LLC ++ * SPDX-License-Identifier: Apache-2.0 ++ */ ++ ++export interface VoiceInputState { ++ isRecording: boolean; ++ isTranscribing: boolean; ++ error: string | null; ++} ++ ++export interface VoiceBackend { ++ start(): Promise; ++ stop(): Promise; ++ /** Cancel recording without transcribing (e.g., user pressed Escape). */ ++ cancel(): Promise; ++ cleanup(): Promise; ++} ++ ++/** ++ * Options passed to a VoiceBackend at construction time. ++ * Transcripts are delivered via coreEvents (CoreEvent.VoiceTranscript) ++ * rather than a callback, to align with the project's cross-service ++ * communication pattern. ++ */ ++export interface VoiceBackendOptions { ++ onStateChange: (state: VoiceInputState) => void | Promise; ++ /** ++ * RMS energy threshold below which audio is treated as silence and ++ * discarded without an API call. 0 disables silence detection entirely. ++ * Default: 80 (allows whispered speech; blocks near-silence). ++ */ ++ silenceThreshold?: number; ++} ++ ++export interface VoiceInputReturn { ++ isEnabled: boolean; ++ state: VoiceInputState; ++ startRecording: () => Promise; ++ stopRecording: () => Promise; ++ cancelRecording: () => Promise; ++ toggleRecording: () => Promise; ++} +diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts +index 159dde2a6df..41b45029a4f 100644 +--- a/packages/core/src/utils/events.ts ++++ b/packages/core/src/utils/events.ts +@@ -182,21 +182,21 @@ export enum CoreEvent { + ConsentRequest = 'consent-request', + McpProgress = 'mcp-progress', + AgentsDiscovered = 'agents-discovered', +- RequestEditorSelection = 'request-editor-selection', +- EditorSelected = 'editor-selected', +- SlashCommandConflicts = 'slash-command-conflicts', +- QuotaChanged = 'quota-changed', +- TelemetryKeychainAvailability = 'telemetry-keychain-availability', +- TelemetryTokenStorageType = 'telemetry-token-storage-type', ++RequestEditorSelection = 'request-editor-selection', ++EditorSelected = 'editor-selected', ++SlashCommandConflicts = 'slash-command-conflicts', ++QuotaChanged = 'quota-changed', ++TelemetryKeychainAvailability = 'telemetry-keychain-availability', ++TelemetryTokenStorageType = 'telemetry-token-storage-type', ++VoiceTranscript = 'voice-transcript', + } + + /** +- * Payload for the 'editor-selected' event. +- */ ++* Payload for the 'editor-selected' event. ++*/ + export interface EditorSelectedPayload { +- editor?: EditorType; ++editor?: EditorType; + } +- + export interface CoreEvents extends ExtensionEvents { + [CoreEvent.UserFeedback]: [UserFeedbackPayload]; + [CoreEvent.ModelChanged]: [ModelChangedPayload]; +@@ -221,6 +221,7 @@ export interface CoreEvents extends ExtensionEvents { + [CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload]; + [CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent]; + [CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent]; ++ [CoreEvent.VoiceTranscript]: [string]; + } + + type EventBacklogItem = { +@@ -409,6 +410,10 @@ export class CoreEventEmitter extends EventEmitter { + emitTelemetryTokenStorageType(event: TokenStorageInitializationEvent): void { + this._emitOrQueue(CoreEvent.TelemetryTokenStorageType, event); + } ++ ++ emitVoiceTranscript(transcript: string): void { ++ this.emit(CoreEvent.VoiceTranscript, transcript); ++ } + } + + export const coreEvents = new CoreEventEmitter(); +diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json +index a0ef69eab55..ae7a4beb4dc 100644 +--- a/schemas/settings.schema.json ++++ b/schemas/settings.schema.json +@@ -475,6 +475,42 @@ + }, + "additionalProperties": false + }, ++ "voice": { ++ "title": "Voice Input", ++ "description": "Settings for voice input.", ++ "markdownDescription": "Settings for voice input.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`", ++ "default": {}, ++ "type": "object", ++ "properties": { ++ "enabled": { ++ "title": "Enable Voice Input", ++ "description": "Enable voice input support.", ++ "markdownDescription": "Enable voice input support.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", ++ "default": false, ++ "type": "boolean" ++ }, ++ "provider": { ++ "title": "Transcription Backend", ++ "description": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).", ++ "markdownDescription": "Transcription backend: \"gemini\" (default, zero-install) or \"whisper\" (local).\n\n- Category: `General`\n- Requires restart: `no`", ++ "type": "string" ++ }, ++ "whisperPath": { ++ "title": "Whisper Binary Path", ++ "description": "Path to the whisper executable. Only used when provider is \"whisper\".", ++ "markdownDescription": "Path to the whisper executable. Only used when provider is \"whisper\".\n\n- Category: `General`\n- Requires restart: `no`", ++ "type": "string" ++ }, ++ "silenceThreshold": { ++ "title": "Silence Detection Threshold", ++ "description": "RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values allow quieter speech such as whispering. 0 disables silence detection.", ++ "markdownDescription": "RMS energy threshold (0–1000) below which audio is discarded as silence. Lower values allow quieter speech such as whispering. 0 disables silence detection.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `80`", ++ "default": 80, ++ "type": "number" ++ } ++ }, ++ "additionalProperties": false ++ }, + "ide": { + "title": "IDE", + "description": "IDE integration settings.", diff --git a/evals_output.log b/evals_output.log new file mode 100644 index 00000000000..88a2a8c70cd --- /dev/null +++ b/evals_output.log @@ -0,0 +1,40 @@ + +> @google/gemini-cli@0.34.0-nightly.20260304.28af4e127 test:always_passing_evals +> vitest run --config evals/vitest.config.ts + + + RUN v3.2.4 /home/fayerman/gemini-cli + + ↓ evals/generalist_agent.eval.ts (1 test | 1 skipped) + ↓ evals/automated-tool-use.eval.ts (2 tests | 2 skipped) + ↓ evals/concurrency-safety.eval.ts (1 test | 1 skipped) + ↓ evals/frugalSearch.eval.ts (1 test | 1 skipped) + ↓ evals/frugalReads.eval.ts (3 tests | 3 skipped) + ↓ evals/ask_user.eval.ts (4 tests | 4 skipped) + ↓ evals/edit-locations-eval.eval.ts (1 test | 1 skipped) +Initialized empty Git repository in /tmp/gemini-cli-tests/should-not-edit-files-when-asked-a-general-question/.git/ +Initialized empty Git repository in /tmp/gemini-cli-tests/should-refuse-file-modification-when-in-plan-mode/.git/ +Initialized empty Git repository in /tmp/gemini-cli-tests/should-not-git-add-commit-changes-unprompted/.git/ +[main (root-commit) 831fc43] Initial commit + 3 files changed, 31 insertions(+) + create mode 100644 .gemini/settings.json + create mode 100644 README.md + create mode 120000 node_modules +[main (root-commit) 1744b3a] Initial commit + 5 files changed, 30 insertions(+) + create mode 100644 .gemini/settings.json + create mode 100644 .gitignore + create mode 100644 index.test.ts + create mode 100644 index.ts + create mode 100644 package.json +[main (root-commit) b6577d3] Initial commit + 4 files changed, 29 insertions(+) + create mode 100644 .gemini/settings.json + create mode 100644 app.ts + create mode 120000 node_modules + create mode 100644 package.json + ↓ evals/subagents.eval.ts (1 test | 1 skipped) + ↓ evals/grep_search_functionality.eval.ts (6 tests | 6 skipped) + ↓ evals/validation_fidelity_pre_existing_errors.eval.ts (1 test | 1 skipped) + ↓ evals/validation_fidelity.eval.ts (1 test | 1 skipped) + ↓ evals/tool_output_masking.eval.ts (2 tests | 2 skipped) diff --git a/google-gemini-cli-0.30.0-nightly.20260210.a2174751d.tgz b/google-gemini-cli-0.30.0-nightly.20260210.a2174751d.tgz new file mode 100644 index 0000000000000000000000000000000000000000..cfadd1a2f1b3d18b9ac041bb6c72a00f71989697 GIT binary patch literal 6798405 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

!;8D(l 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