Skip to content

feat: voice dictation (speech-to-text)#557

Merged
kevincodex1 merged 3 commits into
mainfrom
feat/voice-dictation-clean
Jul 7, 2026
Merged

feat: voice dictation (speech-to-text)#557
kevincodex1 merged 3 commits into
mainfrom
feat/voice-dictation-clean

Conversation

@anandh8x

@anandh8x anandh8x commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Speech-to-text dictation

Adds voice dictation to Zero. Run /voice to enter hold-to-record mode, then hold Space to dictate and release to transcribe (run /voice again to turn it off so Space types normally). Pick a backend with /stt-model.

Backends

  • Local (sherpa-onnx) — offline, no API key. Auto-downloads a model via /stt-model; the engine binary is fetched once and shared across models. Family auto-detection covers the mainstream offline families (Whisper, transducer/Zipformer, Moonshine, Paraformer, SenseVoice, and the CTC families FireRedASR, NeMo, TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual — plus the encoder/decoder families Canary and FireRedASR-AED).
  • Cloud batch — Groq and OpenAI Whisper. Keys resolve from a configured provider, the encrypted credential store, or the env var; a missing/invalid key is entered inline.
  • Streaming — a warm local sherpa-onnx websocket server (live transcript as you speak) on desktop, or Deepgram / OpenAI Realtime.

Highlights

  • Live audio-reactive waveform while recording (real mic levels when streaming; a synthesized wave for batch).
  • /stt-model download chooser: recommended models first, Live/Batch tag per row, size + download state, and a search filter.
  • Streaming transcript is inserted live into the composer and committed on stop; batch inserts on stop. Insert-for-review by default (stt.autoSubmit to fire straight at the agent).
  • The warm streaming server is released when /voice is turned off and on exit.

Implementation notes

  • No CGO and no bundled runtimes — every OS integration shells out via exec.Command with discrete argv (never a shell string).
  • The streaming server manager mirrors the LSP manager's warm-subprocess lifecycle.
  • OS-boundary code (recorders, transcribers, server) is unit-tested via injected fakes; real microphone / websocket behavior is covered by env-gated smoke tests.

Testing

  • go build ./..., go vet ./..., and the config / dictation / cli / tui suites pass.
  • Verified end-to-end against real downloaded binaries + models (offline batch, streaming websocket server, and the model auto-download), including the FireRedASR-CTC and NeMo streaming transducer families.

Summary by CodeRabbit

  • New Features
    • Added speech-to-text dictation with local offline transcription, cloud batch transcription, and cloud streaming options.
    • Introduced an in-TUI STT model picker with local (sherpa-onnx) download, deterministic progress/status, and installed-state awareness.
    • Added voice mode for Space press/hold recording plus an inline cloud API-key prompt with saved-key persistence.
  • Bug Fixes
    • Strengthened STT configuration validation and merging so invalid providers/stream providers and out-of-range settings are rejected early with clearer guidance.
    • Improved dictation streaming partial/region handling and safer STT server warm-up/shutdown behavior.

Adds voice dictation to Zero. Run `/voice` to enter hold-to-record mode, then hold **Space** to dictate and release to transcribe (run `/voice` again to turn it off so Space types normally). Pick a backend with `/stt-model`.

### Backends
- **Local (sherpa-onnx)** — offline, no API key. Auto-downloads a model via `/stt-model`; the engine binary is fetched once and shared across models. Family auto-detection covers the mainstream offline families (Whisper, transducer/Zipformer, Moonshine, Paraformer, SenseVoice, and the CTC families FireRedASR, NeMo, TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual — plus the encoder/decoder families Canary and FireRedASR-AED).
- **Cloud batch** — Groq and OpenAI Whisper. Keys resolve from a configured provider, the encrypted credential store, or the env var; a missing/invalid key is entered inline.
- **Streaming** — a warm local sherpa-onnx websocket server (live transcript as you speak) on desktop, or Deepgram / OpenAI Realtime.

### Highlights
- Live audio-reactive waveform while recording (real mic levels when streaming; a synthesized wave for batch).
- `/stt-model` download chooser: recommended models first, Live/Batch tag per row, size + download state, and a search filter.
- Streaming transcript is inserted live into the composer and committed on stop; batch inserts on stop. Insert-for-review by default (`stt.autoSubmit` to fire straight at the agent).
- The warm streaming server is released when `/voice` is turned off and on exit.

### Implementation notes
- No CGO and no bundled runtimes — every OS integration shells out via `exec.Command` with discrete argv (never a shell string).
- The streaming server manager mirrors the LSP manager's warm-subprocess lifecycle.
- OS-boundary code (recorders, transcribers, server) is unit-tested via injected fakes; real microphone / websocket behavior is covered by env-gated smoke tests.

### Testing
- `go build ./...`, `go vet ./...`, and the config / dictation / cli / tui suites pass.
- Verified end-to-end against real downloaded binaries + models (offline batch, streaming websocket server, and the model auto-download), including the FireRedASR-CTC and NeMo streaming transducer families.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added speech-to-text dictation with both batch and streaming modes, supporting local (sherpa-onnx) and cloud providers, including WAV/M4A detection.
  * Introduced voice dictation mode with STT model selection, local engine/model auto-download, and an inline prompt to save cloud API keys.
  * Added new TUI commands for voice/STT model selection and enabled F1–F12 key bindings.
* **Bug Fixes**
  * Improved streaming dictation to preserve accumulated transcript partials and keep the editor region consistent.
  * Refined TUI status/picker rendering, including clearer loading/search behavior and more reliable selection alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 8b78a3bac1b9
Changed files (55): go.mod, go.sum, internal/cli/app.go, internal/cli/dictation.go, internal/cli/dictation_test.go, internal/config/resolver.go, internal/config/stt_test.go, internal/config/types.go, internal/config/validate.go, internal/config/writer.go, internal/dictation/dictation.go, internal/dictation/download.go, and 43 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4064ccd6-3e81-4274-adbe-c6109f8a39a2

📥 Commits

Reviewing files that changed from the base of the PR and between b202719 and 8b78a3b.

📒 Files selected for processing (4)
  • internal/dictation/recorder.go
  • internal/tui/dictation.go
  • internal/tui/dictation_stream.go
  • internal/tui/model.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/tui/dictation_stream.go
  • internal/dictation/recorder.go
  • internal/tui/dictation.go
  • internal/tui/model.go

📝 Walkthrough

Walkthrough

This PR adds STT configuration, dictation runtime primitives, local/cloud/streaming transcribers, warm server and auto-download support, CLI wiring, and TUI flows for dictation, voice mode, model selection, downloads, and API-key prompts.

Changes

Speech-to-Text Dictation Feature

Layer / File(s) Summary
STT config and model filtering
internal/config/types.go, internal/config/resolver.go, internal/config/validate.go, internal/config/writer.go, internal/config/stt_test.go, internal/providermodelcatalog/filter.go, internal/providermodelcatalog/stt_filter_test.go
Adds STT config types, merges, validation, persistence, and STT model classification helpers with tests.
Recorder, subprocess, spill, and server/download bootstrap
internal/dictation/recorder.go, internal/dictation/runner.go, internal/dictation/spill.go, internal/dictation/owner_*.go, internal/dictation/server.go, internal/dictation/download.go, *_test.go, smoke tests
Adds recorder capture, subprocess helpers, temp spill hardening, warm server lifecycle, and engine/model download and extraction.
Cloud and local transcribers
internal/dictation/transcriber.go, internal/dictation/transcriber_cloud.go, internal/dictation/transcriber_deepgram.go, internal/dictation/transcriber_local.go, internal/dictation/transcriber_local_stream.go, internal/dictation/transcriber_openai_realtime.go, internal/dictation/transcriber_*.go, internal/dictation/listmodels_test.go, internal/providermodelcatalog/filter.go, internal/providermodelcatalog/stt_filter_test.go
Adds cloud batch, Deepgram, OpenAI Realtime, local offline, and local streaming transcribers with parsing helpers and tests.
CLI dictation factory and wiring
internal/cli/app.go, internal/cli/dictation.go, internal/cli/dictation_test.go, go.mod
Wires STT runtime setup into the CLI and adds dictation factory/key-resolution logic.
TUI dictation core and status UI
internal/tui/dictation.go, internal/tui/model.go, internal/tui/keybindings.go, internal/tui/commands.go, internal/tui/view.go, internal/tui/autocomplete_test.go, internal/tui/dictation_test.go
Adds the dictation controller, waveform/status rendering, and command/keybinding integration across the TUI.
TUI streaming dictation and voice mode
internal/tui/dictation_stream.go, internal/tui/dictation_voice.go, internal/tui/dictation_voice_test.go
Adds live streaming transcript updates and hold-to-record voice mode handling.
TUI download progress and application
internal/tui/dictation_download.go, internal/tui/dictation_download_test.go
Adds live download progress messaging and applies downloaded engine components to config.
STT pickers, API-key prompt, and overlay UI
internal/tui/stt_model_picker.go, internal/tui/stt_key_prompt.go, internal/tui/picker.go, internal/tui/mouse.go, internal/tui/mouse_test.go, internal/tui/view.go, internal/tui/stt_key_prompt_test.go
Adds /stt-model and /voice, STT model/download pickers, inline key prompting, and picker overlay interaction updates.

Estimated code review effort: 5 (Critical) | ~150 minutes

Possibly related PRs

  • Gitlawb/zero#52: Extends the same internal/config/resolver.go STT merge/validation path used here.
  • Gitlawb/zero#163: Also changes runInteractiveTUIWithSetup and internal/tui/options.go wiring.
  • Gitlawb/zero#222: Touches the same internal/tui/model.go transcript rendering path.

Suggested reviewers: Vasanthdev2004, gnanam1990

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding voice dictation/speech-to-text support.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voice-dictation-clean

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (9)
internal/dictation/dictation.go (1)

26-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep one STT provider allowlist BatchProviders() / StreamProviders() are labeled “for config validation,” but internal/config/resolver.go still validates with its own hardcoded switch. That leaves two lists to keep in sync; make one canonical source or drop the validation claim from these comments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/dictation/dictation.go` around lines 26 - 48, Make the STT provider
allowlist use a single canonical source instead of duplicating validation logic:
update the config validation in resolver.go to consume BatchProviders() and
StreamProviders() from internal/dictation/dictation.go, or remove the “for
config validation” claim from those helpers if resolver.go must remain the
source of truth. Keep the provider constants and the
BatchProviders()/StreamProviders() symbols as the authoritative names so future
provider additions only need one update path.
internal/dictation/transcriber_stream_test.go (1)

34-55: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dead gotAuth — the Deepgram auth-header contract is never actually asserted. The handler never reads Authorization, so gotAuth stays empty and _ = gotAuth just silences the unused-var error. Either capture and assert the Token <key> header inside the handler, or drop the variable to avoid implying coverage that doesn't exist.

♻️ Assert the header instead of discarding it
 	url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
 		defer c.Close(websocket.StatusNormalClosure, "")

Note: wsTestServer doesn't currently expose the upgrade request headers to the handler, so asserting Authorization would require threading *http.Request (or its headers) through the handler signature.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/dictation/transcriber_stream_test.go` around lines 34 - 55, The test
currently leaves gotAuth unused and does not verify the Deepgram Authorization
header, so the auth contract is untested. Update wsTestServer and the anonymous
handler in transcriber_stream_test.go so the upgrade request headers (or
*http.Request) are available, capture the Authorization value during
NewDeepgramTranscriber handshake, and assert it equals the expected Token
header; if you do not plan to expose headers, remove gotAuth and the dummy
assignment instead of keeping dead coverage.
internal/dictation/download_test.go (1)

101-150: 📐 Maintainability & Code Quality | 🔵 Trivial

Consider adding a regression test for the "truncated-file-treated-as-installed" scenario.

Once the atomic-extraction fix from download.go lands, a test that writes a truncated bin/sherpa-onnx-offline into engineDir before calling EnsureLocalEngine (asserting it does NOT get treated as installed) would guard against regressing back to the check-then-act idempotency bug.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/dictation/download_test.go` around lines 101 - 150, Add a regression
test for the truncated-engine cache case in
TestEnsureLocalEngineDownloadsAndExtracts or a nearby test helper: write a
partial bin/sherpa-onnx-offline into the target engineDir before calling
EnsureLocalEngine, then verify it is not treated as an already-installed engine
and that the code re-downloads/re-extracts instead of returning the truncated
binary. Use EnsureLocalEngine, DownloadOptions, and the existing
fakeReleaseServer setup to keep the test aligned with the current download flow.
internal/dictation/download.go (1)

216-249: 🚀 Performance & Scalability | 🔵 Trivial

Pagination heuristic relies on a cumulative modulo instead of the Link header.

The loop continues fetching while len(assets)%100==0, which happens to work for the current release sizes (the asr-models release currently has 460 assets, confirmed via GitHub), but it's fragile: it's guessing "there might be more" from a cumulative count rather than reading the standard Link: rel="next" response header GitHub provides for paginated endpoints. Following Link would be more robust against future changes in how many assets the inline release payload embeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/dictation/download.go` around lines 216 - 249, The asset pagination
in ListModels is using a brittle len(assets)%100 heuristic instead of the GitHub
pagination metadata. Update the pagination logic in ListModels to follow the
response Link header for rel="next" when fetching release assets, rather than
inferring more pages from the cumulative asset count. Keep the existing
ListModels/getJSON flow, but have the asset-fetching loop continue only while a
next-page link is present so future changes to inline asset counts do not break
pagination.
internal/config/stt_test.go (1)

1-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add direct test coverage for SetSTTProvider.

SetSTTModel and SetSTTLocalEngine each get dedicated persistence tests, but SetSTTProvider (writer.go Lines 255-277) has none here, despite being used to persist the "local" provider selection from the picker flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/stt_test.go` around lines 1 - 140, Add direct test coverage
for SetSTTProvider in the STT config tests, since it currently has no
persistence assertion even though it is used by the picker flow. Add a focused
test alongside TestSetSTTModelPersists and
TestSetSTTLocalEngineResetsStreamProvider that calls SetSTTProvider, then
reloads the saved config with ValidateFile or ValidateBytes and verifies the STT
provider is persisted as local (and any related stream/provider fields remain
correct). Use the SetSTTProvider symbol to locate the implementation and keep
the new test consistent with the existing writer.go persistence coverage.
internal/config/writer.go (1)

179-277: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared config-load boilerplate.

SetSTTModel, SetSTTLocalEngine, and SetSTTProvider each repeat the same 13-line "read file, unmarshal, treat ENOENT as empty" block already present in SetTheme. Worth factoring into one helper now that there are 4 copies.

♻️ Suggested refactor
+func loadConfigOrEmpty(path string) (FileConfig, error) {
+	cfg := FileConfig{}
+	if data, err := os.ReadFile(path); err == nil {
+		if err := json.Unmarshal(data, &cfg); err != nil {
+			return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
+		}
+	} else if !os.IsNotExist(err) {
+		return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
+	}
+	return cfg, nil
+}

Then each SetSTT* function becomes cfg, err := loadConfigOrEmpty(path) + the field-specific mutation + validate + write.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/writer.go` around lines 179 - 277, The three STT writers
duplicate the same “trim path, read config, unmarshal JSON, ignore missing file”
boilerplate that already exists in SetTheme. Extract that repeated load logic
into a shared helper (for example, alongside writeConfigFile) and update
SetSTTModel, SetSTTLocalEngine, and SetSTTProvider to call it, then keep only
their field-specific mutations, validation, and write steps.
internal/tui/dictation_voice_test.go (1)

59-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing positive-case coverage for keyboard enhancement detection.

Only the unsupported (Flags: 0) branch is tested. Since this flag determines hold-to-record vs. toggle-fallback for the whole voice-mode UX, add a case with the event-types bit set to assert eventTypesSupported == true.

✅ Suggested additional test case
func TestKeyboardEnhancementsRecordedSupported(t *testing.T) {
	m := model{dictation: batchOnlyController()}
	m = m.handleKeyboardEnhancements(tea.KeyboardEnhancementsMsg{Flags: int(ansi.KittyReportEventTypes)})
	if !m.dictation.eventTypesKnown || !m.dictation.eventTypesSupported {
		t.Error("event-types flag set should be known-and-supported")
	}
}

Please confirm the exact flag constant name (ansi.KittyReportEventTypes or equivalent) used elsewhere in this codebase for constructing this message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/dictation_voice_test.go` around lines 59 - 66, Add positive-case
coverage for keyboard enhancement detection in TestKeyboardEnhancementsRecorded
by creating a second test (or extending the existing one) that calls
model.handleKeyboardEnhancements with the event-types bit set and asserts
dictation.eventTypesKnown and dictation.eventTypesSupported are both true. Use
the same KeyboardEnhancementsMsg flow and the exact flag constant already used
elsewhere for the event-types bit (for example ansi.KittyReportEventTypes or the
project’s equivalent) so the test matches the production message construction.
internal/tui/dictation_test.go (1)

216-229: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test mirrors the guard instead of exercising it.

transcribeBatchCtx duplicates the nil-check from transcribeBatchCmd rather than calling the real function; a regression in the actual guard wouldn't be caught by this test.

If transcribeBatchCmd's ctx-normalization can be extracted into a small exported-for-test helper (or the command itself tested via its returned tea.Cmd), prefer that over a hand-duplicated copy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/dictation_test.go` around lines 216 - 229, The test currently
duplicates the nil-guard logic instead of verifying the real behavior in
transcribeBatchCmd, so a regression in the command would be missed. Refactor the
ctx-normalization out of transcribeBatchCmd into a small helper that the test
can call directly, or change TestDictationBatchCtxNonNil to exercise the
command’s returned tea.Cmd path. Remove the hand-copied transcribeBatchCtx
helper and keep the assertion tied to the actual implementation symbols
transcribeBatchCmd and dictationController.
internal/tui/dictation_download_test.go (1)

24-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for the two behavioral gaps flagged in dictation_download.go/stt_model_picker.go.

Once fixed, add tests for: (1) selecting a second download variant while m.dictation.downloading is true is a no-op, and (2) opening the local-model download picker then cancelling (Esc) does not persist Provider=local over a previously-working cloud provider — mirroring TestCloudKeyPromptCancelKeepsPreviousProvider.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/dictation_download_test.go` around lines 24 - 42, Add regression
tests covering the two dictation download picker gaps in
`model.toggleDictation`, `dictation_download.go`, and `stt_model_picker.go`. Add
one test that verifies choosing a second download variant while
`m.dictation.downloading` is already true is ignored and leaves the current
state unchanged. Add another test that opens the local-model download picker,
cancels with Esc, and confirms `Provider=local` is not persisted over an
existing working cloud provider, mirroring the existing cloud cancel behavior
test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/resolver.go`:
- Around line 797-823: validateSTTConfig currently validates provider,
streamProvider, maxDurationSeconds, and localServerPort but misses NumThreads,
allowing negative values to slip through. Update validateSTTConfig in STTConfig
to add a bounds check for cfg.NumThreads and return a clear fmt.Errorf when it
is negative, matching the existing startup-validation style and keeping the
error message specific to stt.numThreads.

In `@internal/dictation/download.go`:
- Around line 454-555: EnsureLocalEngine currently trusts fileExists(binPath)
after extraction, but a partial extract can leave a truncated engine binary in
the final directory and falsely look installed. Update the install flow around
downloadVerifyExtract and resolveEnginePaths so extraction happens in a
temporary staging directory first, then atomically move or rename the completed
tree into engineDir only after the full extraction succeeds. Make sure the final
presence check in EnsureLocalEngine only observes the staged-then-promoted
result, not partially written files.

In `@internal/dictation/runner.go`:
- Around line 48-70: The startProcess function leaves pipe file descriptors open
if cmd.Start fails after StdoutPipe or StdinPipe has already allocated them.
Update startProcess to track any opened stdout/stdin pipe ends and close them
before returning the start error, using the existing cmd, stdout, and stdin
handling to ensure no leaked descriptors remain on failure.

In `@internal/dictation/server.go`:
- Around line 136-149: The Shutdown method in ServerManager ignores its ctx
argument, so it can block past the caller’s deadline. Update Shutdown(ctx
context.Context) to honor ctx by wiring it into the graceful-stop/wait path, or
by deriving the timeout from ctx and using it instead of the fixed stopGrace in
proc.StopGracefully and waitWithTimeout. Keep the behavior in
ServerManager.Shutdown consistent with the caller’s shutdown budget while
preserving the existing proc lifecycle handling.

In `@internal/dictation/spill.go`:
- Around line 24-42: The audioSpillDir() path only verifies ownership after
MkdirAll, so an existing spill directory can keep loose permissions. Update
audioSpillDir() to also inspect and enforce the directory mode after os.Lstat,
tightening any pre-existing directory to 0o700 or returning an error if it is
not sufficiently restricted. Keep the check alongside checkAudioDirOwner so both
ownership and permissions are validated for the existing audio spill directory.

In `@internal/tui/dictation_download.go`:
- Around line 41-70: The dictation download flow currently allows a second
download to start while one is already in progress. Add a guard in
startVariantDownload and handleSTTDownloadSelection to check the existing
downloading state before calling EnsureLocalEngine, and short-circuit with a
user-facing notice or no-op if a download is active. Keep the existing
canOfferDownload behavior consistent by reusing the same m.dictation.downloading
/ d.downloading check so reopening the model picker or retrying cannot trigger
overlapping downloads against the same destination.

In `@internal/tui/stt_model_picker.go`:
- Around line 117-143: The local STT picker flow in maybeOpenSTTDownloadPicker
commits config too early by setting Provider to local and persisting it before
the user selects a model, which breaks the cancel-preserves-provider behavior.
Move the provider commit to the successful-selection path by relying on
applyEngineComponents as the only place that applies the local provider/model,
matching the cloud flow’s commit-on-success pattern. Also stop discarding the
SetSTTProvider error in maybeOpenSTTDownloadPicker and handle or surface it
consistently with the rest of the file.
- Around line 263-265: The isCurrent helper in stt_model_picker.go is matching
currentPath with strings.Contains, which can incorrectly mark a variant as
current when DirName is only a substring of another path segment. Update
isCurrent to compare against path segments or a normalized path boundary using
filepath so it only returns true when currentPath actually contains the exact
variant DirName, and keep the check localized to the ModelVariant row selection
logic.
- Around line 162-169: The STT model lookup in fetchSTTModelsCmd uses
context.Background(), so a stalled GitHub request can hang the picker
indefinitely. Update fetchSTTModelsCmd to create and pass a context with timeout
into dictation.ListModels, and make sure the timeout is long enough for the
GitHub round-trip but still guarantees the tea.Cmd returns with an error if the
request stalls.

---

Nitpick comments:
In `@internal/config/stt_test.go`:
- Around line 1-140: Add direct test coverage for SetSTTProvider in the STT
config tests, since it currently has no persistence assertion even though it is
used by the picker flow. Add a focused test alongside TestSetSTTModelPersists
and TestSetSTTLocalEngineResetsStreamProvider that calls SetSTTProvider, then
reloads the saved config with ValidateFile or ValidateBytes and verifies the STT
provider is persisted as local (and any related stream/provider fields remain
correct). Use the SetSTTProvider symbol to locate the implementation and keep
the new test consistent with the existing writer.go persistence coverage.

In `@internal/config/writer.go`:
- Around line 179-277: The three STT writers duplicate the same “trim path, read
config, unmarshal JSON, ignore missing file” boilerplate that already exists in
SetTheme. Extract that repeated load logic into a shared helper (for example,
alongside writeConfigFile) and update SetSTTModel, SetSTTLocalEngine, and
SetSTTProvider to call it, then keep only their field-specific mutations,
validation, and write steps.

In `@internal/dictation/dictation.go`:
- Around line 26-48: Make the STT provider allowlist use a single canonical
source instead of duplicating validation logic: update the config validation in
resolver.go to consume BatchProviders() and StreamProviders() from
internal/dictation/dictation.go, or remove the “for config validation” claim
from those helpers if resolver.go must remain the source of truth. Keep the
provider constants and the BatchProviders()/StreamProviders() symbols as the
authoritative names so future provider additions only need one update path.

In `@internal/dictation/download_test.go`:
- Around line 101-150: Add a regression test for the truncated-engine cache case
in TestEnsureLocalEngineDownloadsAndExtracts or a nearby test helper: write a
partial bin/sherpa-onnx-offline into the target engineDir before calling
EnsureLocalEngine, then verify it is not treated as an already-installed engine
and that the code re-downloads/re-extracts instead of returning the truncated
binary. Use EnsureLocalEngine, DownloadOptions, and the existing
fakeReleaseServer setup to keep the test aligned with the current download flow.

In `@internal/dictation/download.go`:
- Around line 216-249: The asset pagination in ListModels is using a brittle
len(assets)%100 heuristic instead of the GitHub pagination metadata. Update the
pagination logic in ListModels to follow the response Link header for rel="next"
when fetching release assets, rather than inferring more pages from the
cumulative asset count. Keep the existing ListModels/getJSON flow, but have the
asset-fetching loop continue only while a next-page link is present so future
changes to inline asset counts do not break pagination.

In `@internal/dictation/transcriber_stream_test.go`:
- Around line 34-55: The test currently leaves gotAuth unused and does not
verify the Deepgram Authorization header, so the auth contract is untested.
Update wsTestServer and the anonymous handler in transcriber_stream_test.go so
the upgrade request headers (or *http.Request) are available, capture the
Authorization value during NewDeepgramTranscriber handshake, and assert it
equals the expected Token header; if you do not plan to expose headers, remove
gotAuth and the dummy assignment instead of keeping dead coverage.

In `@internal/tui/dictation_download_test.go`:
- Around line 24-42: Add regression tests covering the two dictation download
picker gaps in `model.toggleDictation`, `dictation_download.go`, and
`stt_model_picker.go`. Add one test that verifies choosing a second download
variant while `m.dictation.downloading` is already true is ignored and leaves
the current state unchanged. Add another test that opens the local-model
download picker, cancels with Esc, and confirms `Provider=local` is not
persisted over an existing working cloud provider, mirroring the existing cloud
cancel behavior test.

In `@internal/tui/dictation_test.go`:
- Around line 216-229: The test currently duplicates the nil-guard logic instead
of verifying the real behavior in transcribeBatchCmd, so a regression in the
command would be missed. Refactor the ctx-normalization out of
transcribeBatchCmd into a small helper that the test can call directly, or
change TestDictationBatchCtxNonNil to exercise the command’s returned tea.Cmd
path. Remove the hand-copied transcribeBatchCtx helper and keep the assertion
tied to the actual implementation symbols transcribeBatchCmd and
dictationController.

In `@internal/tui/dictation_voice_test.go`:
- Around line 59-66: Add positive-case coverage for keyboard enhancement
detection in TestKeyboardEnhancementsRecorded by creating a second test (or
extending the existing one) that calls model.handleKeyboardEnhancements with the
event-types bit set and asserts dictation.eventTypesKnown and
dictation.eventTypesSupported are both true. Use the same
KeyboardEnhancementsMsg flow and the exact flag constant already used elsewhere
for the event-types bit (for example ansi.KittyReportEventTypes or the project’s
equivalent) so the test matches the production message construction.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d906cbf6-dcc6-4672-aec8-e65a58046570

📥 Commits

Reviewing files that changed from the base of the PR and between 008bc9b and d4d3387.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (54)
  • go.mod
  • internal/cli/app.go
  • internal/cli/dictation.go
  • internal/cli/dictation_test.go
  • internal/config/resolver.go
  • internal/config/stt_test.go
  • internal/config/types.go
  • internal/config/validate.go
  • internal/config/writer.go
  • internal/dictation/dictation.go
  • internal/dictation/download.go
  • internal/dictation/download_smoke_test.go
  • internal/dictation/download_test.go
  • internal/dictation/listmodels_test.go
  • internal/dictation/local_smoke_test.go
  • internal/dictation/owner_unix.go
  • internal/dictation/owner_windows.go
  • internal/dictation/recorder.go
  • internal/dictation/recorder_test.go
  • internal/dictation/runner.go
  • internal/dictation/server.go
  • internal/dictation/server_test.go
  • internal/dictation/spill.go
  • internal/dictation/stream_smoke_test.go
  • internal/dictation/transcriber.go
  • internal/dictation/transcriber_cloud.go
  • internal/dictation/transcriber_cloud_test.go
  • internal/dictation/transcriber_deepgram.go
  • internal/dictation/transcriber_local.go
  • internal/dictation/transcriber_local_stream.go
  • internal/dictation/transcriber_local_test.go
  • internal/dictation/transcriber_openai_realtime.go
  • internal/dictation/transcriber_stream_test.go
  • internal/providermodelcatalog/filter.go
  • internal/providermodelcatalog/stt_filter_test.go
  • internal/tui/autocomplete_test.go
  • internal/tui/commands.go
  • internal/tui/dictation.go
  • internal/tui/dictation_download.go
  • internal/tui/dictation_download_test.go
  • internal/tui/dictation_stream.go
  • internal/tui/dictation_test.go
  • internal/tui/dictation_voice.go
  • internal/tui/dictation_voice_test.go
  • internal/tui/keybindings.go
  • internal/tui/model.go
  • internal/tui/mouse.go
  • internal/tui/mouse_test.go
  • internal/tui/options.go
  • internal/tui/picker.go
  • internal/tui/stt_key_prompt.go
  • internal/tui/stt_key_prompt_test.go
  • internal/tui/stt_model_picker.go
  • internal/tui/view.go

Comment thread internal/config/resolver.go
Comment thread internal/dictation/download.go
Comment thread internal/dictation/runner.go
Comment thread internal/dictation/server.go
Comment thread internal/dictation/spill.go
Comment thread internal/tui/dictation_download.go
Comment thread internal/tui/stt_model_picker.go
Comment thread internal/tui/stt_model_picker.go
Comment thread internal/tui/stt_model_picker.go
Vasanthdev2004

This comment was marked as low quality.

@Vasanthdev2004 Vasanthdev2004 dismissed their stale review July 6, 2026 14:06

Withdrawing; will re-review.

@kevincodex1

Copy link
Copy Markdown
Contributor

nice feature this is really useful! kindly address some coderabbit comments @anandh8x

- validateSTTConfig: add NumThreads bounds check
- downloadVerifyExtract: stage extraction into a sibling temp dir,
  atomic-rename into destDir on success so a partial extract cannot
  leave a truncated engine binary that passes fileExists() on next run
- startProcess: close pipe fds when cmd.Start() fails
- ServerManager.Shutdown: honor the caller's context deadline
- audioSpillDir: tighten any pre-existing directory to 0700
- startVariantDownload: refuse re-entry while a download is in flight
- maybeOpenSTTDownloadPicker: defer Provider=local persistence to
  applyEngineComponents (commit-on-success), matching the cloud flow
- fetchSTTModelsCmd: bound with a 30s timeout client+context
- newSTTDownloadPickerFrom.isCurrent: compare path segments via
  filepath.Base to avoid substring false-positives

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great work!

@euxaristia

Copy link
Copy Markdown
Contributor

Hey folks, I took a look at this PR and noticed a few correctness issues with the dictation features:

1. Nil Pointer Panic in recorder.Stop()

If a recording is started as a streaming capture (via StartStreaming()), r.proc is not set. When Stop() is eventually called, proc.StopGracefully() is invoked on a nil handle, causing a panic:

// internal/dictation/recorder.go
func (r *recorder) Stop() ([]byte, error) {
    ...
    proc, path := r.proc, r.filePath
    r.proc, r.filePath, r.recording = nil, "", false
    defer os.Remove(path)

    if r.opts.Platform == PlatformTermux {
        ...
    }

    // proc is nil for streaming, causing a panic here
    if err := proc.StopGracefully(); err != nil {
        _ = proc.Kill()
    }

Fix: Wrap the shutdown logic in a proc != nil check:

	if proc != nil {
		if err := proc.StopGracefully(); err != nil {
			_ = proc.Kill()
		}
		_ = waitWithTimeout(proc, stopGrace)
	}

2. Esc Swallows Run-Cancel Confirmation

When a background task is running and you press Ctrl+C once to trigger cancel confirmation ("Press Esc to confirm cancel"), pressing Esc while dictation is active will cancel dictation but silently swallow the run-cancellation confirmation without actually stopping the task.

This happens because dictation intercepts Esc right after disarming the cancel confirmation:

// internal/tui/model.go
			wasConfirmingCancel := m.pending && m.cancelConfirmActive
			m = m.disarmCancelConfirmation()
			if m.dictation.active() {
				return m.cancelDictation() // Esc handled here; wasConfirmingCancel is swallowed
			}

Fix: Prioritize wasConfirmingCancel at the top of the Esc handler:

			wasConfirmingCancel := m.pending && m.cancelConfirmActive
			if wasConfirmingCancel {
				m = m.disarmCancelConfirmation()
				m.clearComposer()
				m.cancelRun()
				return m, nil
			}
			m = m.disarmCancelConfirmation()
			if m.dictation.active() {
				return m.cancelDictation()
			}

3. Composer Corruption & Cursor Jumping During Typing while Streaming

If you start typing in the composer while streaming dictation is active:

  1. Corruption: regionStart and regionEnd are absolute indices that don't adjust when you type before the region. The next incoming partial transcript deletes the wrong range, corrupting your text.
  2. Cursor Jumping: The cursor is hard-reset to the end of the streaming transcript after every partial, jumping it away from where you are typing.

Fix:

  1. In Update (internal/tui/model.go), if the composer text changes while dictation is active, check the edit bounds. If the edit is entirely before the region, shift the bounds. If it overlaps/modifies the region, deactivate the region (regionActive = false) so the manual changes are kept:
    // Inside Update(msg tea.Msg)
    if dictationActiveBefore {
        switch msg.(type) {
        case sttPartialMsg, dictationTranscribedMsg:
        default:
            newState := nm.currentComposerState()
            if newState.text != oldComposerText || newState.cursor != oldComposerCursor {
                oldRunes := []rune(oldComposerText)
                newRunes := []rune(newState.text)
                oldRegionStart := m.dictation.regionStart
                oldRegionEnd := m.dictation.regionEnd

                diffStart := 0
                for diffStart < len(oldRunes) && diffStart < len(newRunes) && oldRunes[diffStart] == newRunes[diffStart] {
                    diffStart++
                }
                diffEndOld := len(oldRunes)
                diffEndNew := len(newRunes)
                for diffEndOld > diffStart && diffEndNew > diffStart && oldRunes[diffEndOld-1] == newRunes[diffEndNew-1] {
                    diffEndOld--
                    diffEndNew--
                }

                if diffStart >= oldRegionEnd {
                    // Edit is after region
                } else if diffEndOld <= oldRegionStart {
                    shift := (diffEndNew - diffStart) - (diffEndOld - diffStart)
                    nm.dictation.regionStart += shift
                    nm.dictation.regionEnd += shift
                } else {
                    nm.dictation.regionActive = false
                }
            }
        }
    }
  1. In applyStreamingText (internal/tui/dictation_stream.go), calculate the change in region length (delta), and shift the cursor dynamically relative to the bounds so it doesn't jump back:
    oldCursor := state.cursor
    oldRegionStart := m.dictation.regionStart
    oldRegionEnd := m.dictation.regionEnd
    newRegionLen := len([]rune(rendered))
    delta := newRegionLen - (oldRegionEnd - oldRegionStart)

    var targetCursor int
    if oldCursor <= oldRegionStart {
        targetCursor = oldCursor
    } else if oldCursor >= oldRegionEnd {
        targetCursor = oldCursor + delta
    } else {
        targetCursor = oldRegionStart + newRegionLen
    }
    
    // ... insertComposerText ...
    updated.cursor = targetCursor
    m.setComposerState(updated)

…region

Three correctness issues from PR review:

1) recorder.Stop() panicked (nil-deref) when called on a StartStreaming
   session because StartStreaming intentionally leaves r.proc nil. Reject
   that misuse with a clear error. Termux's batch path also leaves
   r.proc nil by design — keep that path working.

2) Esc was consumed by an active dictation session before the
   confirming second Esc could cancel a pending run, so a user
   mid-recording who double-Esc's to kill the run found the first Esc
   swallowed by dictation. Gate dictation-cancel on the Esc not being
   a pending-run confirm.

3) applyStreamingText hard-reset the cursor to regionStart on every
   partial, yanking focus away from users editing elsewhere in the
   composer, and stored regionStart/End as absolute indices that became
   stale the moment the user typed or pasted before the live region.
   Preserve the cursor when it's outside the region, and snapshot the
   prefix BEFORE the region so the next partial can detect external
   edits and shift [start,end) to stay aligned.

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@kevincodex1 kevincodex1 merged commit 87158a1 into main Jul 7, 2026
9 checks passed
yanalialiuk pushed a commit to yanalialiuk/zero that referenced this pull request Jul 8, 2026
* ## Speech-to-text dictation

Adds voice dictation to Zero. Run `/voice` to enter hold-to-record mode, then hold **Space** to dictate and release to transcribe (run `/voice` again to turn it off so Space types normally). Pick a backend with `/stt-model`.

### Backends
- **Local (sherpa-onnx)** — offline, no API key. Auto-downloads a model via `/stt-model`; the engine binary is fetched once and shared across models. Family auto-detection covers the mainstream offline families (Whisper, transducer/Zipformer, Moonshine, Paraformer, SenseVoice, and the CTC families FireRedASR, NeMo, TeleSpeech, WeNet, Zipformer-CTC, Dolphin, Omnilingual — plus the encoder/decoder families Canary and FireRedASR-AED).
- **Cloud batch** — Groq and OpenAI Whisper. Keys resolve from a configured provider, the encrypted credential store, or the env var; a missing/invalid key is entered inline.
- **Streaming** — a warm local sherpa-onnx websocket server (live transcript as you speak) on desktop, or Deepgram / OpenAI Realtime.

### Highlights
- Live audio-reactive waveform while recording (real mic levels when streaming; a synthesized wave for batch).
- `/stt-model` download chooser: recommended models first, Live/Batch tag per row, size + download state, and a search filter.
- Streaming transcript is inserted live into the composer and committed on stop; batch inserts on stop. Insert-for-review by default (`stt.autoSubmit` to fire straight at the agent).
- The warm streaming server is released when `/voice` is turned off and on exit.

### Implementation notes
- No CGO and no bundled runtimes — every OS integration shells out via `exec.Command` with discrete argv (never a shell string).
- The streaming server manager mirrors the LSP manager's warm-subprocess lifecycle.
- OS-boundary code (recorders, transcribers, server) is unit-tested via injected fakes; real microphone / websocket behavior is covered by env-gated smoke tests.

### Testing
- `go build ./...`, `go vet ./...`, and the config / dictation / cli / tui suites pass.
- Verified end-to-end against real downloaded binaries + models (offline batch, streaming websocket server, and the model auto-download), including the FireRedASR-CTC and NeMo streaming transducer families.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Added speech-to-text dictation with both batch and streaming modes, supporting local (sherpa-onnx) and cloud providers, including WAV/M4A detection.
  * Introduced voice dictation mode with STT model selection, local engine/model auto-download, and an inline prompt to save cloud API keys.
  * Added new TUI commands for voice/STT model selection and enabled F1–F12 key bindings.
* **Bug Fixes**
  * Improved streaming dictation to preserve accumulated transcript partials and keep the editor region consistent.
  * Refined TUI status/picker rendering, including clearer loading/search behavior and more reliable selection alignment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

* fix(dictation): address CodeRabbit review feedback

- validateSTTConfig: add NumThreads bounds check
- downloadVerifyExtract: stage extraction into a sibling temp dir,
  atomic-rename into destDir on success so a partial extract cannot
  leave a truncated engine binary that passes fileExists() on next run
- startProcess: close pipe fds when cmd.Start() fails
- ServerManager.Shutdown: honor the caller's context deadline
- audioSpillDir: tighten any pre-existing directory to 0700
- startVariantDownload: refuse re-entry while a download is in flight
- maybeOpenSTTDownloadPicker: defer Provider=local persistence to
  applyEngineComponents (commit-on-success), matching the cloud flow
- fetchSTTModelsCmd: bound with a 30s timeout client+context
- newSTTDownloadPickerFrom.isCurrent: compare path segments via
  filepath.Base to avoid substring false-positives

* fix(dictation): harden recorder.Stop, Esc-cancel priority, streaming region

Three correctness issues from PR review:

1) recorder.Stop() panicked (nil-deref) when called on a StartStreaming
   session because StartStreaming intentionally leaves r.proc nil. Reject
   that misuse with a clear error. Termux's batch path also leaves
   r.proc nil by design — keep that path working.

2) Esc was consumed by an active dictation session before the
   confirming second Esc could cancel a pending run, so a user
   mid-recording who double-Esc's to kill the run found the first Esc
   swallowed by dictation. Gate dictation-cancel on the Esc not being
   a pending-run confirm.

3) applyStreamingText hard-reset the cursor to regionStart on every
   partial, yanking focus away from users editing elsewhere in the
   composer, and stored regionStart/End as absolute indices that became
   stale the moment the user typed or pasted before the live region.
   Preserve the cursor when it's outside the region, and snapshot the
   prefix BEFORE the region so the next partial can detect external
   edits and shift [start,end) to stay aligned.
@anandh8x anandh8x deleted the feat/voice-dictation-clean branch July 8, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants