feat(agents): infer interrupted and stale turns, report permission prompts first-hand (#1003)

- Interrupts: an Esc / Ctrl+C (legacy, kitty and modifyOtherKeys forms)
  on a pane whose agent is mid-turn arms a 1s settle; if no hook event
  arrives, the turn is assumed over and goes Done. Claude's Stop skips
  user interrupts, so panes used to stay on working until the next prompt.
- Stale turns: a daemon sweep moves a turn that has been Working with no
  hook event for 30 minutes to Idle.
- Both set `inferred` on the session; the next real event clears it, and
  a tool finishing after a guess puts the turn back on Working. Inferred
  states raise no finish notification and no unread badge.
- Claude and Codex now hook PermissionRequest, and PreToolUse for their
  ask-the-user tools (AskUserQuestion / request_user_input), so Waiting
  shows the moment the prompt opens. Codex also gains PostToolUse, so it
  leaves Waiting once the approved tool has run.
- The "finished" desktop notification waits 1.5s and is dropped if the
  next turn starts first (queued message, Stop hook sending it back).
This commit is contained in:
l0ng-ai
2026-09-28 20:47:01 +08:00
committed by GitHub
parent ae38747f69
commit 3ef692b353
8 changed files with 563 additions and 4 deletions
+71
View File
@@ -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::<serde_json::Value>(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<PathBuf> {
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",
+133 -1
View File
@@ -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 <kbd>Esc</kbd> or <kbd>Ctrl+C</kbd>
/// 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 {
+1
View File
@@ -1715,6 +1715,7 @@ mod tests {
cwd: Some("/work/api".into()),
activity: 3,
turns: 1,
inferred: false,
},
}])),
ControlReply::Ok(ReplyOk::AgentStates(Vec::new())),
+298
View File
@@ -717,10 +717,77 @@ struct PaneState {
agent: Option<crate::core::cli_agent::CLIAgent>,
agent_argv: Option<Vec<String>>,
agent_session: Option<crate::core::cli_agent::AgentSessionState>,
agent_clock: AgentClock,
alive: bool,
exit_code: Option<i32>,
}
/// 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<std::time::Instant>,
/// 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
/// <kbd>Esc</kbd> or <kbd>Ctrl+C</kbd>, 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::<u32>().ok())
else {
return false;
};
let mut modifiers = fields.next().unwrap_or("1").split(':');
let mods = modifiers
.next()
.and_then(|m| m.parse::<u32>().ok())
.unwrap_or(1);
let event = modifiers.next().map_or(Some(1), |e| e.parse::<u32>().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<OutputGate> {
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<Mutex<PaneState>>) {
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<crate::core::cli_agent::AgentEvent>,
@@ -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!(
+1
View File
@@ -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 }),
+22
View File
@@ -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<Registry>) {
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<Registry>) {
@@ -606,6 +627,7 @@ fn run_with(registry: Arc<Registry>, 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
+3
View File
@@ -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();
+34 -3
View File
@@ -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<crate::core::cli_agent::AgentStatus>,
last_agent_session: (Option<String>, Option<Vec<String>>),
agent_turn_started: Option<std::time::Instant>,
/// 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<Task<()>>,
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();