Skip to content

Commit 28e8803

Browse files
BunsDevclaude
andcommitted
fix(tui): plain V no longer hijacks typing; voice PTT is Alt+V only
Closes #49. The press-half of voice "hold-to-talk" lived at app.rs:4641-4656 and fired on plain `v` (no modifiers) whenever the prompt was in vim Insert mode — i.e., the mode the user is in while typing. Every literal `v` character ("review", "verify", "/coven", …) started a recording instead of going to the prompt buffer. The release-half at app.rs:7174-7185 was gated behind crossterm Press/Release event delivery, which only works on platforms with the kitty keyboard enhancement (mostly Windows). On macOS/Linux without it the press fires, the release never does, and the recording sticks on, silently eating every subsequent keystroke. Both handlers are removed. The voice recorder is still wired up when `voice_enabled: true` is set in `~/.coven-code/ui-settings.json`, but the only way to start a recording is now the explicit Alt+V chord (app.rs:~4588). Alt+V is press-only and toggles record-on / record-off, so it works the same on every platform. Also adds `COVEN_CODE_VOICE=0` (or `=false`) as a per-launch opt-out, requested in #49. Set it to disable voice for the current run without editing the persisted settings file. Refactoring: * App::new()'s inline voice-bootstrap block is extracted to `voice_recorder_from_env_and_settings()` so the env-var/file resolution can be reused and tested. * New `voice_explicitly_disabled()` helper recognises COVEN_CODE_VOICE=0/false. Tests: * `issue_49_plain_v_does_not_start_voice_recording`: with a real VoiceRecorder attached, typing "voice" lands in the prompt buffer and never trips `app.voice_recording`. Before this fix the first `v` would have set `voice_recording = true`. * `issue_49_coven_code_voice_env_opt_out_is_respected`: pins the COVEN_CODE_VOICE=0/false → disabled, =1/unset → enabled matrix. cargo fmt --all -- --check ✅ cargo check --workspace ✅ cargo clippy -- -D warnings ✅ cargo test --workspace ✅ 1551 passing, 0 failing (+2 new) Live tmux smoke test against the release build with `voice_enabled: true`: TUI launches without auto-recording, `/coven` keystrokes reach the input, slash command fires and reports daemon state. fixes #49. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4ff873d commit 28e8803

2 files changed

Lines changed: 139 additions & 55 deletions

File tree

src-rust/crates/tui/src/app.rs

Lines changed: 72 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,57 @@ fn help_overlay_entries() -> Vec<HelpEntry> {
252252
.collect()
253253
}
254254

255+
// ---------------------------------------------------------------------------
256+
// Voice mode bootstrap
257+
// ---------------------------------------------------------------------------
258+
259+
/// `true` when the launcher should treat voice mode as opted out for this
260+
/// run regardless of what `ui-settings.json` says. Set
261+
/// `COVEN_CODE_VOICE=0` (or `false`) to disable voice without editing the
262+
/// stored UI settings — useful when the persistent flag has been turned on
263+
/// and the user wants a one-shot launch where slash-command keystrokes
264+
/// aren't routed through the voice pipeline.
265+
pub(crate) fn voice_explicitly_disabled() -> bool {
266+
matches!(
267+
std::env::var("COVEN_CODE_VOICE").as_deref(),
268+
Ok("0") | Ok("false")
269+
)
270+
}
271+
272+
/// Resolve the voice recorder Arc for this `App` instance.
273+
///
274+
/// Returns `Some(recorder)` only when voice mode is opted in via:
275+
/// * `COVEN_CODE_VOICE_ENABLED=1` (per-launch override), OR
276+
/// * `voice_enabled: true` in `~/.coven-code/ui-settings.json`
277+
///
278+
/// `COVEN_CODE_VOICE=0` (or `=false`) hard-overrides the persistent
279+
/// setting and forces the recorder to `None` for the launch.
280+
fn voice_recorder_from_env_and_settings(
281+
) -> Option<std::sync::Arc<std::sync::Mutex<claurst_core::voice::VoiceRecorder>>> {
282+
if voice_explicitly_disabled() {
283+
return None;
284+
}
285+
let voice_on = std::env::var("COVEN_CODE_VOICE_ENABLED")
286+
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
287+
.unwrap_or(false)
288+
|| {
289+
let path = claurst_core::config::Settings::config_dir().join("ui-settings.json");
290+
std::fs::read_to_string(&path)
291+
.ok()
292+
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
293+
.and_then(|v| v["voice_enabled"].as_bool())
294+
.unwrap_or(false)
295+
};
296+
if !voice_on {
297+
return None;
298+
}
299+
let recorder = claurst_core::voice::global_voice_recorder();
300+
if let Ok(mut r) = recorder.lock() {
301+
r.set_enabled(true);
302+
}
303+
Some(recorder)
304+
}
305+
255306
// ---------------------------------------------------------------------------
256307
// Provider connection helpers
257308
// ---------------------------------------------------------------------------
@@ -1825,32 +1876,7 @@ impl App {
18251876
completion_toast_enabled: true,
18261877
bell_on_complete: false,
18271878
completion_toast_min_secs: 8,
1828-
voice_recorder: {
1829-
// Check whether voice input has been enabled via the /voice command
1830-
// (stored in ~/.coven-code/ui-settings.json). We also accept
1831-
// COVEN_CODE_VOICE_ENABLED=1 as an override for easier testing.
1832-
let voice_on = std::env::var("COVEN_CODE_VOICE_ENABLED")
1833-
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1834-
.unwrap_or(false)
1835-
|| {
1836-
let path =
1837-
claurst_core::config::Settings::config_dir().join("ui-settings.json");
1838-
std::fs::read_to_string(&path)
1839-
.ok()
1840-
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
1841-
.and_then(|v| v["voice_enabled"].as_bool())
1842-
.unwrap_or(false)
1843-
};
1844-
if voice_on {
1845-
let recorder = claurst_core::voice::global_voice_recorder();
1846-
if let Ok(mut r) = recorder.lock() {
1847-
r.set_enabled(true);
1848-
}
1849-
Some(recorder)
1850-
} else {
1851-
None
1852-
}
1853-
},
1879+
voice_recorder: voice_recorder_from_env_and_settings(),
18541880
voice_recording: false,
18551881
voice_event_rx: None,
18561882
pending_key: None,
@@ -4638,22 +4664,18 @@ impl App {
46384664
return false;
46394665
}
46404666

4641-
// ---- Voice PTT: plain V press starts recording when voice is on ----
4642-
// This is the "hold to talk" variant. The user presses V to begin
4643-
// recording; releasing V (handled in the run loop) or pressing Enter
4644-
// stops the capture and triggers transcription.
4645-
// Only active when voice mode is enabled (voice_recorder is Some) and
4646-
// the prompt input is in default (non-vim) mode so 'v' doesn't conflict
4647-
// with vim keybindings.
4648-
if key.code == KeyCode::Char('v')
4649-
&& key.modifiers == KeyModifiers::NONE
4650-
&& self.voice_recorder.is_some()
4651-
&& !self.voice_recording
4652-
&& self.prompt_input.vim_mode == crate::prompt_input::VimMode::Insert
4653-
{
4654-
self.handle_voice_ptt_start();
4655-
return false;
4656-
}
4667+
// Plain-letter `v` is NEVER intercepted as voice PTT. Earlier
4668+
// versions tried to make plain V a "hold-to-talk" key (press to
4669+
// start, release to stop), but the release event is only delivered
4670+
// on platforms with the kitty keyboard enhancement (mostly Windows
4671+
// with the right terminal). On the rest, the press half fires and
4672+
// the release never does — recording sticks on and eats every
4673+
// subsequent keystroke. Even on platforms where release works,
4674+
// the press handler was gated on Insert mode, which is exactly
4675+
// when the user is typing and so means literal `v` characters
4676+
// ("review", "version", "verify", `/coven`, …) all trigger voice.
4677+
// Issue #49 fixes this by routing PTT exclusively through Alt+V
4678+
// (handled above) — there is no plain-V intercept anymore.
46574679

46584680
// ---- Ctrl+V / Cmd+V — clipboard paste (image first, then text fallback) ----
46594681
// Only fires when NOT in vim Normal/Visual/VisualBlock mode (where \x16 is
@@ -7167,20 +7189,15 @@ impl App {
71677189
};
71687190
match event {
71697191
Event::Key(key) => {
7170-
// On Windows crossterm fires both Press and Release events.
7171-
// We normally skip non-press events, but when voice PTT mode
7172-
// is active we need the Release event for the `V` key so we
7173-
// can stop recording as soon as the user lifts the key.
7192+
// crossterm fires both Press and Release events on
7193+
// platforms that support the kitty keyboard
7194+
// enhancement (Windows + some Linux terminals). We
7195+
// only ever act on Press: voice PTT was previously
7196+
// hold-to-talk on plain `v`, which broke typing —
7197+
// see Issue #49. Recording now toggles exclusively
7198+
// through Alt+V (a press-only chord), so the
7199+
// dedicated `v`-release handler is gone.
71747200
if key.kind != crossterm::event::KeyEventKind::Press {
7175-
// Handle V-key release to stop PTT recording.
7176-
if key.kind == crossterm::event::KeyEventKind::Release
7177-
&& key.code == KeyCode::Char('v')
7178-
&& key.modifiers == KeyModifiers::NONE
7179-
&& self.voice_recording
7180-
&& self.voice_recorder.is_some()
7181-
{
7182-
self.handle_voice_ptt_stop();
7183-
}
71847201
continue;
71857202
}
71867203

src-rust/crates/tui/src/lib.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,4 +1625,71 @@ mod tests {
16251625
}
16261626
assert_eq!(app.permission_request.as_ref().unwrap().selected_option, 3);
16271627
}
1628+
1629+
/// Regression for issue #49: with a voice recorder attached, pressing
1630+
/// plain `v` must NOT start recording. Plain-V used to be a broken
1631+
/// hold-to-talk that ate every literal `v` character (typing
1632+
/// "/coven", "review", etc.). Recording now toggles exclusively
1633+
/// through Alt+V.
1634+
#[test]
1635+
fn issue_49_plain_v_does_not_start_voice_recording() {
1636+
let mut app = make_app();
1637+
assert!(
1638+
!app.voice_recording,
1639+
"fresh app must not be recording at startup"
1640+
);
1641+
1642+
// Simulate a voice recorder being attached as if voice_enabled were
1643+
// true in ui-settings.json. The previous behaviour was to start
1644+
// recording on the first plain-`v` keystroke once voice_recorder
1645+
// was Some — we override the field directly to isolate the input
1646+
// path from the env-var/file resolution.
1647+
app.voice_recorder = Some(std::sync::Arc::new(std::sync::Mutex::new(
1648+
claurst_core::voice::VoiceRecorder::new(claurst_core::voice::VoiceConfig {
1649+
enabled: true,
1650+
..Default::default()
1651+
}),
1652+
)));
1653+
1654+
// Type "voice" — every plain `v` press must hit the prompt buffer
1655+
// instead of starting a recording.
1656+
app.handle_key_event(key(KeyCode::Char('v')));
1657+
app.handle_key_event(key(KeyCode::Char('o')));
1658+
app.handle_key_event(key(KeyCode::Char('i')));
1659+
app.handle_key_event(key(KeyCode::Char('c')));
1660+
app.handle_key_event(key(KeyCode::Char('e')));
1661+
1662+
assert!(
1663+
!app.voice_recording,
1664+
"plain `v` must not trigger voice recording (issue #49)"
1665+
);
1666+
assert!(
1667+
app.prompt_input.text.contains("voice"),
1668+
"plain `v` must reach the prompt buffer, got: {:?}",
1669+
app.prompt_input.text
1670+
);
1671+
}
1672+
1673+
/// Regression for issue #49: `COVEN_CODE_VOICE=0` (or `=false`) must
1674+
/// be a hard opt-out that ignores `voice_enabled: true` in
1675+
/// ui-settings.json.
1676+
#[test]
1677+
fn issue_49_coven_code_voice_env_opt_out_is_respected() {
1678+
// SAFETY: env vars are process-global; the surrounding test
1679+
// suite is single-threaded by default for this binary.
1680+
let prev = std::env::var("COVEN_CODE_VOICE").ok();
1681+
1682+
std::env::set_var("COVEN_CODE_VOICE", "0");
1683+
assert!(crate::app::voice_explicitly_disabled());
1684+
std::env::set_var("COVEN_CODE_VOICE", "false");
1685+
assert!(crate::app::voice_explicitly_disabled());
1686+
std::env::set_var("COVEN_CODE_VOICE", "1");
1687+
assert!(!crate::app::voice_explicitly_disabled());
1688+
std::env::remove_var("COVEN_CODE_VOICE");
1689+
assert!(!crate::app::voice_explicitly_disabled());
1690+
1691+
if let Some(v) = prev {
1692+
std::env::set_var("COVEN_CODE_VOICE", v);
1693+
}
1694+
}
16281695
}

0 commit comments

Comments
 (0)