Compare commits

...
7 changed files with 261 additions and 51 deletions

No files matched your search

+1 -1
View File
@@ -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,
},
{
+9 -14
View File
@@ -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-
+41 -10
View File
@@ -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
}
+200 -15
View File
@@ -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)
}
})
}
}
+8 -9
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}