From f2b07defda59692fbddfd5eef6ac837941d8f3f6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:41:51 +0200 Subject: [PATCH] fix(agents): count only agents that start under a view, not ones reattached to A view rebuilt over a running pane - every agent tab after an app restart, or a workspace switched back to - saw its agent appear from nothing and reported it as detected, bumping that agent's frecency once per launch of the app. The terminal now remembers whether its link was an attach, and such a view only reports agents once its shell has been seen back at the prompt with no agent in front. Also pins down that the agents seen in a remote workspace, its quick launch list, persist in views.json with the rest of the workspace. --- crates/tty7-core/src/core/session.rs | 25 ++++++ src/terminal/remote.rs | 10 +++ src/terminal/view.rs | 124 +++++++++++++++++++++++++-- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index a4f1daf6..6ff7e7e3 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -1179,4 +1179,29 @@ mod tests { views.views.retain(|w| w.id != b_id); assert_eq!(numbers(&views), vec![a_id, c_id]); } + + /// A remote workspace's quick-launch list is the agents seen running in + /// it, so the list has to be in `views.json` with the rest of the + /// workspace — an in-memory one would be empty after every restart. + #[test] + fn agents_seen_in_a_workspace_survive_a_restart() { + let _file = lock_session_file(); + pin_config_dir(); + let mut entry = remote_view("build-box"); + assert!(entry.saw_agent("claude")); + assert!(entry.saw_agent("codex")); + assert!(!entry.saw_agent("codex"), "already the most recent"); + assert!(entry.saw_agent("claude"), "moves to the end"); + let id = entry.id; + WindowViews { + active: Some(id), + views: vec![entry, view()], + } + .save(); + let loaded = WindowViews::load().expect("a saved views file should load back"); + assert_eq!(loaded.get(id).unwrap().seen_agents, vec!["codex", "claude"]); + // A workspace that has seen nothing writes nothing for it. + let text = std::fs::read_to_string(WindowViews::path().unwrap()).unwrap(); + assert_eq!(text.matches("seen_agents").count(), 1); + } } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index b319c5c2..9d45f505 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -571,6 +571,10 @@ pub struct RemoteTerminal { pub exited: bool, size: TermSize, synced_size: bool, + /// This link reattached to a pane that was already running rather than + /// spawning a fresh one, so whatever it reports first — an agent in the + /// foreground included — predates this client. + reattached: bool, /// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked /// alongside `size` so a display-scale change still reaches the child even /// when the grid dimensions are unchanged. @@ -1087,6 +1091,7 @@ impl RemoteTerminal { exited: false, size, synced_size: false, + reattached: awaiting_replay, synced_cell: (0, 0), resize_sent_at: None, link, @@ -1878,6 +1883,11 @@ impl RemoteTerminal { self.remote_context.lock().ok().and_then(|g| g.clone()) } + /// See the field: an attach to a running pane, not a fresh spawn. + pub fn reattached(&self) -> bool { + self.reattached + } + pub fn at_prompt(&self) -> bool { self.shell_state .lock() diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1f94ec38..1f160d3c 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -445,6 +445,12 @@ pub struct TerminalView { /// The foreground agent as of the last poll, so a new one is reported /// once ([`AgentDetected`]) rather than on every frame it keeps running. seen_agent: Option, + /// Whether an agent appearing here is news. A pane this view spawned is + /// armed from the start; one it reattached to — an app restart, a + /// workspace switched back to — may already be running an agent that was + /// counted when it started, so it arms only once its shell is seen back + /// at the prompt with no agent in front. + agent_detection_armed: bool, last_agent_status: Option, last_agent_session: (Option, Option>), agent_turn_started: Option, @@ -1461,6 +1467,7 @@ impl TerminalView { .map(crate::core::config::gpui_font_features); let report_mouse = config.mouse_reporting; let prompt_editor = config.prompt_editor; + let agent_detection_armed = !terminal.reattached(); let mut font = gpui::font(font_family); font.fallbacks = Some(gpui::FontFallbacks::from_fonts(fallbacks.clone())); if let Some(features) = &font_features { @@ -1676,6 +1683,7 @@ impl TerminalView { running_title: String::new(), running_agent: None, seen_agent: None, + agent_detection_armed, last_agent_status: None, last_agent_session: (None, None), agent_turn_started: None, @@ -3784,13 +3792,7 @@ impl TerminalView { _ => {} } - let agent = self.terminal.foreground_agent(); - if agent != self.seen_agent { - self.seen_agent = agent; - if let Some(agent) = agent { - cx.emit(AgentDetected(agent)); - } - } + self.poll_agent_detection(at_prompt, cx); let turn_finished = self.poll_agent_status(notify_allowed, window, cx); @@ -4052,6 +4054,24 @@ impl TerminalView { ); } + /// Report an agent that has just started in this pane ([`AgentDetected`]), + /// once per start, and never one that was already running when this view + /// reattached to the pane. + fn poll_agent_detection(&mut self, at_prompt: bool, cx: &mut Context) { + let agent = self.terminal.foreground_agent(); + if agent != self.seen_agent { + self.seen_agent = agent; + if let Some(agent) = agent + && self.agent_detection_armed + { + cx.emit(AgentDetected(agent)); + } + } + if agent.is_none() && at_prompt { + self.agent_detection_armed = true; + } + } + fn poll_agent_status( &mut self, notify_allowed: bool, @@ -16301,6 +16321,96 @@ mod gpui_tests { }) .unwrap(); } + + /// Quick launch counts an agent each time one starts. Reattaching to a pane + /// whose agent was already running — every agent tab after an app restart + /// — is not a start, and counting it bumped every agent once per launch of + /// the app. Once that pane's agent exits, the next one is a start again. + #[gpui::test] + fn only_an_agent_that_starts_under_this_view_counts_as_detected(cx: &mut TestAppContext) { + use crate::core::cli_agent::CLIAgent; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let detected = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let pane = |reattached: bool, cx: &mut TestAppContext| { + let (pane, daemon) = window + .update(cx, |_, window, cx| { + if reattached { + super::quiet_reattached_test_pane(2, window, cx) + } else { + super::quiet_test_pane(3, window, cx) + } + }) + .unwrap(); + let seen = detected.clone(); + cx.update(|cx| { + cx.subscribe(&pane, move |_, ev: &AgentDetected, _| { + seen.borrow_mut().push(ev.0) + }) + .detach() + }); + (pane, daemon) + }; + let report = |agent: Option, + at_prompt: bool, + pane: &Entity, + daemon: &mut Stream, + cx: &mut TestAppContext| { + DaemonMsg::Agent(agent).encode(daemon).unwrap(); + for _ in 0..200 { + if pane.read_with(cx, |p, _| p.terminal.foreground_agent()) == agent { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + pane.update(cx, |p, cx| p.poll_agent_detection(at_prompt, cx)); + cx.run_until_parked(); + }; + + let (restored, mut restored_daemon) = pane(true, cx); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + report( + Some(CLIAgent::Claude), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![], + "already running when reattached" + ); + report(None, true, &restored, &mut restored_daemon, cx); + report( + Some(CLIAgent::Codex), + false, + &restored, + &mut restored_daemon, + cx, + ); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex], + "started after the reattach" + ); + + let (fresh, mut fresh_daemon) = pane(false, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + report(Some(CLIAgent::Claude), false, &fresh, &mut fresh_daemon, cx); + assert_eq!( + *detected.borrow(), + vec![CLIAgent::Codex, CLIAgent::Claude], + "a spawned pane counts its first agent, once" + ); + } } /// The window between the shell reporting a prompt and its line editor