mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72440a85bf | ||
|
|
ed38f57fab | ||
|
|
7e6f5e2a4a |
No files matched your search
@@ -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 (" + voiceCaptureUsage + ").",
|
||||
kind: commandVoice,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 — "+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
|
||||
@@ -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
|
||||
@@ -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…")
|
||||
}
|
||||
@@ -601,13 +597,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(" · "+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-
|
||||
|
||||
@@ -4,17 +4,19 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// Voice mode's Space-hold gesture — the (only) dictation trigger. Two terminal
|
||||
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:
|
||||
//
|
||||
// 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 +29,37 @@ 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Tier 2: no release events — press-to-toggle.
|
||||
return m.toggleDictation()
|
||||
@@ -45,8 +76,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
|
||||
}
|
||||
|
||||
@@ -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 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") {
|
||||
@@ -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,186 @@ 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 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 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 {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; "+voiceCaptureUsage+".", transientNoticeSuccess), nil
|
||||
}
|
||||
|
||||
// sttKeyPromptOverlay renders the API-key prompt as a centered modal, matching
|
||||
|
||||
@@ -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; " + voiceCaptureUsage + "."
|
||||
}
|
||||
return m, hint
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user