Compare commits

...
Author SHA1 Message Date
KRATOS dae467239c fix(tui): preserve voice exit semantics 2026-08-26 11:32:21 +05:30
KRATOS 4979016ed0 fix(tui): let typing exit idle voice mode 2026-08-26 11:19:35 +05:30
4 changed files with 138 additions and 5 deletions

No files matched your search

+1
View File
@@ -158,6 +158,7 @@ func (m *model) clearComposer() {
m.composerActive = false
m.composerPastePreviews = nil
m.composerSelection = composerSelectionState{}
m.voiceModeExitedByTyping = false
m.input.SetValue("")
}
+5 -3
View File
@@ -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
+110 -1
View File
@@ -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") {
@@ -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)
}
}
+22 -1
View File
@@ -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,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,
@@ -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 {
@@ -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)
}