From bfd97428b36f2aec22513d8c2cd938ebbf9eccbf Mon Sep 17 00:00:00 2001 From: Tung Nguyen Date: Sun, 13 Sep 2026 09:25:46 +0100 Subject: [PATCH] feat: distinguish Claude Desktop App / IDE / CLI sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code can be launched from three different surfaces that all show up as plain `claude` processes today: a terminal CLI install, the Claude desktop app's bundled Claude Code binary, and an editor extension (VS Code, Cursor, Windsurf, ...). abtop previously labeled all of them identically as "claude" with no way to tell which is which. Add a `LaunchSurface` (Cli/App/Ide) classified from each session's resolved process command path: - `.../Claude/claude-code/...` (Electron userData layout) -> App - `.../extensions/anthropic.claude-code-.../...` -> Ide - everything else (homebrew/npm/native installs, the auto-updater's `claude/versions/` layout) -> Cli (default, unchanged behavior) The sessions table's existing 3-char agent label ("*CC") now gets a trailing suffix letter for the less common surfaces: "*CCa" for the desktop app, "*CCi" for an IDE extension. Plain CLI sessions are unaffected. Also threaded through to the JSON snapshot (`SessionView`) for headless consumers. Codex and OpenCode sessions always report `Cli` — no desktop-app or editor-extension equivalent is known for those tools. Add a "Legend" entry to the in-app keybinding help (`?`) explaining the a/i suffix, in English and Chinese, since the suffix alone isn't self-explanatory. --- src/app.rs | 2 + src/collector/claude.rs | 92 ++++++++++++++++++++++++++++++++++++++- src/collector/codex.rs | 5 ++- src/collector/opencode.rs | 3 +- src/demo.rs | 9 +++- src/locale.rs | 10 +++++ src/model/session.rs | 31 +++++++++++++ src/snapshot.rs | 7 ++- src/ui/help.rs | 2 + src/ui/sessions.rs | 61 ++++++++++++++++++++++++-- 10 files changed, 210 insertions(+), 12 deletions(-) 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}"),