diff --git a/src/app.rs b/src/app.rs index c9de3cc..e47408b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1012,10 +1012,12 @@ fn is_killable_agent_command(cmd: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::model::LaunchSurface; fn waiting_session(cli: &'static str) -> AgentSession { AgentSession { agent_cli: cli, + launch_surface: LaunchSurface::Cli, pid: 1, session_id: String::new(), cwd: String::new(), diff --git a/src/collector/claude.rs b/src/collector/claude.rs index 8f08db5..c4d8474 100644 --- a/src/collector/claude.rs +++ b/src/collector/claude.rs @@ -1,7 +1,7 @@ use super::process::{self, ProcInfo}; use crate::model::{ - AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, SessionFile, - SessionStatus, SubAgent, MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, + AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, LaunchSurface, + SessionFile, SessionStatus, SubAgent, MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, }; use serde_json::Value; use std::collections::HashMap; @@ -264,6 +264,36 @@ impl ClaudeCollector { pids } + /// Classify which surface launched a `claude` process, from the resolved + /// executable path in its full command line (as reported by `ps`/ + /// `/proc/{pid}/cmdline`/sysinfo — see `process::ProcInfo::command`). + /// + /// - The Claude desktop app bundles its own Claude Code binary under a + /// per-user `Claude/claude-code//` directory (Electron's + /// userData layout: `~/Library/Application Support/Claude/...` on + /// macOS, `%APPDATA%\Claude\...` on Windows, `~/.config/Claude/...` + /// on Linux) — distinct from a plain PATH install. + /// - Editor extensions (VS Code, Cursor, Windsurf, ...) vendor the + /// binary under `/anthropic.claude-code-/`. + /// - Anything else (homebrew/npm/native install, the auto-updater's + /// `claude/versions/` layout) is a plain CLI invocation. + fn detect_launch_surface(cmd: &str) -> LaunchSurface { + // Match against the whole command string rather than the first + // whitespace-split token: unlike `cmd_has_binary`'s binary-name + // check, these are fixed path fragments with no ambiguity, and the + // desktop app's own path already contains an unquoted space + // ("Application Support") on macOS that a naive first-token split + // would cut through. + let normalized = cmd.replace('\\', "/"); + if normalized.contains("/Claude/claude-code/") { + LaunchSurface::App + } else if normalized.contains("/extensions/anthropic.claude-code") { + LaunchSurface::Ide + } else { + LaunchSurface::Cli + } + } + fn map_pid_to_open_paths(pids: &[u32]) -> HashMap { if pids.is_empty() { return HashMap::new(); @@ -343,6 +373,9 @@ impl ClaudeCollector { let pid_alive = proc_cmd .map(|c| process::cmd_has_binary(c, "claude")) .unwrap_or(false); + let launch_surface = proc_cmd + .map(Self::detect_launch_surface) + .unwrap_or(LaunchSurface::Cli); // Skip sessions whose PID is a descendant of abtop itself — // those are the `claude --print` summary children spawned by @@ -642,6 +675,7 @@ impl ClaudeCollector { Some(AgentSession { agent_cli: "claude", + launch_surface, pid: sf.pid, session_id: sf.session_id, cwd: sf.cwd, @@ -2026,6 +2060,60 @@ mod tests { use super::*; use std::io::Write; + // ---- detect_launch_surface ---- + + #[test] + fn detect_launch_surface_desktop_app_macos() { + let cmd = "/Users/a/Library/Application Support/Claude/claude-code/2.1.266/claude.app/Contents/MacOS/claude"; + assert_eq!( + ClaudeCollector::detect_launch_surface(cmd), + LaunchSurface::App + ); + } + + #[test] + fn detect_launch_surface_desktop_app_windows_backslashes() { + let cmd = r#"C:\Users\a\AppData\Roaming\Claude\claude-code\2.1.266\claude.exe"#; + assert_eq!( + ClaudeCollector::detect_launch_surface(cmd), + LaunchSurface::App + ); + } + + #[test] + fn detect_launch_surface_vscode_extension() { + let cmd = "/Users/a/.vscode/extensions/anthropic.claude-code-2.1.269-darwin-arm64/resources/native-binary/claude"; + assert_eq!( + ClaudeCollector::detect_launch_surface(cmd), + LaunchSurface::Ide + ); + } + + #[test] + fn detect_launch_surface_plain_cli_install() { + assert_eq!( + ClaudeCollector::detect_launch_surface("/usr/local/bin/claude"), + LaunchSurface::Cli + ); + assert_eq!( + ClaudeCollector::detect_launch_surface("claude --session-id abc"), + LaunchSurface::Cli + ); + } + + #[test] + fn detect_launch_surface_autoupdater_versions_layout_is_cli() { + // The auto-updater's `/versions/` layout (see + // `cmd_has_binary_autoupdater_layout` in process.rs) is a plain CLI + // install, not the desktop app — it must not match on "claude" alone. + assert_eq!( + ClaudeCollector::detect_launch_surface( + "/Users/a/.local/share/claude/versions/2.1.121 --allow-dangerously-skip-permissions", + ), + LaunchSurface::Cli + ); + } + fn write_lines(file: &mut tempfile::NamedTempFile, lines: &[&str]) { for line in lines { writeln!(file, "{}", line).unwrap(); diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 43a4bfc..91e675d 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1,7 +1,7 @@ use super::process::{self, ProcInfo}; use crate::model::{ - AgentSession, ChatMessage, ChatRole, ChildProcess, RateLimitInfo, SessionStatus, ToolCall, - MAX_CHAT_MESSAGES, + AgentSession, ChatMessage, ChatRole, ChildProcess, LaunchSurface, RateLimitInfo, SessionStatus, + ToolCall, MAX_CHAT_MESSAGES, }; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -630,6 +630,7 @@ impl CodexCollector { Some(( AgentSession { agent_cli: "codex", + launch_surface: LaunchSurface::Cli, pid: display_pid, session_id: result.session_id, cwd: result.cwd, diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 608e3b6..7acb911 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -1,5 +1,5 @@ use super::{process, context_window_for_model}; -use crate::model::{AgentSession, ChildProcess, SessionStatus}; +use crate::model::{AgentSession, ChildProcess, LaunchSurface, SessionStatus}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; @@ -195,6 +195,7 @@ impl OpenCodeCollector { sessions.push(AgentSession { agent_cli: "opencode", + launch_surface: LaunchSurface::Cli, pid: matched_pid, session_id: ds.id.clone(), cwd: ds.directory.clone(), diff --git a/src/demo.rs b/src/demo.rs index 3e28358..b8d3271 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -1,7 +1,7 @@ use crate::app::App; use crate::model::{ - AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, OrphanPort, - RateLimitInfo, SessionStatus, SubAgent, ToolCall, + AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, LaunchSurface, + OrphanPort, RateLimitInfo, SessionStatus, SubAgent, ToolCall, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -23,6 +23,7 @@ pub fn populate_demo(app: &mut App) { app.sessions = vec![ AgentSession { agent_cli: "claude", + launch_surface: LaunchSurface::Cli, pid: 7336, session_id: "a1b2c3d4-5678-9abc-def0-111111111111".into(), cwd: "/Users/demo/webshop".into(), @@ -254,6 +255,7 @@ pub fn populate_demo(app: &mut App) { }, AgentSession { agent_cli: "claude", + launch_surface: LaunchSurface::App, pid: 8840, session_id: "b2c3d4e5-6789-abcd-ef01-222222222222".into(), cwd: "/Users/demo/ml-pipeline".into(), @@ -311,6 +313,7 @@ pub fn populate_demo(app: &mut App) { }, AgentSession { agent_cli: "claude", + launch_surface: LaunchSurface::Ide, pid: 9102, session_id: "c3d4e5f6-789a-bcde-f012-333333333333".into(), cwd: "/Users/demo/api-server".into(), @@ -415,6 +418,7 @@ pub fn populate_demo(app: &mut App) { }, AgentSession { agent_cli: "codex", + launch_surface: LaunchSurface::Cli, pid: 8901, session_id: "d4e5f6a7-89ab-cdef-0123-444444444444".into(), cwd: "/Users/demo/data-viz".into(), @@ -469,6 +473,7 @@ pub fn populate_demo(app: &mut App) { }, AgentSession { agent_cli: "opencode", + launch_surface: LaunchSurface::Cli, pid: 9500, session_id: "ses_e5f6a7b8-9abc-def0-1234-555555555555".into(), cwd: "/Users/demo/infra".into(), diff --git a/src/locale.rs b/src/locale.rs index d8f29fb..9f52a80 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -134,6 +134,11 @@ static LOCALE_EN: LazyLock> = LazyLock::ne "toggle mcp-server suppression in sessions panel", ); m.insert("help.this_help", "this help"); + m.insert("help.legend", "Legend"); + m.insert( + "help.launch_surface_suffix", + "Claude: a = Desktop App, i = IDE extension (blank = CLI)", + ); // Footer m.insert("footer.select", "select"); @@ -377,6 +382,11 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne ); m.insert("help.mcp_suppress", "切换会话面板中的 MCP 服务器隐藏"); m.insert("help.this_help", "显示帮助"); + m.insert("help.legend", "图例"); + m.insert( + "help.launch_surface_suffix", + "Claude: a = 桌面应用, i = IDE 扩展(无后缀 = 命令行)", + ); // Footer m.insert("footer.select", "选择"); diff --git a/src/model/session.rs b/src/model/session.rs index cc3a5bf..72c0ea6 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -130,12 +130,42 @@ pub struct ChatMessage { /// Maximum chat messages kept per session to bound memory and UI noise. pub const MAX_CHAT_MESSAGES: usize = 12; +/// Which surface launched this session's process, detected from the resolved +/// executable path in its command line. Always `Cli` for Codex and OpenCode +/// sessions — no desktop-app or editor-extension equivalent is known for +/// those tools yet. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub enum LaunchSurface { + /// Plain CLI invocation: a terminal shell running an npm/homebrew/native + /// install (or the auto-updater's `versions/` layout). + Cli, + /// The Claude desktop app's bundled `claude-code` binary. + App, + /// An editor extension (VS Code, Cursor, Windsurf, ...). + Ide, +} + +impl LaunchSurface { + /// Single-char suffix appended to the agent-label column in the sessions + /// table. Empty for the default `Cli` case so existing rows/tests are + /// unaffected; `a`/`i` flag the less common surfaces. + pub fn label_suffix(&self) -> &'static str { + match self { + LaunchSurface::Cli => "", + LaunchSurface::App => "a", + LaunchSurface::Ide => "i", + } + } +} + #[derive(Debug, Clone)] pub struct AgentSession { /// Which CLI tool this session belongs to: "claude", "codex", etc. /// Also used as the identifier for the `hidden_agents` config key /// (case-insensitive match). pub agent_cli: &'static str, + /// Which surface launched this session's process (CLI / desktop app / IDE extension). + pub launch_surface: LaunchSurface, pub pid: u32, pub session_id: String, pub cwd: String, @@ -266,6 +296,7 @@ mod tests { fn make_session(input: u64, output: u64, cache_read: u64, cache_create: u64) -> AgentSession { AgentSession { agent_cli: "claude", + launch_surface: LaunchSurface::Cli, pid: 0, session_id: String::new(), cwd: String::new(), diff --git a/src/snapshot.rs b/src/snapshot.rs index d89c257..4a631b6 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -14,7 +14,8 @@ use crate::app::App; use crate::collector::mcp::ACTIVE_MTIME_SECS; use crate::host_info::{AgentAggregate, HostMetrics}; use crate::model::{ - ChatRole, ChildProcess, OrphanPort, RateLimitInfo, SessionStatus, MAX_CHAT_MESSAGES, + ChatRole, ChildProcess, LaunchSurface, OrphanPort, RateLimitInfo, SessionStatus, + MAX_CHAT_MESSAGES, }; use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; @@ -86,6 +87,9 @@ pub struct SubAgentView { pub struct SessionView { /// Owning CLI: "claude", "codex", "opencode". pub agent_cli: &'static str, + /// Which surface launched this session (CLI / Claude desktop app / IDE + /// extension); always `Cli` for Codex and OpenCode. + pub launch_surface: LaunchSurface, /// OS process id of the agent CLI for this session. pub pid: u32, /// Agent-assigned session identifier (stable for the life of the session). @@ -202,6 +206,7 @@ impl App { .iter() .map(|s| SessionView { agent_cli: s.agent_cli, + launch_surface: s.launch_surface, pid: s.pid, session_id: s.session_id.clone(), project_name: s.project_name.clone(), diff --git a/src/ui/help.rs b/src/ui/help.rs index e4e5b12..6c97cb5 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -29,6 +29,8 @@ fn get_entries() -> Vec<(String, String)> { (" M".to_string(), t("help.mcp_suppress")), (t("help.help"), String::new()), (" ?".to_string(), t("help.this_help")), + (t("help.legend"), String::new()), + (" a / i".to_string(), t("help.launch_surface_suffix")), ] } diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index 1a911eb..ce25f39 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -133,7 +133,7 @@ pub(crate) fn draw_sessions_panel_active( let selected = i == app.selected; let marker = if selected { "►" } else { " " }; - let (agent_label, agent_color) = match session.agent_cli { + let (agent_base, agent_color) = match session.agent_cli { "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta "codex" => (">CD", Color::Rgb(122, 157, 255)), // #7A9DFF periwinkle "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // #4ADE80 emerald @@ -145,6 +145,9 @@ pub(crate) fn draw_sessions_panel_active( ) } }; + // App/IDE-launched sessions get a trailing suffix letter ("a"/"i"); + // plain CLI sessions (the common case) keep the label unchanged. + let agent_label = format!("{agent_base}{}", session.launch_surface.label_suffix()); let (status_icon_str, status_color) = match &session.status { crate::model::SessionStatus::Thinking => (t("sess.think"), theme.proc_misc), @@ -287,7 +290,7 @@ pub(crate) fn draw_sessions_panel_active( if app.tree_view && !session.subagents.is_empty() { for (sa_idx, sa) in session.subagents.iter().enumerate() { let is_last = sa_idx == session.subagents.len() - 1; - // Tree connector fits the 3-wide agent column (was truncated before). + // Tree connector fits the agent column (was truncated before). let prefix = if is_last { "└─" } else { "├─" }; let is_working = sa.status.eq_ignore_ascii_case("working") || sa.status.eq_ignore_ascii_case("in_progress"); @@ -378,7 +381,7 @@ pub(crate) fn draw_sessions_panel_active( let mut widths_vec: Vec = vec![ Constraint::Length(1), // marker - Constraint::Length(3), // agent label + Constraint::Length(4), // agent label (+1 for the App/IDE suffix letter) ]; if show_pid { widths_vec.push(Constraint::Length(6)); // pid @@ -1251,7 +1254,7 @@ fn draw_timeline( mod tests { use super::*; use crate::config::PanelVisibility; - use crate::model::SessionStatus; + use crate::model::{LaunchSurface, SessionStatus}; use ratatui::backend::TestBackend; use ratatui::Terminal; @@ -1276,6 +1279,7 @@ mod tests { let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); app.sessions.push(AgentSession { agent_cli: "codex", + launch_surface: LaunchSurface::Cli, pid: 42, session_id: "codex-session".into(), cwd: "/tmp/project".into(), @@ -1343,6 +1347,54 @@ mod tests { ); } + #[test] + fn agent_label_flags_app_and_ide_launch_surfaces() { + let render = |surface: LaunchSurface| { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + let mut session = test_session("sid", "proj"); + session.launch_surface = surface; + app.sessions.push(session); + + let backend = TestBackend::new(120, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw_sessions_panel( + f, + &app, + Rect { + x: 0, + y: 0, + width: 120, + height: 20, + }, + &app.theme, + ) + }) + .unwrap(); + format!("{}", terminal.backend()) + }; + + let cli_text = render(LaunchSurface::Cli); + assert!( + cli_text.contains("*CC "), + "plain CLI session should render the unsuffixed label\n{cli_text}" + ); + assert!(!cli_text.contains("*CCa") && !cli_text.contains("*CCi")); + + let app_text = render(LaunchSurface::App); + assert!( + app_text.contains("*CCa"), + "Claude desktop app session should get the 'a' suffix\n{app_text}" + ); + + let ide_text = render(LaunchSurface::Ide); + assert!( + ide_text.contains("*CCi"), + "IDE-extension session should get the 'i' suffix\n{ide_text}" + ); + } + #[test] fn task_row_text_respects_terminal_display_width() { assert_eq!(task_row_text("ABCD", 6), "└─ A…"); @@ -1428,6 +1480,7 @@ mod tests { fn test_session(session_id: &str, project_name: &str) -> AgentSession { AgentSession { agent_cli: "claude", + launch_surface: LaunchSurface::Cli, pid: 42, session_id: session_id.into(), cwd: format!("/tmp/{project_name}"),