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