Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/tui/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ func (m *model) clearComposer() {
m.composerActive = false
m.composerPastePreviews = nil
m.composerSelection = composerSelectionState{}
m.voiceModeExitedByTyping = false
m.input.SetValue("")
}

Expand Down
8 changes: 5 additions & 3 deletions internal/tui/dictation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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; run /voice again to type spaces normally.
// 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; 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
Expand Down
111 changes: 110 additions & 1 deletion internal/tui/dictation_voice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 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") {
Expand Down Expand Up @@ -145,3 +145,112 @@ 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)
}
}

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)
}
}
23 changes: 22 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1676,10 +1680,23 @@ 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) && !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
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,
Expand Down Expand Up @@ -4555,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 {
Expand Down Expand Up @@ -4596,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)
}
Expand Down
Loading