mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae467239c | ||
|
|
4979016ed0 |
No files matched your search
@@ -158,6 +158,7 @@ func (m *model) clearComposer() {
|
||||
m.composerActive = false
|
||||
m.composerPastePreviews = nil
|
||||
m.composerSelection = composerSelectionState{}
|
||||
m.voiceModeExitedByTyping = false
|
||||
m.input.SetValue("")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user