From 4979016ed0cf884b86b6da2691c3da5ae317edf2 Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:06:28 +0530 Subject: [PATCH 1/2] fix(tui): let typing exit idle voice mode --- internal/tui/dictation.go | 4 ++-- internal/tui/dictation_voice_test.go | 25 ++++++++++++++++++++++++- internal/tui/model.go | 11 ++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 52a8bd297..4290555e3 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -194,14 +194,14 @@ 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. +// trigger. While on, Space records; ordinary typing or /voice turns it off. 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 Space to dictate; start typing or 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 diff --git a/internal/tui/dictation_voice_test.go b/internal/tui/dictation_voice_test.go index b42a04012..f951718be 100644 --- a/internal/tui/dictation_voice_test.go +++ b/internal/tui/dictation_voice_test.go @@ -36,7 +36,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 Space to dictate; start typing or run /voice again to turn it off." { t.Errorf("voice-mode-on notice = %q", next.transientNotice.text) } if transcriptHasText(next, "Voice mode on") { @@ -145,3 +145,26 @@ func TestVoiceSpaceToggleFallback(t *testing.T) { t.Errorf("toggle-mode Space should start recording (phase=%d)", next.dictation.phase) } } + +func TestVoiceModeYieldsToOrdinaryTyping(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + + for _, key := range []tea.KeyPressMsg{ + testKeyText("f"), + testKeyText("i"), + testKeyText("x"), + testKey(tea.KeySpace), + } { + updated, _ := m.Update(key) + m = updated.(model) + } + + if m.dictation.voiceModeEnabled { + t.Fatal("ordinary typing should exit voice mode") + } + if got := m.composerValue(); got != "fix " { + t.Fatalf("composer value = %q, want typed prompt with its space preserved", got) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f8b220341..68f7e0571 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1676,10 +1676,19 @@ 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.dictation.active() && !m.transcriptDetailed && keyPrintable(msg) && !keyIs(msg, tea.KeySpace) && m.noBlockingModal(): + // Ordinary typing takes ownership from an idle voice mode immediately. + // Preserve this first character and release any warm dictation server so + // the next Space reaches the composer instead of starting a recording. + next, cmd := m.toggleVoiceMode() + if typed, ok := next.applyComposerKey(msg); ok { + return typed, cmd + } + return next, cmd 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. + // off (/voice or ordinary typing) to type normally. return m.handleVoiceSpacePress(msg) case keyIs(msg, tea.KeyEsc): // Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user, From dae467239cd109241dfd63e6d5394a3b971a29cf Mon Sep 17 00:00:00 2001 From: KRATOS <84986124+gnanam1990@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:32:21 +0530 Subject: [PATCH 2/2] fix(tui): preserve voice exit semantics --- internal/tui/composer.go | 1 + internal/tui/dictation.go | 8 ++- internal/tui/dictation_voice_test.go | 88 +++++++++++++++++++++++++++- internal/tui/model.go | 14 ++++- 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/internal/tui/composer.go b/internal/tui/composer.go index bbe55fad4..20f2d4d17 100644 --- a/internal/tui/composer.go +++ b/internal/tui/composer.go @@ -158,6 +158,7 @@ func (m *model) clearComposer() { m.composerActive = false m.composerPastePreviews = nil m.composerSelection = composerSelectionState{} + m.voiceModeExitedByTyping = false m.input.SetValue("") } diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index 4290555e3..09eeb08de 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -193,15 +193,17 @@ func (m model) toggleDictation() (model, tea.Cmd) { } } -// toggleVoiceMode flips the /voice hold-to-record gesture — the dictation -// trigger. While on, Space records; ordinary typing or /voice turns it off. +// toggleVoiceMode flips the /voice Space-to-record gesture — hold-to-record on +// terminals with release events, press-to-toggle elsewhere. Ordinary typing or +// /voice turns it off. func (m model) toggleVoiceMode() (model, tea.Cmd) { + m.voiceModeExitedByTyping = false 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; start typing or run /voice again to turn it off.", transientNoticeSuccess), nil + return m.showTransientNoticeInline("Voice mode on — use Space to dictate; start typing or 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 diff --git a/internal/tui/dictation_voice_test.go b/internal/tui/dictation_voice_test.go index f951718be..65fedeb5c 100644 --- a/internal/tui/dictation_voice_test.go +++ b/internal/tui/dictation_voice_test.go @@ -36,7 +36,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; start typing or run /voice again to turn it off." { + if next.transientNotice.text != "Voice mode on — use Space to dictate; start typing or run /voice again to turn it off." { t.Errorf("voice-mode-on notice = %q", next.transientNotice.text) } if transcriptHasText(next, "Voice mode on") { @@ -168,3 +168,89 @@ func TestVoiceModeYieldsToOrdinaryTyping(t *testing.T) { t.Fatalf("composer value = %q, want typed prompt with its space preserved", got) } } + +func TestVoiceCommandTypedAfterExitStaysOff(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + + for _, key := range []tea.KeyPressMsg{ + testKeyText("/"), + testKeyText("v"), + testKeyText("o"), + testKeyText("i"), + testKeyText("c"), + testKeyText("e"), + } { + updated, _ := m.Update(key) + m = updated.(model) + } + if got := m.composerValue(); got != "/voice" { + t.Fatalf("composer value = %q, want preserved /voice command", got) + } + updated, _ := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + + if m.dictation.voiceModeEnabled { + t.Fatal("/voice submitted after typing exited voice mode must leave it off") + } + if got := m.composerValue(); got != "" { + t.Fatalf("composer value = %q, want submitted command cleared", got) + } +} + +func TestVoiceCommandStillEnablesNormally(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.dictation = batchOnlyController() + + for _, key := range []tea.KeyPressMsg{ + testKeyText("/"), + testKeyText("v"), + testKeyText("o"), + testKeyText("i"), + testKeyText("c"), + testKeyText("e"), + testKey(tea.KeyEnter), + } { + updated, _ := m.Update(key) + m = updated.(model) + } + + if !m.dictation.voiceModeEnabled { + t.Fatal("an ordinary /voice command should still enable voice mode") + } +} + +func TestVoiceModeCmdVStillProbesClipboardImage(t *testing.T) { + original := readClipboardImage + t.Cleanup(func() { readClipboardImage = original }) + readClipboardImage = func() ([]byte, string, error) { + return []byte("png-bytes"), "image/png", nil + } + + m := newModel(context.Background(), Options{}) + m.dictation = batchOnlyController() + m.dictation.voiceModeEnabled = true + + cmdV := testKeyText("v") + cmdVKey := cmdV.Key() + cmdVKey.Mod = tea.ModSuper + updated, cmd := m.Update(tea.KeyPressMsg(cmdVKey)) + next := updated.(model) + if cmd == nil { + t.Fatal("Cmd+V did not issue the clipboard image probe") + } + image, ok := execCmd(cmd).(clipboardImageMsg) + if !ok { + t.Fatal("Cmd+V command was not the clipboard image probe") + } + if string(image.data) != "png-bytes" || image.mediaType != "image/png" { + t.Fatalf("unexpected image message: %+v", image) + } + if !next.dictation.voiceModeEnabled { + t.Fatal("Cmd+V must not exit voice mode") + } + if got := next.composerValue(); got != "" { + t.Fatalf("composer value after Cmd+V = %q, want unchanged", got) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 68f7e0571..07f82215a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -297,6 +297,10 @@ type model struct { // hide the cursor for those users. terminalFocused bool queuedMessage string + // voiceModeExitedByTyping is scoped to the current composer entry. It keeps + // a typed /voice command from undoing the voice-off transition caused by its + // leading slash while leaving ordinary /voice toggles unchanged. + voiceModeExitedByTyping bool // loops holds the session's active /loop definitions (see loop.go). activeLoopID // tags the in-flight run when it is a loop iteration (empty = a user turn), so the // completion seam knows whether to advance a loop. loopSeq invalidates a stale @@ -1676,12 +1680,16 @@ 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.dictation.active() && !m.transcriptDetailed && keyPrintable(msg) && !keyIs(msg, tea.KeySpace) && m.noBlockingModal(): + case m.dictation.voiceModeEnabled && !m.dictation.active() && !m.transcriptDetailed && keyPrintable(msg) && !keyHasMod(msg, tea.ModSuper) && !keyIs(msg, tea.KeySpace) && m.noBlockingModal(): // Ordinary typing takes ownership from an idle voice mode immediately. // Preserve this first character and release any warm dictation server so // the next Space reaches the composer instead of starting a recording. + composerWasEmpty := m.composerValue() == "" next, cmd := m.toggleVoiceMode() if typed, ok := next.applyComposerKey(msg); ok { + if composerWasEmpty && keyText(msg) == "/" { + typed.voiceModeExitedByTyping = true + } return typed, cmd } return next, cmd @@ -4564,6 +4572,7 @@ func (m model) chooseSuggestion() (tea.Model, tea.Cmd) { func (m model) handleSubmit() (tea.Model, tea.Cmd) { input := m.composerValue() + voiceModeExitedByTyping := m.voiceModeExitedByTyping // A drag-dropped image/PDF path that reached the composer (e.g. inserted as // text) attaches instead of being parsed as an unknown "/…" command. if path, ok := droppedAttachmentPath(input, m.cwd); ok { @@ -4605,6 +4614,9 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) { if command.kind != commandEmpty { m.chatScrollOffset = 0 } + if command.kind == commandVoice && voiceModeExitedByTyping { + return m, nil + } return m.dispatchCommand(command) }