mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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 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:
parent
60374460f2
commit
58f84cf041
3 changed files with 334 additions and 4 deletions
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue