diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 61aaa797..afac7e88 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -42,7 +42,20 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { if ran_by_grok { "grok" } else { agent } } +/// The tools that stop a turn to ask the user a question: Claude's +/// `AskUserQuestion` and Codex's `request_user_input`. Neither goes through a +/// permission prompt, so their `PreToolUse` is the only sign the turn is now +/// waiting on the user. +const QUESTION_TOOLS: &[&str] = &["AskUserQuestion", "request_user_input"]; + fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { + // `tool-start` is not an event of its own: it fires before every tool + // call, and only the ones that ask the user something change the status. + if event == "tool-start" { + let payload = serde_json::from_str::(stdin_json).ok()?; + let tool = payload.get("tool_name").and_then(|t| t.as_str())?; + return QUESTION_TOOLS.contains(&tool).then_some("question-asked"); + } // Antigravity has no turn-start event, only `PreInvocation` before every // model call. The first call of a turn starts it; later ones are the same // turn still working, and must not count as new prompts. `Stop` with @@ -892,18 +905,33 @@ fn home_dir() -> Option { const OWNED_FILE_STEM_JSON: &str = "tty7.json"; const OWNED_FILE_STEM_JS: &str = "tty7.js"; +/// `PermissionRequest` fires the moment the permission dialog opens; the +/// `Notification` that also reports it can trail by seconds, and stays as the +/// fallback for older Claude builds that do not know the event. A hook that +/// prints nothing leaves the decision to the dialog. `PreToolUse` is there for +/// `AskUserQuestion` alone (see [`QUESTION_TOOLS`]). const CLAUDE_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + ("PreToolUse", "tool-start"), ("Notification", "notification"), ("PostToolUse", "tool-complete"), ("Stop", "stop"), ("SessionEnd", "session-end"), ]; +/// Codex's `PermissionRequest` answered with no output falls through to its +/// own approval prompt, so it reports the wait without deciding anything. +/// `PostToolUse` is what takes the pane off waiting once the approved tool has +/// run, and `PreToolUse` catches `request_user_input` (see +/// [`QUESTION_TOOLS`]), which Codex runs without asking for approval. const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + ("PreToolUse", "tool-start"), + ("PostToolUse", "tool-complete"), ("Stop", "stop"), ]; @@ -1966,6 +1994,46 @@ mod tests { assert_eq!(effective_agent("grok", false), "grok"); } + #[test] + fn tool_start_only_speaks_for_question_tools() { + for (agent, tool) in [ + ("claude", "AskUserQuestion"), + ("codex", "request_user_input"), + ] { + let input = format!(r#"{{"tool_name":"{tool}","tool_input":{{}}}}"#); + assert_eq!( + effective_event(agent, "tool-start", &input), + Some("question-asked"), + "{agent} {tool}" + ); + } + for input in [ + r#"{"tool_name":"Bash"}"#, + r#"{"tool_name":"shell"}"#, + "{}", + "not json", + ] { + assert_eq!( + effective_event("claude", "tool-start", input), + None, + "{input}" + ); + } + } + + #[test] + fn claude_and_codex_report_permission_prompts_first_hand() { + for events in [CLAUDE_HOOK_EVENTS, CODEX_HOOK_EVENTS] { + for (hook, sentinel) in [ + ("PermissionRequest", "permission-request"), + ("PreToolUse", "tool-start"), + ("PostToolUse", "tool-complete"), + ] { + assert!(events.contains(&(hook, sentinel)), "{hook}"); + } + } + } + #[test] fn every_installed_event_parses_as_a_sentinel_kind() { use crate::core::cli_agent::parse_agent_event; @@ -1985,6 +2053,9 @@ mod tests { .chain(CURSOR_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) + // Never sent as itself: `effective_event` turns it into a real + // kind or drops it (`tool_start_only_speaks_for_question_tools`). + .filter(|e| *e != "tool-start") .collect(); events.extend([ "prompt-submit", diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 8b033eb3..09de41d4 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -971,6 +971,13 @@ pub struct AgentSessionState { /// later one that finished while nobody was watching (#870). #[serde(default)] pub turns: u64, + /// The status was concluded by tty7, not reported by the agent: a turn + /// the user interrupted ([`Self::assume_interrupted`]) or one that went + /// quiet for too long ([`Self::assume_stale`]). Cleared by the next real + /// event, and a tool finishing after it proves the guess wrong and puts + /// the turn back on [`AgentStatus::Working`]. + #[serde(default)] + pub inferred: bool, } impl AgentStatus { @@ -989,8 +996,44 @@ impl AgentSessionState { AgentStatus::Idle } + /// Whether a turn is in flight: the only statuses a user interrupt or a + /// lost `Stop` can leave behind. + pub fn mid_turn(&self) -> bool { + matches!(self.status, AgentStatus::Working | AgentStatus::Waiting) + } + + /// End the turn the user just interrupted. + /// + /// Most agents fire no hook when Esc or Ctrl+C + /// cancels a turn — Claude's `Stop` explicitly skips user interrupts — so + /// without this the pane stays on working until the next prompt, and a + /// close asks about a turn that is long over. The caller has already + /// waited for a real event to say otherwise. + pub fn assume_interrupted(&mut self) { + if !self.mid_turn() { + return; + } + self.turns = self.turns.wrapping_add(1); + self.status = AgentStatus::Done; + self.message = None; + self.inferred = true; + } + + /// Give up on a turn that has gone silent for longer than any real one + /// stays quiet. Idle rather than Done: nothing says it finished, only + /// that nothing says it is still going. + pub fn assume_stale(&mut self) { + if self.status != AgentStatus::Working { + return; + } + self.status = AgentStatus::Idle; + self.message = None; + self.inferred = true; + } + pub fn apply_event(&mut self, ev: &AgentEvent) { self.rich = true; + let guessed = std::mem::take(&mut self.inferred); if let Some(id) = &ev.session_id { self.session_id = Some(id.clone()); } @@ -1018,7 +1061,7 @@ impl AgentSessionState { } AgentEventKind::ToolComplete => { self.activity = self.activity.wrapping_add(1); - if self.status == AgentStatus::Waiting { + if self.status == AgentStatus::Waiting || guessed { self.status = AgentStatus::Working; self.message = None; } @@ -1648,6 +1691,95 @@ mod tests { assert_eq!(s.activity, 4); } + #[test] + fn an_assumed_interrupt_ends_the_turn_until_the_agent_says_otherwise() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + + let mut s = AgentSessionState::default(); + s.assume_interrupted(); + assert_eq!(s.status, AgentStatus::Idle, "no turn, nothing to interrupt"); + assert!(!s.inferred); + + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::PermissionRequest)); + s.assume_interrupted(); + assert_eq!( + s.status, + AgentStatus::Done, + "a rejected prompt ends the turn" + ); + assert_eq!(s.turns, 1); + assert!(s.inferred); + + s.apply_event(&ev(AgentEventKind::ToolComplete)); + assert_eq!( + s.status, + AgentStatus::Working, + "a tool finishing after the key proves the turn was still going" + ); + assert!(!s.inferred); + + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 2); + s.apply_event(&ev(AgentEventKind::ToolComplete)); + assert_eq!( + s.status, + AgentStatus::Done, + "a real Stop is not a guess and stays put" + ); + + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.assume_interrupted(); + s.apply_event(&ev(AgentEventKind::Notification)); + assert!(!s.inferred, "any real event retires the guess"); + s.apply_event(&ev(AgentEventKind::ToolComplete)); + assert_eq!(s.status, AgentStatus::Done); + } + + #[test] + fn a_stale_turn_goes_idle_and_comes_back_on_the_next_tool() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::PermissionRequest)); + s.assume_stale(); + assert_eq!( + s.status, + AgentStatus::Waiting, + "a prompt nobody answered is not stale" + ); + + s.apply_event(&ev(AgentEventKind::ToolComplete)); + s.assume_stale(); + assert_eq!(s.status, AgentStatus::Idle); + assert_eq!(s.turns, 0, "going quiet is not finishing"); + assert!(s.inferred); + + s.apply_event(&ev(AgentEventKind::ToolComplete)); + assert_eq!(s.status, AgentStatus::Working); + } + + #[test] + fn a_session_state_from_an_older_daemon_is_not_inferred() { + let s: AgentSessionState = serde_json::from_str(r#"{"status":"done"}"#).unwrap(); + assert!(!s.inferred); + } + #[test] fn each_finished_turn_is_counted_once() { let ev = |kind| AgentEvent { diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 00feed3c..bc3f71ce 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1715,6 +1715,7 @@ mod tests { cwd: Some("/work/api".into()), activity: 3, turns: 1, + inferred: false, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 6492ca1f..9a23b1eb 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -717,10 +717,77 @@ struct PaneState { agent: Option, agent_argv: Option>, agent_session: Option, + agent_clock: AgentClock, alive: bool, exit_code: Option, } +/// When the agent session last heard from the agent, for the two conclusions +/// tty7 draws from silence: a turn the user interrupted, and a turn whose end +/// was lost. +#[derive(Default)] +struct AgentClock { + /// Bumped by every hook event and every session reset, so a pending + /// interrupt can tell whether anything spoke after the key it was armed by. + generation: u64, + /// The last hook event, or when the sweep first saw a turn it had no + /// event time for (a pane carried across a daemon handoff). + last_event: Option, + /// An interrupt key is already waiting out [`INTERRUPT_SETTLE`]. + interrupt_pending: bool, +} + +/// How long after an interrupt key a real hook event still gets to speak +/// first. Agents that do report an interrupt (Kimi's `Interrupt`, a `Stop` +/// racing the key) do it well inside this. +const INTERRUPT_SETTLE: Duration = Duration::from_millis(1000); + +/// How long a turn may stay on working with no hook event at all before the +/// status is given up on. Far past any single quiet stretch a live turn has: +/// Claude's longest tool call is capped at ten minutes, and every other event +/// — a tool finishing, a permission prompt — resets the clock. +pub(crate) const AGENT_STALE_AFTER: Duration = Duration::from_secs(30 * 60); + +/// Whether one input write is the user asking a running agent to stop: a bare +/// Esc or Ctrl+C, in the legacy encoding or in the +/// kitty keyboard protocol and xterm `modifyOtherKeys` forms a TUI may have +/// switched the terminal into. Only a whole write counts — the key arrives on +/// its own, and a paste that happens to hold `0x03` is not a keypress. +fn is_interrupt_key(bytes: &[u8]) -> bool { + match bytes { + b"\x1b" | b"\x03" | b"\x1b[27;5;99~" => return true, + _ => {} + } + let Some(body) = bytes + .strip_prefix(b"\x1b[") + .and_then(|b| b.strip_suffix(b"u")) + .and_then(|b| std::str::from_utf8(b).ok()) + else { + return false; + }; + let mut fields = body.split(';'); + let Some(code) = fields + .next() + .and_then(|f| f.split(':').next()) + .and_then(|c| c.parse::().ok()) + else { + return false; + }; + let mut modifiers = fields.next().unwrap_or("1").split(':'); + let mods = modifiers + .next() + .and_then(|m| m.parse::().ok()) + .unwrap_or(1); + let event = modifiers.next().map_or(Some(1), |e| e.parse::().ok()); + if event != Some(1) { + return false; + } + // Caps Lock and Num Lock ride along in the mask without changing the key. + const LOCKS: u32 = 64 | 128; + let mods = mods.saturating_sub(1) & !LOCKS; + (code == 27 && mods == 0) || (code == 99 && mods == 4) +} + fn notify(st: &mut PaneState, msg: DaemonMsg) { if let Some(sub) = &st.subscriber { let _ = sub.send(msg.clone()); @@ -1594,6 +1661,7 @@ impl DaemonPane { agent: None, agent_session: None, agent_argv: None, + agent_clock: AgentClock::default(), alive: true, exit_code: None, }, @@ -1825,6 +1893,7 @@ impl DaemonPane { agent: carried.agent, agent_session: carried.agent_session, agent_argv: carried.agent_argv, + agent_clock: AgentClock::default(), alive: true, exit_code: None, }, @@ -1879,6 +1948,7 @@ impl DaemonPane { agent: None, agent_session: None, agent_argv: None, + agent_clock: AgentClock::default(), alive: true, exit_code: None, })); @@ -2251,6 +2321,11 @@ impl DaemonPane { agent_state_snapshot(&self.state.lock().unwrap()) } + /// See [`expire_stale_agent`]. + pub fn expire_stale_agent(&self) { + expire_stale_agent(&mut self.state.lock().unwrap(), std::time::Instant::now()); + } + pub fn gate(&self) -> Arc { self.gate.clone() } @@ -2259,6 +2334,9 @@ impl DaemonPane { if bytes.is_empty() { return; } + if is_interrupt_key(bytes) { + arm_interrupt(&self.state); + } if let Ok(mut writer) = self.writer.lock() { let _ = writer.write_all(bytes); let _ = writer.flush(); @@ -3067,6 +3145,73 @@ fn agent_from_shell_mark( Some((agent, crate::core::cli_agent::command_argv(cmd))) } +/// Start waiting out an interrupt key on a pane whose agent is mid-turn. +/// +/// The key only *may* have stopped the turn — it also dismisses menus and +/// clears half-typed input — so nothing changes until [`INTERRUPT_SETTLE`] +/// passes with no hook event. A guess that still turns out wrong is undone by +/// the agent's next finished tool ([`AgentSessionState::apply_event`]). +/// +/// [`AgentSessionState::apply_event`]: crate::core::cli_agent::AgentSessionState::apply_event +fn arm_interrupt(state: &Arc>) { + let generation = { + let mut st = state.lock().unwrap(); + let armable = st + .agent_session + .as_ref() + .is_some_and(|s| s.rich && s.mid_turn()); + if !armable || st.agent_clock.interrupt_pending { + return; + } + st.agent_clock.interrupt_pending = true; + st.agent_clock.generation + }; + let waiter = state.clone(); + let spawned = std::thread::Builder::new() + .name("tty7-agent-interrupt".into()) + .spawn(move || { + std::thread::sleep(INTERRUPT_SETTLE); + let mut st = waiter.lock().unwrap(); + settle_interrupt(&mut st, generation); + }); + if spawned.is_err() { + state.lock().unwrap().agent_clock.interrupt_pending = false; + } +} + +fn settle_interrupt(st: &mut PaneState, armed_at: u64) { + st.agent_clock.interrupt_pending = false; + if st.agent_clock.generation != armed_at || !st.alive { + return; + } + let Some(sess) = st.agent_session.as_mut().filter(|s| s.mid_turn()) else { + return; + }; + sess.assume_interrupted(); + notify(st, DaemonMsg::AgentStatus(st.agent_session.clone())); +} + +/// Give up on a turn that has said nothing for [`AGENT_STALE_AFTER`]. Run by +/// the daemon's periodic sweep, since a silent pane produces nothing that +/// would otherwise wake anything up to notice. +fn expire_stale_agent(st: &mut PaneState, now: std::time::Instant) { + let working = st + .agent_session + .as_ref() + .is_some_and(|s| s.status == crate::core::cli_agent::AgentStatus::Working); + if !working { + return; + } + let since = *st.agent_clock.last_event.get_or_insert(now); + if now.saturating_duration_since(since) < AGENT_STALE_AFTER { + return; + } + if let Some(sess) = st.agent_session.as_mut() { + sess.assume_stale(); + } + notify(st, DaemonMsg::AgentStatus(st.agent_session.clone())); +} + fn apply_agent_signals( st: &mut PaneState, events: Vec, @@ -3092,6 +3237,8 @@ fn apply_agent_signals( st.agent = event.agent; notify(st, DaemonMsg::Agent(st.agent)); } + st.agent_clock.generation = st.agent_clock.generation.wrapping_add(1); + st.agent_clock.last_event = Some(std::time::Instant::now()); st.agent_session .get_or_insert_with(AgentSessionState::default) .apply_event(event); @@ -3218,6 +3365,8 @@ fn apply_agent( // Claude to omp (or back to the shell) must not keep the previous id. if st.agent_session.is_some() { st.agent_session = None; + st.agent_clock.generation = st.agent_clock.generation.wrapping_add(1); + st.agent_clock.last_event = None; notify(st, DaemonMsg::AgentStatus(None)); } if agent.is_none() { @@ -5170,11 +5319,159 @@ mod tests { agent: None, agent_session: None, agent_argv: None, + agent_clock: AgentClock::default(), alive, exit_code: None, } } + #[test] + fn interrupt_keys_are_recognised_in_every_encoding() { + for key in [ + &b"\x1b"[..], + b"\x03", + b"\x1b[27u", + b"\x1b[27;1u", + b"\x1b[27;1:1u", + b"\x1b[27;65u", + b"\x1b[99;5u", + b"\x1b[99;5:1u", + b"\x1b[99:67;5u", + b"\x1b[27;5;99~", + ] { + assert!(is_interrupt_key(key), "{key:?}"); + } + for key in [ + &b"\x1b[A"[..], + b"\x1b\x1b", + b"\x1b[27;1:3u", + b"\x1b[27;3u", + b"\x1b[99u", + b"\x1b[99;6u", + b"abc\x03", + b"\x1b[13u", + b"q", + ] { + assert!(!is_interrupt_key(key), "{key:?}"); + } + } + + fn working_session() -> crate::core::cli_agent::AgentSessionState { + crate::core::cli_agent::AgentSessionState { + status: crate::core::cli_agent::AgentStatus::Working, + rich: true, + ..Default::default() + } + } + + #[test] + fn an_interrupt_settles_only_if_nothing_spoke_after_it() { + use crate::core::cli_agent::AgentStatus; + + let mut st = test_state(true); + st.agent_session = Some(working_session()); + st.agent_clock.interrupt_pending = true; + let armed = st.agent_clock.generation; + settle_interrupt(&mut st, armed); + let sess = st.agent_session.as_ref().unwrap(); + assert_eq!(sess.status, AgentStatus::Done); + assert!(sess.inferred); + assert!(!st.agent_clock.interrupt_pending); + + let mut st = test_state(true); + st.agent_session = Some(working_session()); + let armed = st.agent_clock.generation; + st.agent_clock.generation += 1; + settle_interrupt(&mut st, armed); + assert_eq!( + st.agent_session.as_ref().unwrap().status, + AgentStatus::Working, + "a hook event after the key has the last word" + ); + + let mut st = test_state(false); + st.agent_session = Some(working_session()); + let armed = st.agent_clock.generation; + settle_interrupt(&mut st, armed); + assert_eq!( + st.agent_session.as_ref().unwrap().status, + AgentStatus::Working, + "a dead pane is left to its exit report" + ); + } + + #[test] + fn an_interrupt_arms_once_and_only_mid_turn() { + let state = Arc::new(Mutex::new(test_state(true))); + arm_interrupt(&state); + assert!( + !state.lock().unwrap().agent_clock.interrupt_pending, + "no agent" + ); + + state.lock().unwrap().agent_session = Some(crate::core::cli_agent::AgentSessionState { + rich: false, + ..working_session() + }); + arm_interrupt(&state); + assert!( + !state.lock().unwrap().agent_clock.interrupt_pending, + "a status without hooks behind it has no Stop to miss" + ); + + state.lock().unwrap().agent_session = Some(working_session()); + arm_interrupt(&state); + assert!(state.lock().unwrap().agent_clock.interrupt_pending); + } + + #[test] + fn a_turn_silent_past_the_limit_goes_idle() { + use crate::core::cli_agent::AgentStatus; + + let now = std::time::Instant::now(); + let mut st = test_state(true); + st.agent_session = Some(working_session()); + + expire_stale_agent(&mut st, now); + assert_eq!( + st.agent_clock.last_event, + Some(now), + "a turn with no event time starts the clock rather than expiring" + ); + expire_stale_agent(&mut st, now + AGENT_STALE_AFTER - Duration::from_secs(1)); + assert_eq!( + st.agent_session.as_ref().unwrap().status, + AgentStatus::Working + ); + + expire_stale_agent(&mut st, now + AGENT_STALE_AFTER); + let sess = st.agent_session.as_ref().unwrap(); + assert_eq!(sess.status, AgentStatus::Idle); + assert!(sess.inferred); + } + + #[test] + fn hook_events_restart_the_agent_clock() { + use crate::core::cli_agent::{AgentEvent, AgentEventKind}; + + let mut st = test_state(true); + let before = st.agent_clock.generation; + apply_agent_signals( + &mut st, + vec![AgentEvent { + agent: None, + kind: AgentEventKind::PromptSubmit, + session_id: None, + message: None, + cwd: None, + prompt: None, + }], + None, + ); + assert_ne!(st.agent_clock.generation, before); + assert!(st.agent_clock.last_event.is_some()); + } + #[test] fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; @@ -5473,6 +5770,7 @@ mod tests { cwd: None, activity: 0, turns: 0, + inferred: false, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index fad170f6..65ea1a58 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1905,6 +1905,7 @@ mod tests { cwd: Some("/repo/.claude/worktrees/fix-x".into()), activity: 12, turns: 4, + inferred: false, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index de4c4f5f..a0282095 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -117,6 +117,27 @@ impl crate::host::server::PaneDirectory for Registry { } } +/// How often panes are checked for an agent turn that has gone silent past +/// [`crate::daemon::pane::AGENT_STALE_AFTER`]. The threshold is half an hour, +/// so being up to a minute late to notice costs nothing. +const AGENT_STALE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); + +fn spawn_agent_stale_sweep(registry: Arc) { + let spawned = std::thread::Builder::new() + .name("tty7-agent-stale".into()) + .spawn(move || { + loop { + std::thread::sleep(AGENT_STALE_SWEEP_INTERVAL); + for pane in registry.all() { + pane.expire_stale_agent(); + } + } + }); + if let Err(e) = spawned { + log::warn!("could not start the agent status sweep: {e}"); + } +} + const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600); fn spawn_orphan_sweep(registry: Arc) { @@ -606,6 +627,7 @@ fn run_with(registry: Arc, alone: bool) -> anyhow::Result<()> { } spawn_orphan_sweep(registry.clone()); + spawn_agent_stale_sweep(registry.clone()); // No sweeping here, deliberately — of either kind. Startup is the one // moment this process knows least: it owns no panes yet, and the windows // that know which of a dead daemon's files are still wanted cannot say so diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 9673e5d2..c9c3b0e9 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -6496,6 +6496,7 @@ mod tests { cwd: None, activity: 0, turns: 0, + inferred: false, })) .encode(daemon) .unwrap(); @@ -6557,6 +6558,7 @@ mod tests { cwd: None, activity: 0, turns: 0, + inferred: false, })) .encode(&mut daemon_side) .unwrap(); @@ -6607,6 +6609,7 @@ mod tests { cwd: None, activity: 0, turns: 0, + inferred: false, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 16cba3f5..cde55b33 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6,7 +6,7 @@ use alacritty_terminal::term::TermMode; use gpui::{ App, ClipboardEntry, ClipboardItem, Context, EntityId, ExternalPaths, FocusHandle, Focusable, Font, KeyDownEvent, Modifiers, MouseButton, MouseDownEvent, Pixels, ScrollDelta, - ScrollWheelEvent, WeakEntity, Window, actions, div, prelude::*, px, + ScrollWheelEvent, Task, WeakEntity, Window, actions, div, prelude::*, px, }; use gpui_component::kbd::Kbd; use gpui_component::menu::{ContextMenuExt, PopupMenuItem}; @@ -437,6 +437,10 @@ pub struct TerminalView { last_agent_status: Option, last_agent_session: (Option, Option>), agent_turn_started: Option, + /// The "finished" notification for a turn that just reached `Done`, + /// waiting out [`AGENT_DONE_SETTLE`]. Dropped — and so never sent — when + /// the status moves on first. + pending_finish_notice: Option>, agent_was_rich: bool, agent_result_unread: bool, keep_unread_on_focus: bool, @@ -664,6 +668,11 @@ const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_mil /// wait for it is still what the reader is doing. const TITLE_SETTLE: std::time::Duration = std::time::Duration::from_millis(400); +/// How long a turn has to stay finished before it is announced. Long enough +/// for a queued message or a `Stop` hook to start the next turn, short enough +/// that the notification still reads as the moment the agent stopped. +const AGENT_DONE_SETTLE: std::time::Duration = std::time::Duration::from_millis(1500); + /// What a pane does with a title the program just set — see /// `TerminalView::set_title_when_settled`. #[derive(Debug, PartialEq, Eq)] @@ -1659,6 +1668,7 @@ impl TerminalView { last_agent_status: None, last_agent_session: (None, None), agent_turn_started: None, + pending_finish_notice: None, agent_was_rich: false, agent_result_unread: false, keep_unread_on_focus: false, @@ -4103,6 +4113,10 @@ impl TerminalView { } let prev = std::mem::replace(&mut self.last_agent_status, status); let first_sight = !std::mem::replace(&mut self.agent_status_seen, true); + self.pending_finish_notice = None; + // tty7's own conclusion, not the agent's: the user interrupted the + // turn, or it went silent. Neither is a result to announce. + let inferred = session.as_ref().is_some_and(|s| s.inferred); // A view built over a pane that already holds a finished turn sees // `Done` arrive from nothing, the same as a turn finishing now. If the @@ -4139,7 +4153,7 @@ impl TerminalView { // built, before its leaf was attached, never gets the blur that // would clear `self.focused` — trusting the cached flag there // drops the badge on the one pane the reader is not looking at. - self.agent_result_unread = !self.focus_handle.is_focused(window); + self.agent_result_unread = !inferred && !self.focus_handle.is_focused(window); self.keep_unread_on_focus = false; } Some(AgentStatus::Done) => {} @@ -4170,6 +4184,7 @@ impl TerminalView { Some(AgentStatus::Done) if rich && notify_allowed + && !inferred && matches!( prev, Some(AgentStatus::Working) | Some(AgentStatus::Waiting) @@ -4182,7 +4197,19 @@ impl TerminalView { } None => t(L10nKey::NotifyTurnFinished).to_string(), }; - self.notify_pane(Some(agent_name), &body, cx); + // Held back briefly: a turn that ends only for the next one to + // start at once — a message queued while the agent worked, a + // `Stop` hook sending it back to work — is not finished, and + // announcing it would be a notification about nothing. + let agent_name = agent_name.to_string(); + self.pending_finish_notice = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(AGENT_DONE_SETTLE).await; + this.update(cx, |view, cx| { + view.pending_finish_notice = None; + view.notify_pane(Some(&agent_name), &body, cx); + }) + .ok(); + })); } _ => {} } @@ -10466,6 +10493,7 @@ mod gpui_tests { cwd: None, activity: 0, turns: 0, + inferred: false, })) .encode(daemon) .unwrap(); @@ -10522,6 +10550,7 @@ mod gpui_tests { cwd: None, activity: 0, turns: 0, + inferred: false, })) .encode(&mut daemon) .unwrap(); @@ -10589,6 +10618,7 @@ mod gpui_tests { cwd: None, activity: 0, turns, + inferred: false, }; DaemonMsg::AgentStatus(Some(state.clone())) .encode(daemon) @@ -11046,6 +11076,7 @@ mod gpui_tests { cwd: Some(working_in.clone()), activity: 0, turns: 0, + inferred: false, })) .encode(&mut daemon) .unwrap();