Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 91 additions & 11 deletions crates/echo/src/rec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ pub fn run_rec_once() -> i32 {
}

pub fn run_rec_toggle() -> i32 {
match ToggleSession::start_or_stop() {
Ok(action) => {
match start_or_stop_with_intent() {
Ok((action, cancel_transcription)) => {
let recording_token = action.recording_token().map(str::to_string);
if let Err(err) =
status::mark_shortcut_activation("toggle-command", recording_token.as_deref())
Expand All @@ -148,7 +148,9 @@ pub fn run_rec_toggle() -> i32 {
// Preserve the CLI/hotkey toggle convention after capture:
// its stop gesture means cancel while transcription is live.
// Explicit desktop capture-stop never takes this path.
apply_toggle_stop_intent(&owner);
if cancel_transcription {
apply_toggle_stop_intent(&owner);
}
0
}
}
Expand All @@ -163,8 +165,8 @@ pub fn run_rec_toggle() -> i32 {
/// Toggle an in-process recording after synchronously acquiring or stopping
/// the cross-process session. Recording work continues on a background thread.
pub fn toggle_managed_recording() -> Result<Option<String>, String> {
match ToggleSession::start_or_stop()? {
ToggleAction::Start(session) => {
match start_or_stop_with_intent()? {
(ToggleAction::Start(session), _) => {
let recording_token = session.token.clone();
std::thread::Builder::new()
.name("echo-record-toggle".to_string())
Expand All @@ -174,18 +176,40 @@ pub fn toggle_managed_recording() -> Result<Option<String>, String> {
.map(|_| Some(recording_token))
.map_err(|err| err.to_string())
}
ToggleAction::Stop(owner) => {
apply_toggle_stop_intent(&owner);
(ToggleAction::Stop(owner), cancel_transcription) => {
if cancel_transcription {
apply_toggle_stop_intent(&owner);
}
Ok(owner.token)
}
}
}

/// Decide the meaning of a toggle before writing a capture-stop signal. The
/// token check prevents an observation of an earlier owner from authorizing a
/// cancellation for a replacement owner.
fn start_or_stop_with_intent() -> Result<(ToggleAction, bool), String> {
decide_toggle_intent(status::read, ToggleSession::start_or_stop)
}

fn decide_toggle_intent(
read_status: impl FnOnce() -> status::Status,
start_or_stop: impl FnOnce() -> Result<ToggleAction, String>,
) -> Result<(ToggleAction, bool), String> {
let observed = read_status();
let action = start_or_stop()?;
let cancel_transcription =
matches!(&action, ToggleAction::Stop(owner) if should_cancel_toggle(&observed, owner));
Ok((action, cancel_transcription))
}

fn should_cancel_toggle(observed: &status::Status, owner: &LockOwner) -> bool {
observed.state == "Transcribing" && observed.session_id.as_deref() == owner.token.as_deref()
}

fn apply_toggle_stop_intent(owner: &LockOwner) {
if status::read().state == "Transcribing" {
if let Some(token) = owner.token.as_deref() {
let _ = ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), token);
}
if let Some(token) = owner.token.as_deref() {
let _ = ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), token);
}
}

Expand Down Expand Up @@ -1624,6 +1648,62 @@ mod tests {
let _ = fs::remove_dir_all(dir);
}

#[test]
fn capture_toggle_observed_before_transition_never_becomes_cancel() {
let owner = LockOwner {
pid: 1,
token: Some("capture-a".to_string()),
start_time_ticks: Some(1),
scoped_intents: true,
};
let recording = status::Status {
state: "Recording".to_string(),
last: None,
last_history_id: None,
error: None,
recording_limit: None,
session_id: Some("capture-a".to_string()),
revision: 2,
};
let read_happened = Cell::new(false);
let (_, cancel) = decide_toggle_intent(
|| {
read_happened.set(true);
recording.clone()
},
|| {
assert!(
read_happened.get(),
"status must be observed before stop writes its signal"
);
// This callback represents capture ending and the owner
// immediately publishing Transcribing.
Ok(ToggleAction::Stop(owner.clone()))
},
)
.unwrap();
assert!(!cancel);
let transcribing = status::Status {
state: "Transcribing".to_string(),
..recording.clone()
};
let (_, cancel) =
decide_toggle_intent(|| transcribing, || Ok(ToggleAction::Stop(owner.clone())))
.unwrap();
assert!(cancel);
let transcribing_replacement = status::Status {
state: "Transcribing".to_string(),
session_id: Some("capture-b".to_string()),
..recording
};
let (_, cancel) = decide_toggle_intent(
|| transcribing_replacement,
|| Ok(ToggleAction::Stop(owner)),
)
.unwrap();
assert!(!cancel);
}

#[test]
fn fixture_returns_wav_without_opening_host() {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/claude_code.wav");
Expand Down