diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 309552c1a29..540abc040f2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -99,6 +99,45 @@ const MIN_PANEL_WIDTH: Pixels = px(300.); const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent"; const LAST_CREATED_ENTRY_KIND_KEY: &str = "agent_panel__last_created_entry_kind"; const TERMINAL_AGENT_TELEMETRY_ID: &str = "terminal"; +const KNOWN_TERMINAL_AGENT_COMMANDS: &[&str] = &[ + "agent", // Unfortunately, both Cursor cli + grok + "agy", + "aider", + "amp", + "claude", + "codex", + "copilot", + "crush", + "devin", + "droid", + "gemini", + "goose", + "grok", + "openhands", + "opencode", + "pi", + "qwen", +]; + +fn is_known_terminal_agent_command(command: &str) -> bool { + KNOWN_TERMINAL_AGENT_COMMANDS.contains(&command) +} + +fn terminal_program_to_report( + last_observed_program: &mut Option, + current_program: Option, +) -> Option { + let current_program = + current_program.filter(|program| is_known_terminal_agent_command(program)); + let program_to_report = + if current_program.is_some() && current_program != *last_observed_program { + current_program.clone() + } else { + None + }; + *last_observed_program = current_program; + program_to_report +} /// Maximum number of idle threads kept in the agent panel's retained list. /// Set as a GPUI global to override; otherwise defaults to 5. @@ -831,6 +870,7 @@ struct AgentTerminal { title_editor_initial_title: Option, title_editor_subscription: Option, last_known_title: String, + last_observed_program: Option, working_directory: Option, created_at: DateTime, has_notification: bool, @@ -889,6 +929,34 @@ impl AgentTerminal { fn custom_title(&self, cx: &App) -> Option { self.view.read(cx).custom_title().map(SharedString::from) } + + fn report_started_terminal_program( + &mut self, + terminal_id: TerminalId, + source: AgentThreadSource, + cx: &App, + ) { + let current_program = self + .view + .read(cx) + .terminal() + .read(cx) + .foreground_process_command_name(); + + if let Some(program) = + terminal_program_to_report(&mut self.last_observed_program, current_program) + { + telemetry::event!( + "Agent Terminal Program Started", + agent = TERMINAL_AGENT_TELEMETRY_ID, + terminal_id = terminal_id.to_key_string(), + program = program, + source = source.as_str(), + side = crate::agent_sidebar_side(cx), + thread_location = "current_worktree", + ); + } + } } enum BaseView { @@ -1900,6 +1968,7 @@ impl AgentPanel { | TerminalEvent::Wakeup | TerminalEvent::BreadcrumbsChanged => { this.refresh_terminal_metadata(terminal_id, cx); + this.report_terminal_program(terminal_id, source, cx); } TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), TerminalEvent::CloseTerminal => { @@ -1920,6 +1989,7 @@ impl AgentPanel { last_known_title: initial_title .map(|title| title.to_string()) .unwrap_or_default(), + last_observed_program: None, working_directory, created_at: created_at.unwrap_or_else(Utc::now), has_notification: false, @@ -1931,9 +2001,10 @@ impl AgentPanel { self.pending_terminal_spawn = None; } terminal.refresh_metadata(cx); + terminal.report_started_terminal_program(terminal_id, source, cx); self.terminals.insert(terminal_id, terminal); self.persist_terminal_metadata(terminal_id, cx); - self.emit_terminal_thread_started(source, cx); + self.emit_terminal_thread_started(terminal_id, source, cx); if select { self.set_base_view(BaseView::Terminal { terminal_id }, focus, window, cx); } @@ -2028,10 +2099,16 @@ impl AgentPanel { self.close_terminal_internal(terminal_id, false, metadata, window, cx); } - fn emit_terminal_thread_started(&self, source: AgentThreadSource, cx: &App) { + fn emit_terminal_thread_started( + &self, + terminal_id: TerminalId, + source: AgentThreadSource, + cx: &App, + ) { telemetry::event!( "Agent Thread Started", agent = TERMINAL_AGENT_TELEMETRY_ID, + terminal_id = terminal_id.to_key_string(), source = source.as_str(), side = crate::agent_sidebar_side(cx), thread_location = "current_worktree", @@ -2048,6 +2125,17 @@ impl AgentPanel { } } + fn report_terminal_program( + &mut self, + terminal_id: TerminalId, + source: AgentThreadSource, + cx: &mut Context, + ) { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) { + terminal.report_started_terminal_program(terminal_id, source, cx); + } + } + fn persist_all_terminal_metadata(&self, cx: &mut Context) { let terminal_ids = self.terminals.keys().copied().collect::>(); for terminal_id in terminal_ids { @@ -6240,6 +6328,51 @@ mod tests { use std::sync::Arc; use std::time::Instant; + #[test] + fn test_is_known_terminal_agent_command() { + assert!(is_known_terminal_agent_command("claude")); + assert!(is_known_terminal_agent_command("codex")); + assert!(!is_known_terminal_agent_command("cargo")); + assert!(!is_known_terminal_agent_command("internal-agent")); + } + + #[test] + fn test_terminal_program_reports_known_agent_transitions() { + let mut last_observed_program = None; + + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + Some("codex".to_string()) + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + None + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("zsh".to_string())), + None + ); + assert_eq!( + terminal_program_to_report( + &mut last_observed_program, + Some("customer-data-export".to_string()) + ), + None + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + Some("codex".to_string()) + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, None), + None + ); + assert_eq!( + terminal_program_to_report(&mut last_observed_program, Some("codex".to_string())), + Some("codex".to_string()) + ); + } + #[derive(Clone, Default)] struct SessionTrackingConnection { next_session_number: Arc>, diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 96908479afe..560a24ca547 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -185,6 +185,11 @@ impl PtyProcessInfo { Some(info) } + #[cfg(all(test, unix))] + pub(crate) fn load_for_test(&self) -> Option { + self.load() + } + /// Updates the cached process info, emitting a [`Event::TitleChanged`] event if the Zed-relevant info has changed pub fn emit_title_changed_if_changed(self: &Arc, cx: &mut Context<'_, Terminal>) { if self.task.lock().is_some() { diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 00aa2cbb01b..271e2df0e9a 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -60,7 +60,7 @@ use std::{ cmp::{self, min}, fmt::Display, ops::{Deref, RangeInclusive}, - path::PathBuf, + path::{Path, PathBuf}, process::ExitStatus, sync::Arc, time::{Duration, Instant}, @@ -2137,6 +2137,18 @@ impl Terminal { } } + /// Normalizes the command name of the foreground process, if one is known. + pub fn foreground_process_command_name(&self) -> Option { + match &self.terminal_type { + TerminalType::Pty { info, .. } => info + .current + .read() + .as_ref() + .and_then(|process| foreground_process_command_from_argv(&process.argv)), + TerminalType::DisplayOnly => None, + } + } + /// Returns the working directory of the process that's connected to the PTY. /// That means it returns the working directory of the local shell or program /// that's running inside the terminal. @@ -2461,6 +2473,77 @@ impl Drop for Terminal { impl EventEmitter for Terminal {} +fn normalize_path_command_name(command: &str) -> Option { + const MAX_COMMAND_NAME_LENGTH: usize = 64; + + let command = command.trim(); + if command.is_empty() + || command.len() > MAX_COMMAND_NAME_LENGTH + || command.starts_with('.') + || command.starts_with('-') + || command.contains('/') + || command.contains('\\') + { + return None; + } + + let mut command = command.to_ascii_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1"] { + if command.ends_with(suffix) { + command.truncate(command.len() - suffix.len()); + break; + } + } + + if command.is_empty() + || !command.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + return None; + } + + Some(command) +} + +fn foreground_process_command_from_argv(argv: &[String]) -> Option { + let command = argv + .first() + .and_then(|command| normalize_path_command_name(command)); + + if !matches!( + command.as_deref(), + Some("node" | "python" | "python3" | "bun" | "deno") + ) { + return command; + } + + argv.iter() + .skip(1) + .filter_map(|argument| normalize_script_command_name(argument)) + .next() + .or(command) +} + +fn normalize_script_command_name(argument: &str) -> Option { + let path = Path::new(argument); + let file_stem = path + .file_stem() + .and_then(|file_stem| file_stem.to_str()) + .and_then(normalize_path_command_name)?; + + if file_stem != "index" { + return Some(file_stem); + } + + path.parent() + .and_then(|parent| parent.parent()) + .and_then(|package_path| package_path.file_name()) + .and_then(|package_name| package_name.to_str()) + .and_then(|package_name| package_name.strip_suffix("-cli").or(Some(package_name))) + .and_then(normalize_path_command_name) +} + fn make_selection(range: &RangeInclusive) -> Selection { let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left); selection.update(*range.end(), AlacDirection::Right); @@ -2597,6 +2680,54 @@ mod tests { use rand::{Rng, distr, rngs::StdRng}; use task::{Shell, ShellBuilder}; + #[test] + fn test_normalize_path_command_name() { + assert_eq!(normalize_path_command_name("claude"), Some("claude".into())); + assert_eq!(normalize_path_command_name("Cargo"), Some("cargo".into())); + assert_eq!(normalize_path_command_name("node.exe"), Some("node".into())); + assert_eq!( + normalize_path_command_name("my-agent_cli.1"), + Some("my-agent_cli.1".into()) + ); + assert_eq!(normalize_path_command_name("./local-agent"), None); + assert_eq!(normalize_path_command_name("../local-agent"), None); + assert_eq!(normalize_path_command_name("/usr/local/bin/cargo"), None); + assert_eq!( + normalize_path_command_name("target\\debug\\agent.exe"), + None + ); + assert_eq!(normalize_path_command_name(".hidden-agent"), None); + assert_eq!(normalize_path_command_name("agent with spaces"), None); + assert_eq!(normalize_path_command_name("zsh"), Some("zsh".into())); + assert_eq!(normalize_path_command_name("-zsh"), None); + assert_eq!(normalize_path_command_name("pwsh.exe"), Some("pwsh".into())); + } + + #[test] + fn test_foreground_process_command_from_interpreter_wrapper() { + assert_eq!( + foreground_process_command_from_argv(&[ + "node".to_string(), + "/opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js".to_string(), + ]), + Some("gemini".to_string()) + ); + assert_eq!( + foreground_process_command_from_argv(&[ + "python3".to_string(), + "/Users/me/.local/bin/codex.py".to_string(), + ]), + Some("codex".to_string()) + ); + assert_eq!( + foreground_process_command_from_argv(&[ + "node".to_string(), + "/Users/me/private-project/scripts/customer-data-export.js".to_string(), + ]), + Some("customer-data-export".to_string()) + ); + } + #[cfg(not(target_os = "windows"))] fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -2613,10 +2744,18 @@ mod tests { command: &str, args: &[&str], ) -> (Entity, Receiver>) { - let (completion_tx, completion_rx) = async_channel::unbounded(); let args: Vec = args.iter().map(|s| s.to_string()).collect(); let (program, args) = ShellBuilder::new(&Shell::System, false).build(Some(command.to_owned()), &args); + build_test_terminal_with_arguments(cx, program, args).await + } + + async fn build_test_terminal_with_arguments( + cx: &mut TestAppContext, + program: String, + args: Vec, + ) -> (Entity, Receiver>) { + let (completion_tx, completion_rx) = async_channel::unbounded(); let builder = cx .update(|cx| { TerminalBuilder::new( @@ -2755,6 +2894,23 @@ mod tests { ); } + #[cfg(unix)] + #[gpui::test] + async fn test_foreground_process_command_tracks_path_command(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + + let (terminal, completion_rx) = + build_test_terminal_with_arguments(cx, "sleep".to_string(), vec!["1".to_string()]) + .await; + + assert_foreground_process_command_eventually(&terminal, "sleep", cx).await; + + assert!( + completion_rx.recv().await.is_ok(), + "expected terminal completion after sleep exits" + ); + } + // TODO should be tested on Linux too, but does not work there well #[cfg(target_os = "macos")] #[gpui::test(iterations = 10)] @@ -3359,6 +3515,42 @@ mod tests { panic!("Expected terminal content to contain {expected:?}, got: {content}"); } + #[cfg(unix)] + async fn assert_foreground_process_command_eventually( + terminal: &Entity, + expected: &str, + cx: &mut TestAppContext, + ) { + let mut command_name = None; + for _ in 0..100 { + terminal.update(cx, |terminal, _| { + if let TerminalType::Pty { info, .. } = &terminal.terminal_type { + info.load_for_test(); + } + }); + command_name = + terminal.update(cx, |terminal, _| terminal.foreground_process_command_name()); + if command_name.as_deref() == Some(expected) { + return; + } + cx.background_executor + .timer(Duration::from_millis(10)) + .await; + } + let process_info = terminal.update(cx, |terminal, _| match &terminal.terminal_type { + TerminalType::Pty { info, .. } => format!( + "pid={:?}, fallback_pid={:?}, has_current_info={}", + info.pid(), + info.pid_getter().fallback_pid(), + info.current.read().is_some() + ), + TerminalType::DisplayOnly => "display-only".to_string(), + }); + panic!( + "Expected foreground process command name to be {expected:?}, got {command_name:?}; process info: {process_info:?}" + ); + } + /// Test that kill_active_task properly terminates both the foreground process /// and the shell, allowing wait_for_completed_task to complete and output to be captured. #[cfg(unix)]