Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
92 changes: 90 additions & 2 deletions src/collector/claude.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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/<version>/` 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 `<editor-extensions-dir>/anthropic.claude-code-<ver>/`.
/// - Anything else (homebrew/npm/native install, the auto-updater's
/// `claude/versions/<ver>` 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<u32, ProcessOpenPaths> {
if pids.is_empty() {
return HashMap::new();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -642,6 +675,7 @@ impl ClaudeCollector {

Some(AgentSession {
agent_cli: "claude",
launch_surface,
pid: sf.pid,
session_id: sf.session_id,
cwd: sf.cwd,
Expand Down Expand Up @@ -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 `<name>/versions/<ver>` 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();
Expand Down
5 changes: 3 additions & 2 deletions src/collector/codex.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/collector/opencode.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
9 changes: 7 additions & 2 deletions src/demo.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ static LOCALE_EN: LazyLock<std::collections::HashMap<&str, &str>> = 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");
Expand Down Expand Up @@ -377,6 +382,11 @@ static LOCALE_ZH: LazyLock<std::collections::HashMap<&str, &str>> = 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", "选择");
Expand Down
31 changes: 31 additions & 0 deletions src/model/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ver>` 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,
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 6 additions & 1 deletion src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions src/ui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
}

Expand Down
Loading
Loading