From 7e6f5e2a4a659f6cf3b59b0d0592c6ed604f28d7 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Wed, 26 Aug 2026 14:54:23 +0530 Subject: [PATCH 1/3] fix(tui): keep voice mode from swallowing spaces --- internal/tui/commands.go | 2 +- internal/tui/dictation.go | 14 +-- internal/tui/dictation_voice.go | 32 ++++-- internal/tui/dictation_voice_test.go | 161 ++++++++++++++++++++++++--- internal/tui/model.go | 17 ++- internal/tui/stt_key_prompt.go | 2 +- internal/tui/stt_model_picker.go | 2 +- 7 files changed, 186 insertions(+), 44 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index e5ad05af4..effc7043e 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -109,7 +109,7 @@ var commandDefinitions = []commandDefinition{ name: "/voice", usage: "/voice", group: commandGroupRuntime, - description: "Toggle voice mode (hold Space to dictate).", + description: "Toggle voice mode (hold Ctrl+Space to dictate).", kind: commandVoice, }, { diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 52a8bd297..e4b77fe49 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -88,7 +88,7 @@ type dictationController struct { waveBars []int waveTick int - // voiceModeEnabled repurposes Space into hold-to-record (§13.9). + // voiceModeEnabled repurposes Ctrl+Space into hold-to-record (§13.9). voiceModeEnabled bool // eventTypesSupported records whether the terminal confirmed key-release // reporting (Kitty protocol); eventTypesKnown gates it until the terminal has @@ -173,7 +173,7 @@ func firstNonEmptyStr(a, b string) string { func (d dictationController) active() bool { return d.phase != dictIdle } // toggleDictation starts a recording when idle and stops-and-transcribes when -// recording. Invoked by the voice-mode Space gesture; a call during +// recording. Invoked by the voice-mode Ctrl+Space gesture; a call during // startup/transcription is ignored (the machine is mid-transition). func (m model) toggleDictation() (model, tea.Cmd) { if !m.dictation.available() { @@ -193,15 +193,15 @@ func (m model) toggleDictation() (model, tea.Cmd) { } } -// toggleVoiceMode flips the /voice hold-to-record gesture — the dictation -// trigger. While on, Space records; run /voice again to type spaces normally. +// toggleVoiceMode flips the /voice hold-to-record gesture. While on, +// Ctrl+Space records and ordinary Space continues to type normally. func (m model) toggleVoiceMode() (model, tea.Cmd) { if !m.dictation.available() { return m.appendSystemNotice("Dictation is not configured. See docs/dictation.md to set up a local engine or a Groq/OpenAI key."), nil } m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled if m.dictation.voiceModeEnabled { - return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil + return m.showTransientNoticeInline("Voice mode on — hold Ctrl+Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil } // Turning voice off is the "done dictating" signal, so release the warm sherpa // streaming server — otherwise a loaded model keeps idling in RAM (and holding @@ -374,7 +374,7 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) if m.dictation.phase == dictStarting { m.dictation.phase = dictRecording } - // A voice-mode Space release that arrived mid-startup asked us to stop as soon + // A voice-mode Ctrl+Space release that arrived mid-startup asked us to stop as soon // as recording began. if m.dictation.voiceStopPending && m.dictation.phase == dictRecording { m.dictation.voiceStopPending = false @@ -607,7 +607,7 @@ func (m model) voiceModeIndicator() string { if !m.dictation.voiceModeEnabled { return "" } - return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.dictation.currentModelLabel()) + return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · Ctrl+Space · "+m.dictation.currentModelLabel()) } // dictationErrorText renders a dictation error for the transcript: a missing- diff --git a/internal/tui/dictation_voice.go b/internal/tui/dictation_voice.go index 24dde5679..119209b57 100644 --- a/internal/tui/dictation_voice.go +++ b/internal/tui/dictation_voice.go @@ -4,17 +4,17 @@ import ( tea "charm.land/bubbletea/v2" ) -// Voice mode's Space-hold gesture — the (only) dictation trigger. Two terminal +// Voice mode's Ctrl+Space gesture — the (only) dictation trigger. Two terminal // tiers: // // 1. The terminal confirms key-release reporting (Ghostty/Kitty/WezTerm, …): -// Space press starts recording, Space release stops it — true hold-to-record, -// no ambiguity. -// 2. The terminal does not confirm release events: Space falls back to +// Ctrl+Space press starts recording and release stops it — true +// hold-to-record, with ordinary Space left available for typing. +// 2. The terminal does not confirm release events: Ctrl+Space falls back to // press-to-toggle (press to start, press again to stop) — a deliberately // simpler, robust fallback than inferring release from key-repeat timing -// (racy in a terminal). This works on ANY terminal, so voice mode is always -// usable once enabled. +// (racy in a terminal). Legacy NUL input is decoded as Ctrl+Space by the +// terminal input layer, so the same matcher covers both tiers. // // Only active while voice mode is on; the rest of dispatch is built on // KeyPressMsg and is untouched. @@ -27,8 +27,20 @@ func (m model) handleKeyboardEnhancements(msg tea.KeyboardEnhancementsMsg) model return m } -// handleVoiceSpacePress handles a Space press while voice mode is on. -func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) { +func voiceCaptureKey(msg tea.KeyMsg) bool { + mod := msg.Key().Mod &^ (tea.ModCapsLock | tea.ModNumLock) + return keyIs(msg, tea.KeySpace) && mod == tea.ModCtrl +} + +// voiceCaptureReleaseKey matches either half of the held Ctrl+Space chord. A +// terminal may report Ctrl releasing first, or report the later Space release +// without ModCtrl because Ctrl is no longer down. +func voiceCaptureReleaseKey(msg tea.KeyMsg) bool { + return keyIs(msg, tea.KeySpace) || keyIs(msg, tea.KeyLeftCtrl) || keyIs(msg, tea.KeyRightCtrl) +} + +// handleVoiceCapturePress handles Ctrl+Space while voice mode is on. +func (m model) handleVoiceCapturePress(msg tea.KeyMsg) (model, tea.Cmd) { if !m.dictation.eventTypesSupported { // Tier 2: no release events — press-to-toggle. return m.toggleDictation() @@ -45,8 +57,8 @@ func (m model) handleVoiceSpacePress(msg tea.KeyMsg) (model, tea.Cmd) { return m, nil // already recording; the release will stop it } -// handleVoiceSpaceRelease stops a hold-to-record session when Space is released. -func (m model) handleVoiceSpaceRelease() (model, tea.Cmd) { +// handleVoiceCaptureRelease stops a hold-to-record session when Ctrl+Space is released. +func (m model) handleVoiceCaptureRelease() (model, tea.Cmd) { if !m.dictation.spaceHeld { return m, nil } diff --git a/internal/tui/dictation_voice_test.go b/internal/tui/dictation_voice_test.go index b42a04012..c0b5b5860 100644 --- a/internal/tui/dictation_voice_test.go +++ b/internal/tui/dictation_voice_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "strings" "testing" tea "charm.land/bubbletea/v2" @@ -36,7 +37,7 @@ func TestToggleVoiceModeFlips(t *testing.T) { if !next.dictation.voiceModeEnabled { t.Fatal("first /voice should enable voice mode") } - if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." { + if next.transientNotice.text != "Voice mode on — hold Ctrl+Space to dictate; run /voice again to turn it off." { t.Errorf("voice-mode-on notice = %q", next.transientNotice.text) } if transcriptHasText(next, "Voice mode on") { @@ -68,15 +69,27 @@ func TestKeyboardEnhancementsRecorded(t *testing.T) { } } -func TestVoiceSpaceHoldStartsRecording(t *testing.T) { +func TestVoiceCaptureKeyIgnoresLockModifiers(t *testing.T) { + for _, mod := range []tea.KeyMod{ + tea.ModCtrl | tea.ModCapsLock, + tea.ModCtrl | tea.ModNumLock, + tea.ModCtrl | tea.ModCapsLock | tea.ModNumLock, + } { + if !voiceCaptureKey(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: mod})) { + t.Fatalf("Ctrl+Space with lock modifiers %v was not recognized", mod) + } + } +} + +func TestVoiceCaptureHoldStartsRecording(t *testing.T) { m := model{dictation: batchOnlyController()} m.dictation.voiceModeEnabled = true m.dictation.eventTypesSupported = true - press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace}) - next, cmd := m.handleVoiceSpacePress(press) + press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl}) + next, cmd := m.handleVoiceCapturePress(press) if next.dictation.phase != dictStarting { - t.Fatalf("Space press should start recording (phase=%d)", next.dictation.phase) + t.Fatalf("Ctrl+Space press should start recording (phase=%d)", next.dictation.phase) } if !next.dictation.spaceHeld { t.Error("spaceHeld should be set in hold mode") @@ -86,28 +99,28 @@ func TestVoiceSpaceHoldStartsRecording(t *testing.T) { } } -func TestVoiceSpaceHoldIgnoresRepeat(t *testing.T) { +func TestVoiceCaptureHoldIgnoresRepeat(t *testing.T) { m := model{dictation: batchOnlyController()} m.dictation.voiceModeEnabled = true m.dictation.eventTypesSupported = true m.dictation.phase = dictRecording m.dictation.spaceHeld = true - repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, IsRepeat: true}) - next, _ := m.handleVoiceSpacePress(repeat) + repeat := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl, IsRepeat: true}) + next, _ := m.handleVoiceCapturePress(repeat) if next.dictation.phase != dictRecording { t.Error("auto-repeat while held must not restart or change phase") } } -func TestVoiceSpaceReleaseStops(t *testing.T) { +func TestVoiceCaptureReleaseStops(t *testing.T) { m := model{dictation: batchOnlyController()} m.dictation.voiceModeEnabled = true m.dictation.eventTypesSupported = true m.dictation.phase = dictRecording m.dictation.spaceHeld = true - next, _ := m.handleVoiceSpaceRelease() + next, _ := m.handleVoiceCaptureRelease() if next.dictation.phase != dictTranscribing { t.Errorf("release should stop recording → transcribing (phase=%d)", next.dictation.phase) } @@ -123,7 +136,7 @@ func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) { m.dictation.phase = dictStarting m.dictation.spaceHeld = true - next, _ := m.handleVoiceSpaceRelease() + next, _ := m.handleVoiceCaptureRelease() if !next.dictation.voiceStopPending { t.Error("a release during startup should defer the stop") } @@ -134,14 +147,132 @@ func TestVoiceReleaseDuringStartupDefersStop(t *testing.T) { } } -func TestVoiceSpaceToggleFallback(t *testing.T) { +func TestVoiceCaptureToggleFallback(t *testing.T) { m := model{dictation: batchOnlyController()} m.dictation.voiceModeEnabled = true m.dictation.eventTypesSupported = false // no release events → toggle fallback - press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace}) - next, _ := m.handleVoiceSpacePress(press) + press := tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl}) + next, _ := m.handleVoiceCapturePress(press) if next.dictation.phase != dictStarting { - t.Errorf("toggle-mode Space should start recording (phase=%d)", next.dictation.phase) + t.Errorf("toggle-mode Ctrl+Space should start recording (phase=%d)", next.dictation.phase) + } +} + +func TestVoiceModeCtrlSpaceReleaseStopsHoldRecording(t *testing.T) { + m := newModel(t.Context(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = true + m.dictation.phase = dictRecording + m.dictation.spaceHeld = true + + updated, _ := m.Update(tea.KeyReleaseMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl})) + next := updated.(model) + if next.dictation.phase != dictTranscribing || next.dictation.spaceHeld { + t.Fatalf("Ctrl+Space release did not stop hold recording: phase=%d held=%v", next.dictation.phase, next.dictation.spaceHeld) + } +} + +func TestVoiceModeCaptureStopsWhenEitherChordKeyIsReleased(t *testing.T) { + for _, test := range []struct { + name string + key tea.Key + }{ + {name: "left Ctrl first", key: tea.Key{Code: tea.KeyLeftCtrl}}, + {name: "right Ctrl first", key: tea.Key{Code: tea.KeyRightCtrl}}, + {name: "Space after Ctrl", key: tea.Key{Code: tea.KeySpace}}, + } { + t.Run(test.name, func(t *testing.T) { + m := newModel(t.Context(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = true + m.dictation.phase = dictRecording + m.dictation.spaceHeld = true + + updated, _ := m.Update(tea.KeyReleaseMsg(test.key)) + next := updated.(model) + if next.dictation.phase != dictTranscribing || next.dictation.spaceHeld { + t.Fatalf("release did not stop hold recording: phase=%d held=%v", next.dictation.phase, next.dictation.spaceHeld) + } + }) + } +} + +func TestVoiceModeIndicatorIncludesCaptureShortcut(t *testing.T) { + m := model{dictation: batchOnlyController()} + m.dictation.voiceModeEnabled = true + if got := m.voiceModeIndicator(); !strings.Contains(got, "Ctrl+Space") { + t.Fatalf("voice indicator = %q, want Ctrl+Space hint", got) + } +} + +func TestVoiceModePlainSpaceTypesNormally(t *testing.T) { + for _, releaseEvents := range []bool{false, true} { + t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) { + m := newModel(t.Context(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = releaseEvents + + updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace})) + next := updated.(model) + if got := next.composerValue(); got != " " { + t.Fatalf("plain Space composer = %q, want one space", got) + } + if next.dictation.phase != dictIdle || cmd != nil { + t.Fatalf("plain Space started dictation: phase=%d cmd=%v", next.dictation.phase, cmd != nil) + } + }) + } +} + +func TestVoiceModeCtrlSpaceStartsAcrossTerminalTiers(t *testing.T) { + for _, releaseEvents := range []bool{false, true} { + t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) { + m := newModel(t.Context(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = releaseEvents + + updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeySpace, Mod: tea.ModCtrl})) + next := updated.(model) + if next.dictation.phase != dictStarting || cmd == nil { + t.Fatalf("Ctrl+Space did not start dictation: phase=%d cmd=%v", next.dictation.phase, cmd != nil) + } + if got := next.composerValue(); got != "" { + t.Fatalf("Ctrl+Space typed into composer: %q", got) + } + }) + } +} + +func TestVoiceModeTypingPreservesSpacesAcrossTerminalTiers(t *testing.T) { + for _, releaseEvents := range []bool{false, true} { + t.Run(map[bool]string{false: "toggle", true: "hold"}[releaseEvents], func(t *testing.T) { + m := newModel(t.Context(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = releaseEvents + for _, key := range []tea.Key{ + {Code: 'f', Text: "f"}, + {Code: 'i', Text: "i"}, + {Code: 'x', Text: "x"}, + {Code: tea.KeySpace}, + {Code: 't', Text: "t"}, + {Code: 'h', Text: "h"}, + {Code: 'e', Text: "e"}, + } { + updated, _ := m.Update(tea.KeyPressMsg(key)) + m = updated.(model) + } + if got := m.composerValue(); got != "fix the" { + t.Fatalf("composer = %q, want %q", got, "fix the") + } + if m.dictation.phase != dictIdle { + t.Fatalf("typing started dictation: phase=%d", m.dictation.phase) + } + }) } } diff --git a/internal/tui/model.go b/internal/tui/model.go index f8b220341..9473de06a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1539,10 +1539,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyboardEnhancementsMsg: return m.handleKeyboardEnhancements(msg), nil case tea.KeyReleaseMsg: - // Voice mode's hold-to-record ends on Space release; every other release - // event is ignored (dispatch elsewhere is press-based). - if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) { - return m.handleVoiceSpaceRelease() + // Voice mode's hold-to-record ends on Ctrl+Space release; every other + // release event is ignored (dispatch elsewhere is press-based). + if m.dictation.voiceModeEnabled && voiceCaptureReleaseKey(msg) { + return m.handleVoiceCaptureRelease() } return m, nil case tea.KeyPressMsg: @@ -1676,11 +1676,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil } return m.showTransientNoticeInline("Mouse interaction re-enabled.", transientNoticeSuccess), nil - case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal(): - // Voice mode (/voice) repurposes Space into the record gesture — the only - // dictation trigger — so it must not also type a space. Turn voice mode - // off (/voice) to type normally. - return m.handleVoiceSpacePress(msg) + case m.dictation.voiceModeEnabled && !m.transcriptDetailed && voiceCaptureKey(msg) && m.noBlockingModal(): + // Voice mode reserves Ctrl+Space for recording; ordinary Space continues + // through the composer path below. + return m.handleVoiceCapturePress(msg) case keyIs(msg, tea.KeyEsc): // Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user, // permission deny, wizard/picker/suggestions dismiss, ...) before ever diff --git a/internal/tui/stt_key_prompt.go b/internal/tui/stt_key_prompt.go index 5a32d2147..68dbf1f88 100644 --- a/internal/tui/stt_key_prompt.go +++ b/internal/tui/stt_key_prompt.go @@ -123,7 +123,7 @@ func (m model) finalizeSTTModelFromKey(p *sttKeyPromptState, prefix string) (tea if modelID != "" { label += " " + modelID } - return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice, then hold Space to dictate.", transientNoticeSuccess), nil + return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice, then hold Ctrl+Space to dictate.", transientNoticeSuccess), nil } // sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching diff --git a/internal/tui/stt_model_picker.go b/internal/tui/stt_model_picker.go index 7259e198e..2fda4819b 100644 --- a/internal/tui/stt_model_picker.go +++ b/internal/tui/stt_model_picker.go @@ -226,7 +226,7 @@ func (m model) handleSTTModelSelection(value string) (model, string) { // instead); this is the manual-setup path (e.g. Termux). hint += " Set stt.localModelPath to a model directory (see docs/dictation.md)." } else { - hint += " Run /voice, then hold Space to dictate." + hint += " Run /voice, then hold Ctrl+Space to dictate." } return m, hint } From ed38f57fab35b2a0a6365ed800de588c567c6706 Mon Sep 17 00:00:00 2001 From: anandh8x Date: Wed, 26 Aug 2026 15:38:35 +0530 Subject: [PATCH 2/3] fix(tui): clarify voice capture controls --- internal/tui/commands.go | 2 +- internal/tui/dictation.go | 7 ++--- internal/tui/dictation_voice.go | 19 ++++++++++++ internal/tui/dictation_voice_test.go | 45 ++++++++++++++++++++++++---- internal/tui/stt_key_prompt.go | 2 +- internal/tui/stt_model_picker.go | 2 +- 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index effc7043e..5eea59d21 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -109,7 +109,7 @@ var commandDefinitions = []commandDefinition{ name: "/voice", usage: "/voice", group: commandGroupRuntime, - description: "Toggle voice mode (hold Ctrl+Space to dictate).", + description: "Toggle voice mode (" + voiceCaptureUsage + ").", kind: commandVoice, }, { diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index e4b77fe49..cfc68ba6f 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -201,7 +201,7 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) { } m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled if m.dictation.voiceModeEnabled { - return m.showTransientNoticeInline("Voice mode on — hold Ctrl+Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil + return m.showTransientNoticeInline("Voice mode on — "+voiceCaptureUsage+". Run /voice again to turn it off.", transientNoticeSuccess), nil } // Turning voice off is the "done dictating" signal, so release the warm sherpa // streaming server — otherwise a loaded model keeps idling in RAM (and holding @@ -601,13 +601,12 @@ func (m model) handleRecTick() (model, tea.Cmd) { return m, nil } -// voiceModeIndicator renders the persistent "voice mode on" hint shown while -// idle so the user knows Space is repurposed to hold-to-record (§10). +// voiceModeIndicator renders the current voice gesture or transcription state. func (m model) voiceModeIndicator() string { if !m.dictation.voiceModeEnabled { return "" } - return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · Ctrl+Space · "+m.dictation.currentModelLabel()) + return zeroTheme.accent.Render("🎙 voice") + zeroTheme.muted.Render(" · "+m.voiceCaptureStatus()+" · "+m.dictation.currentModelLabel()) } // dictationErrorText renders a dictation error for the transcript: a missing- diff --git a/internal/tui/dictation_voice.go b/internal/tui/dictation_voice.go index 119209b57..09a7bd703 100644 --- a/internal/tui/dictation_voice.go +++ b/internal/tui/dictation_voice.go @@ -4,6 +4,8 @@ import ( tea "charm.land/bubbletea/v2" ) +const voiceCaptureUsage = "hold Ctrl+Space to record; press it to toggle where key releases are unavailable" + // Voice mode's Ctrl+Space gesture — the (only) dictation trigger. Two terminal // tiers: // @@ -39,6 +41,23 @@ func voiceCaptureReleaseKey(msg tea.KeyMsg) bool { return keyIs(msg, tea.KeySpace) || keyIs(msg, tea.KeyLeftCtrl) || keyIs(msg, tea.KeyRightCtrl) } +func (m model) voiceCaptureStatus() string { + switch m.dictation.phase { + case dictStarting, dictRecording: + if m.dictation.eventTypesSupported { + return "release Ctrl+Space to stop" + } + return "press Ctrl+Space to stop" + case dictTranscribing: + return "transcribing…" + default: + if m.dictation.eventTypesSupported { + return "hold Ctrl+Space to record" + } + return "press Ctrl+Space to record" + } +} + // handleVoiceCapturePress handles Ctrl+Space while voice mode is on. func (m model) handleVoiceCapturePress(msg tea.KeyMsg) (model, tea.Cmd) { if !m.dictation.eventTypesSupported { diff --git a/internal/tui/dictation_voice_test.go b/internal/tui/dictation_voice_test.go index c0b5b5860..5f79ac526 100644 --- a/internal/tui/dictation_voice_test.go +++ b/internal/tui/dictation_voice_test.go @@ -37,7 +37,7 @@ func TestToggleVoiceModeFlips(t *testing.T) { if !next.dictation.voiceModeEnabled { t.Fatal("first /voice should enable voice mode") } - if next.transientNotice.text != "Voice mode on — hold Ctrl+Space to dictate; run /voice again to turn it off." { + if next.transientNotice.text != "Voice mode on — hold Ctrl+Space to record; press it to toggle where key releases are unavailable. Run /voice again to turn it off." { t.Errorf("voice-mode-on notice = %q", next.transientNotice.text) } if transcriptHasText(next, "Voice mode on") { @@ -200,12 +200,45 @@ func TestVoiceModeCaptureStopsWhenEitherChordKeyIsReleased(t *testing.T) { } } -func TestVoiceModeIndicatorIncludesCaptureShortcut(t *testing.T) { - m := model{dictation: batchOnlyController()} - m.dictation.voiceModeEnabled = true - if got := m.voiceModeIndicator(); !strings.Contains(got, "Ctrl+Space") { - t.Fatalf("voice indicator = %q, want Ctrl+Space hint", got) +func TestVoiceModeIndicatorDescribesTierAndPhase(t *testing.T) { + for _, test := range []struct { + name string + releaseEvents bool + phase dictationPhase + spaceHeld bool + want string + }{ + {name: "toggle idle", want: "press Ctrl+Space to record"}, + {name: "hold idle", releaseEvents: true, want: "hold Ctrl+Space to record"}, + {name: "toggle recording", phase: dictRecording, want: "press Ctrl+Space to stop"}, + {name: "hold recording", releaseEvents: true, phase: dictRecording, spaceHeld: true, want: "release Ctrl+Space to stop"}, + {name: "transcribing", phase: dictTranscribing, want: "transcribing"}, + } { + t.Run(test.name, func(t *testing.T) { + m := model{dictation: batchOnlyController()} + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = test.releaseEvents + m.dictation.phase = test.phase + m.dictation.spaceHeld = test.spaceHeld + if got := m.voiceModeIndicator(); !strings.Contains(strings.ToLower(got), strings.ToLower(test.want)) { + t.Fatalf("voice indicator = %q, want %q", got, test.want) + } + }) + } +} + +func TestVoiceCommandDescriptionExplainsBothTerminalTiers(t *testing.T) { + for _, command := range commandDefinitions { + if command.kind == commandVoice { + for _, want := range []string{"hold Ctrl+Space", "press it to toggle", "key releases"} { + if !strings.Contains(command.description, want) { + t.Fatalf("voice command description = %q, want %q", command.description, want) + } + } + return + } } + t.Fatal("voice command definition not found") } func TestVoiceModePlainSpaceTypesNormally(t *testing.T) { diff --git a/internal/tui/stt_key_prompt.go b/internal/tui/stt_key_prompt.go index 68dbf1f88..b41f403ea 100644 --- a/internal/tui/stt_key_prompt.go +++ b/internal/tui/stt_key_prompt.go @@ -123,7 +123,7 @@ func (m model) finalizeSTTModelFromKey(p *sttKeyPromptState, prefix string) (tea if modelID != "" { label += " " + modelID } - return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice, then hold Ctrl+Space to dictate.", transientNoticeSuccess), nil + return m.showTransientNoticeInline(prefix+"Dictation model: "+label+". Run /voice; "+voiceCaptureUsage+".", transientNoticeSuccess), nil } // sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching diff --git a/internal/tui/stt_model_picker.go b/internal/tui/stt_model_picker.go index 2fda4819b..c2be2e818 100644 --- a/internal/tui/stt_model_picker.go +++ b/internal/tui/stt_model_picker.go @@ -226,7 +226,7 @@ func (m model) handleSTTModelSelection(value string) (model, string) { // instead); this is the manual-setup path (e.g. Termux). hint += " Set stt.localModelPath to a model directory (see docs/dictation.md)." } else { - hint += " Run /voice, then hold Ctrl+Space to dictate." + hint += " Run /voice; " + voiceCaptureUsage + "." } return m, hint } From 72440a85bf36cfdff62719d18def5b041dc0dadf Mon Sep 17 00:00:00 2001 From: anandh8x Date: Wed, 26 Aug 2026 16:25:15 +0530 Subject: [PATCH 3/3] fix(tui): align active voice status with capture key --- internal/tui/dictation.go | 6 +----- internal/tui/dictation_voice_test.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index cfc68ba6f..adac424f7 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -480,12 +480,8 @@ func needsLeadingSpace(state composerState) bool { func (m model) dictationStatusChip() string { switch m.dictation.phase { case dictStarting, dictRecording: - stop := "release Space to stop" - if !m.dictation.eventTypesSupported { - stop = "press Space to stop" // press-to-toggle fallback - } wave := zeroTheme.amber.Render("● " + renderWaveBars(m.dictation.waveBars) + " REC") - return wave + zeroTheme.muted.Render(" · "+stop+", Esc to cancel") + return wave + zeroTheme.muted.Render(" · "+m.voiceCaptureStatus()+", Esc to cancel") case dictTranscribing: return zeroTheme.accent.Render("●") + " " + zeroTheme.muted.Render("transcribing…") } diff --git a/internal/tui/dictation_voice_test.go b/internal/tui/dictation_voice_test.go index 5f79ac526..e49f3701b 100644 --- a/internal/tui/dictation_voice_test.go +++ b/internal/tui/dictation_voice_test.go @@ -227,6 +227,27 @@ func TestVoiceModeIndicatorDescribesTierAndPhase(t *testing.T) { } } +func TestDictationStatusChipUsesVoiceCaptureShortcut(t *testing.T) { + for _, test := range []struct { + name string + releaseEvents bool + want string + }{ + {name: "toggle", want: "press Ctrl+Space to stop"}, + {name: "hold", releaseEvents: true, want: "release Ctrl+Space to stop"}, + } { + t.Run(test.name, func(t *testing.T) { + m := model{dictation: batchOnlyController()} + m.dictation.voiceModeEnabled = true + m.dictation.eventTypesSupported = test.releaseEvents + m.dictation.phase = dictRecording + if got := m.dictationStatusChip(); !strings.Contains(got, test.want) { + t.Fatalf("dictation status = %q, want %q", got, test.want) + } + }) + } +} + func TestVoiceCommandDescriptionExplainsBothTerminalTiers(t *testing.T) { for _, command := range commandDefinitions { if command.kind == commandVoice {