agent_ui: Basic terminal agent telemetry (#57259)
Some checks are pending
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Has been skipped
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions

Some best-effort tracking of an allowed-list of agents (to avoid
grabbing sensitive data) just to get basic data on general usage
patterns.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: morgankrey <morgan@zed.dev>
This commit is contained in:
Ben Brandt 2026-05-28 06:49:12 +02:00 committed by GitHub
parent 60374460f2
commit 58f84cf041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 334 additions and 4 deletions

View file

@ -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<String>,
current_program: Option<String>,
) -> Option<String> {
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<String>,
title_editor_subscription: Option<Subscription>,
last_known_title: String,
last_observed_program: Option<String>,
working_directory: Option<PathBuf>,
created_at: DateTime<Utc>,
has_notification: bool,
@ -889,6 +929,34 @@ impl AgentTerminal {
fn custom_title(&self, cx: &App) -> Option<SharedString> {
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<Self>,
) {
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<Self>) {
let terminal_ids = self.terminals.keys().copied().collect::<Vec<_>>();
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<Mutex<usize>>,

View file

@ -185,6 +185,11 @@ impl PtyProcessInfo {
Some(info)
}
#[cfg(all(test, unix))]
pub(crate) fn load_for_test(&self) -> Option<ProcessInfo> {
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<Self>, cx: &mut Context<'_, Terminal>) {
if self.task.lock().is_some() {

View file

@ -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<String> {
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<Event> for Terminal {}
fn normalize_path_command_name(command: &str) -> Option<String> {
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<String> {
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<String> {
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<AlacPoint>) -> 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<Terminal>, Receiver<Option<ExitStatus>>) {
let (completion_tx, completion_rx) = async_channel::unbounded();
let args: Vec<String> = 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<String>,
) -> (Entity<Terminal>, Receiver<Option<ExitStatus>>) {
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<Terminal>,
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)]