From c44d03c6a7989f8b177de2474ba6f377284d64c0 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 13:36:47 +0200 Subject: [PATCH 01/10] =?UTF-8?q?release:=20v0.3.0=20=E2=80=94=20live=20sc?= =?UTF-8?q?raper,=20multi-agent=20OTLP,=20project=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent + analytics (Layer 1): - New providers: Cline, GitHub Copilot Chat, opencode (now 7 total) - Provider trait gains service_name() for OTel service.name detection - Project resolution via ~/.claude/projects/*/sessions-index.json (src/project.rs) - OTLP parser: compaction events, JSON-string success interpretation, MCP tool-name reconstruction - Storage: log_events raw table, CompactionStats, distinct sessions/service-names, query-time filtering - TUI: project filter (r), agent cycle (a), time filter (t), compaction summary, MCP per-server breakdown Live scraper (Layer 2, abtop-inspired): - New src/scraper/ module (cross-platform via sysinfo) - Live Claude Code session list with status (Thinking/Executing/Waiting/RateLimited) - Current task extraction from last tool_use - Context-window % via model_info lookup table - Per-session + per-child-process RSS - Listening ports + orphan-port detection - Claude subagents (name/status/tokens) - StatusLine sidecar reader for rate-limit ingestion - Host vitals strip (CPU/MEM/load) in header - Token-rate braille sparkline (last 5 min, bucketed) - Quota panel: 5h + 7d gauges with reset countdown - StatusLine hook installer in src/config — writes ~/.claude/agenttop-statusline.sh and patches settings.json (idempotent, backs up .bak) - `agenttop --setup claude` (and --setup all) now installs the StatusLine hook Scraper is Claude-Code-only for v0.3.0; other agents still flow through OTLP. cargo build + cargo clippy --all-targets clean; 167 tests passing (added ~25). --- Cargo.lock | 133 +++++++- Cargo.toml | 8 +- README.md | 171 ++++++++--- src/config/mod.rs | 194 +++++++++++- src/lib.rs | 3 + src/main.rs | 47 ++- src/model_info.rs | 104 +++++++ src/otlp/parser.rs | 149 ++++++++- src/project.rs | 244 +++++++++++++++ src/providers/claude_code.rs | 10 +- src/providers/cline.rs | 70 +++++ src/providers/copilot_chat.rs | 137 +++++++++ src/providers/mod.rs | 53 ++++ src/providers/opencode.rs | 50 +++ src/scraper/claude_sessions.rs | 492 +++++++++++++++++++++++++++++ src/scraper/host.rs | 71 +++++ src/scraper/mod.rs | 289 +++++++++++++++++ src/scraper/ports.rs | 106 +++++++ src/scraper/process.rs | 148 +++++++++ src/scraper/rate_limits.rs | 115 +++++++ src/scraper/subagents.rs | 143 +++++++++ src/storage/mod.rs | 392 ++++++++++++++++++++++++ src/tui/app.rs | 204 +++++++++++- src/tui/mod.rs | 2 +- src/tui/ui.rs | 545 +++++++++++++++++++++++++++------ tests/project_test.rs | 134 ++++++++ 26 files changed, 3864 insertions(+), 150 deletions(-) create mode 100644 src/model_info.rs create mode 100644 src/project.rs create mode 100644 src/providers/cline.rs create mode 100644 src/providers/copilot_chat.rs create mode 100644 src/providers/opencode.rs create mode 100644 src/scraper/claude_sessions.rs create mode 100644 src/scraper/host.rs create mode 100644 src/scraper/mod.rs create mode 100644 src/scraper/ports.rs create mode 100644 src/scraper/process.rs create mode 100644 src/scraper/rate_limits.rs create mode 100644 src/scraper/subagents.rs create mode 100644 tests/project_test.rs diff --git a/Cargo.lock b/Cargo.lock index 5faf80a..b5671ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agenttop" -version = "0.1.0" +version = "0.3.0" dependencies = [ "anyhow", "axum", @@ -28,6 +28,8 @@ dependencies = [ "regex", "serde", "serde_json", + "sysinfo", + "tempfile", "tokio", "tower-http", "tracing", @@ -642,6 +644,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.28.1" @@ -1187,7 +1214,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1621,6 +1648,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2126,6 +2162,26 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2638,6 +2694,20 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + [[package]] name = "tap" version = "1.0.1" @@ -3210,19 +3280,52 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3234,6 +3337,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3251,6 +3365,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index e4ec7b3..31a2e12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agenttop" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "htop for AI coding agents - terminal observability dashboard" authors = ["tech4242"] @@ -45,6 +45,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } once_cell = "1" regex = "1" +# Process / host vitals scraping (cross-platform) +sysinfo = "0.33" + # Build dependencies for protobuf [build-dependencies] prost-build = "0.13" @@ -57,3 +60,6 @@ strip = true inherits = "release" lto = true codegen-units = 1 + +[dev-dependencies] +tempfile = "3.24.0" diff --git a/README.md b/README.md index ac0d3bb..a494022 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,19 @@ A terminal-native observability dashboard for AI coding agents. Real-time visibility into tool usage, token consumption, and productivity metrics. ``` -┌─ agenttop ───────────────────────────── Agent: Claude Code Active: 1h 47m ─┐ -│ Tokens In: 89K Out: 42K Cache: 25K (94% reuse) Session Total: 156K │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ API: 47 calls │ 1.2s avg │ 2 errors │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ TOOL CALLS ERR APR% AVG RANGE LAST FREQ │ -│ ▶ Read 89 0 100% 12ms 5ms-45ms 5s ██████████░░ │ -│ Bash 47 1 98% 234ms 50ms-2.1s 2s █████░░░░░░░ │ -│ Edit 34 2 94% 45ms 10ms-200ms 10s ████░░░░░░░░ │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ MCP Tools │ -│ context7:* 15 1 93% 345ms 100ms-800ms 20s ██████████░░ │ -└───── [q]uit [s]ort [p]ause [d]etail [t]ime [r]eset [a]gent ─────────────────┘ +┌─ agenttop ────────────── Project: myapp Agent: Claude Code Active: 1h 47m ─┐ +│ Tokens In: 89K Out: 42K Cache: 25K (94% reuse) Session Total: 156K │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ API: 47 calls │ 1.2s avg │ 2 errors │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ TOOL CALLS ERR APR% AVG RANGE LAST FREQ │ +│ ▶ Read 89 0 100% 12ms 5ms-45ms 5s ██████████░░ │ +│ Bash 47 1 98% 234ms 50ms-2.1s 2s █████░░░░░░░ │ +│ Edit 34 2 94% 45ms 10ms-200ms 10s ████░░░░░░░░ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ MCP Tools │ +│ context7:* 15 1 93% 345ms 100ms-800ms 20s ██████████░░ │ +└───── [q]uit [s]ort [p]ause [d]etail [t]ime [r] project [a]gent ──────────────┘ ``` ## Origin Story @@ -58,28 +58,41 @@ If you want to contribute, please let me know! | Agent | OTLP Support | Signals | MCP Tools | Key Metrics | |-------|--------------|---------|-----------|-------------| -| **Claude Code** | ✅ Full | Metrics, Logs | Anonymized (`mcp_tool`) | tokens, cost, tools, LOC | -| **OpenAI Codex CLI** | ✅ Partial | Logs, Traces | Full names | tokens, tools, prompts | +| **Claude Code** | ✅ Full | Metrics, Logs | Full names (auto-enabled via `OTEL_LOG_TOOL_DETAILS=1`) | tokens, cost, tools, LOC, compaction | +| **OpenAI Codex CLI** | ⚠️ Partial | Logs, Traces | Full names | tokens, tools, prompts (interactive only — see caveat) | | **Gemini CLI** | ✅ Full | Metrics, Logs | Full names + `tool_type` | 40+ metrics | -| **Qwen Code** | ✅ Full | Metrics, Logs | N/A | tokens, diff stats | -| **Cline** | ⚠️ Via provider | Logs, Metrics | N/A | events, errors | -| **Mistral Vibe** | ❌ None | - | - | - | +| **Qwen Code** | ✅ Full | Metrics, Logs | Supported | tokens, diff stats | +| **Cline** | ✅ Full (Cline Enterprise) | Logs, Metrics | via `use_mcp_tool` | `cline.turns.total`, tool calls | +| **GitHub Copilot Chat** | ✅ Full (since Feb 2026) | Metrics, Logs, Traces | Unconfirmed | OTel GenAI conventions (tokens, latency, model) | +| **opencode** | ⚠️ Plugin (`DEVtheOPS/opencode-plugin-otel`) | Logs, Metrics | mirrors Claude Code | tokens, tools | +| **Mistral Vibe** | ⚠️ Opt-out telemetry, OTLP path undocumented | — | — | — | | **Cursor** | ❌ Proprietary | Admin API only | N/A | aggregate stats | -| **GitHub Copilot** | ❌ Proprietary | REST API only | N/A | usage rates | -| **Aider** | ❌ None | - | - | - | +| **GitHub Copilot CLI** | ❌ Proprietary | REST API only | N/A | usage rates | +| **Aider** | ❌ None | — | — | — | ### Some notes on Limitations #### MCP Tool Names (Claude Code) -Claude Code anonymizes MCP tool names in telemetry for privacy (v2.1.2+). -All MCP tools appear as `mcp_tool`. Other agents (Codex, Gemini) expose full names. +Claude Code 2.1.2+ anonymizes MCP tool names by default. The opt-in env var +`OTEL_LOG_TOOL_DETAILS=1` makes the per-server names available again — Claude Code +emits them in `tool_parameters` as `{"mcp_server_name": "...", "mcp_tool_name": "..."}`, +and agenttop reconstitutes the full `mcp____` form so MCP usage +shows up grouped by server in the TUI. `agenttop --setup claude` writes this +env var for you. -There is an open issue already at: https://github.com/anthropics/claude-code/issues/17046 +History: this was tracked as https://github.com/anthropics/claude-code/issues/17046 +(closed Jan 2026). #### Context Window Usage -Claude Code does NOT expose context window usage or compaction status in telemetry. -The ~200K context window and ~75% compaction threshold are internal only. -agenttop shows cumulative session tokens, not context window remaining. +Claude Code still does NOT expose live context window usage in telemetry. +However, **compaction events** (`event.name = "claude_code.compaction"`) are now +emitted with `pre_tokens`/`post_tokens`, and agenttop surfaces a count + last +delta in the header so you can see when compaction kicked in. + +#### OpenAI Codex CLI caveat +As of 2026-Q1, `codex exec` and `codex mcp-server` emit no telemetry — only +interactive `codex` sessions populate OTLP. See +https://github.com/openai/codex/issues/12913. #### Approval Rate The `decision` attribute for tool approval tracking is not consistently present @@ -88,7 +101,14 @@ in all Claude Code versions. APR% may show as 100% when data is unavailable. ## Features -- **Multi-Agent Support** - Automatic detection of Claude Code, Gemini CLI, OpenAI Codex, and Qwen Code +- **Multi-Agent Support** - Automatic detection of Claude Code, Gemini CLI, OpenAI Codex, Qwen Code, Cline, GitHub Copilot Chat, and opencode (via `service.name`) +- **Live Session Panel** - For Claude Code sessions scraped from `~/.claude/sessions/`: per-session status (Thinking / Executing / Waiting / RateLimited), current tool + arg, context window %, RSS, and any subagents +- **Rate-Limit Gauges** - 5-hour and 7-day Claude quota bars + reset countdown (requires `agenttop --setup claude` to install the StatusLine hook) +- **Token-rate Sparkline** - Tokens/sec over the last 5 minutes, bucketed into a braille sparkline +- **Host Vitals** - CPU%, MEM%, and 1-min load average in the header (cross-platform via `sysinfo`) +- **Open-Port + Orphan Tracking** - Ports opened by agent child processes; surfaces "orphan" ports left behind when a session dies +- **Project Filtering** - Auto-detects project from file paths, filter with `[r]` +- **Compaction Tracking** - Header shows compaction event count and last `pre→post` token delta (Claude Code 2026+) - **Token Tracking** - Input, output, and cache token metrics - **Tool Table** - Real-time tool call metrics with: - Call count and error count @@ -140,10 +160,14 @@ sudo mv agenttop /usr/local/bin/ agenttop # Configure a specific provider -agenttop --setup claude # Configure Claude Code -agenttop --setup gemini # Configure Gemini CLI -agenttop --setup qwen # Configure Qwen Code -agenttop --setup all # Configure all JSON-based providers +agenttop --setup claude # Configure Claude Code (auto-writes ~/.claude/settings.json) +agenttop --setup gemini # Configure Gemini CLI (auto) +agenttop --setup qwen # Configure Qwen Code (auto) +agenttop --setup copilot # Configure GitHub Copilot Chat (auto-writes VSCode settings.json) +agenttop --setup codex # Print Codex TOML setup instructions +agenttop --setup cline # Print Cline Enterprise dashboard setup instructions +agenttop --setup opencode # Print opencode plugin setup instructions +agenttop --setup all # Run every provider's setup # Run in headless mode (no TUI, just OTLP receiver) agenttop --headless @@ -164,7 +188,7 @@ That's it! agenttop automatically: | `p` | Pause/resume updates | | `d` / `Enter` | Show tool details | | `t` | Cycle time filter | -| `r` | Reset statistics | +| `r` | Cycle project filter | | `a` | Cycle through detected agents | | `↑`/`k` | Select previous | | `↓`/`j` | Select next | @@ -183,16 +207,28 @@ agenttop automatically configures Claude Code's `~/.claude/settings.json` with t "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_LOG_TOOL_DETAILS": "1", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318" } } ``` -A backup is created at `~/.claude/settings.json.bak` before any modifications. +`OTEL_LOG_TOOL_DETAILS=1` is what makes per-MCP-server tool names visible +(see Limitations above). A backup is created at `~/.claude/settings.json.bak` +before any modifications. **Note:** After agenttop configures your settings, restart Claude Code for the telemetry to take effect. +#### StatusLine hook (rate-limit ingestion) + +`agenttop --setup claude` also writes `~/.claude/agenttop-statusline.sh` and +registers it as Claude Code's `statusLine` command. The hook captures the +rate-limit JSON Claude pipes to its status bar and writes +`~/.claude/agenttop-rate-limits.json`, which the TUI reads to render the +Quota panel. Requires `jq` on `$PATH`; degrades to a plain status line +otherwise. Anything older than 10 minutes is treated as stale and ignored. + ### OpenAI Codex CLI (Manual Setup Required) OpenAI Codex uses TOML configuration. Add the following to `~/.codex/config.toml`: @@ -204,10 +240,48 @@ exporter = "otlp-http" endpoint = "http://localhost:4318/v1/logs" ``` +Caveat: as of 2026-Q1, `codex exec` and `codex mcp-server` emit no telemetry +([codex#12913](https://github.com/openai/codex/issues/12913)) — only interactive +sessions populate the receiver. + ### Gemini CLI / Qwen Code (Auto-configured) Run `agenttop --setup gemini` or `agenttop --setup qwen` to auto-configure these providers. +### GitHub Copilot Chat (Auto-configured) + +`agenttop --setup copilot` writes the OTLP keys into your VSCode user +`settings.json` and creates a `.bak` alongside it: + +```json +{ + "github.copilot.chat.otel.enabled": true, + "github.copilot.chat.otel.otlpEndpoint": "http://localhost:4318" +} +``` + +Reload VSCode after running it. Set `"github.copilot.chat.otel.captureContent": true` +yourself if you want prompts/responses captured (opt-in). + +### Cline (Manual via Cline Enterprise dashboard) + +Cline emits standard OTLP but is configured through Cline Enterprise's remote +configuration dashboard, not a local file. Point its OTLP endpoint at +`http://localhost:4318` and set `OTEL_SERVICE_NAME=cline` so agenttop can +distinguish it from other agents. + +### opencode (Manual via community plugin) + +opencode (sst/opencode) doesn't have native OTLP yet. Install the community +plugin [`DEVtheOPS/opencode-plugin-otel`](https://github.com/DEVtheOPS/opencode-plugin-otel) +and set the env vars it documents: + +```bash +export OPENCODE_ENABLE_TELEMETRY=1 +export OPENCODE_OTLP_ENDPOINT=http://localhost:4318 +export OPENCODE_OTLP_PROTOCOL=http/protobuf +``` + ## Data Storage Metrics are stored in DuckDB at: @@ -218,20 +292,31 @@ Data is automatically pruned after 7 days. ## How It Works -agenttop uses Claude Code's native OpenTelemetry support to collect metrics: +agenttop combines two data sources: vendor-neutral OTLP telemetry (for any +agent) and local file/process scraping (for live state that telemetry doesn't +expose). ``` -Claude Code agenttop - │ │ - ├── OTEL metrics ─────────────────►│ HTTP OTLP Receiver - │ (port 4318) │ │ - │ │ ▼ - └── OTEL events ──────────────────►│ DuckDB (embedded) - (tool_result, api_request) │ │ - │ ▼ - │ Ratatui TUI +Claude Code / Gemini / Codex / … agenttop + │ │ + ├── OTEL metrics ──────────────────────►│ HTTP OTLP Receiver + │ (port 4318) │ │ + │ │ ▼ + └── OTEL events ──────────────────────►│ DuckDB (embedded, 7-day retention) + (tool_result, api_request) │ │ + │ ▼ +Local FS / process tree │ Ratatui TUI + │ │ ▲ + ├── ~/.claude/sessions/{PID}.json ─────►│ │ + ├── ~/.claude/projects/.../*.jsonl ────►│ │ Scraper (sysinfo + file tail) + ├── ~/.claude/agenttop-rate-limits.json►│ │ + ├── lsof (listening ports) ────────────►│ │ + └── sysinfo (CPU / MEM / load / RSS) ──►│ ``` +The scraper runs every refresh tick (~100 ms) but defers expensive ops like +port enumeration to a slow cycle (~5 s). + ### Metrics Collected | Metric | Description | diff --git a/src/config/mod.rs b/src/config/mod.rs index e37100a..6deb226 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4,6 +4,60 @@ use std::path::PathBuf; const OTLP_ENDPOINT: &str = "http://localhost:4318"; +/// File name (under `~/.claude/`) for the StatusLine hook script that +/// captures rate-limit info into the sidecar JSON. +pub const STATUSLINE_SCRIPT_NAME: &str = "agenttop-statusline.sh"; + +/// Sidecar JSON written by the StatusLine hook; read by the scraper. +pub const RATE_LIMIT_SIDECAR_NAME: &str = "agenttop-rate-limits.json"; + +/// Bash script body for the StatusLine hook. Reads Claude's session JSON +/// from stdin, extracts whatever rate-limit fields are present, and writes +/// the result to `~/.claude/agenttop-rate-limits.json`. Requires `jq`; +/// degrades to printing only the status line otherwise. +pub const STATUSLINE_SCRIPT_BODY: &str = r#"#!/bin/sh +# agenttop StatusLine hook — installed by `agenttop --setup claude`. +# Captures rate-limit data from Claude Code's status-line stdin into a +# sidecar file consumed by the agenttop TUI. + +CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +SIDECAR="$CLAUDE_DIR/agenttop-rate-limits.json" +TMP="$(mktemp)" +cat > "$TMP" + +if command -v jq >/dev/null 2>&1; then + NOW="$(date +%s)" + jq -c --argjson now "$NOW" '{ + five_hour_pct: ( + (.rate_limits.five_hour.pct // .rate_limits.fiveHourPct + // .quotas.five_hour.used_pct // null) + ), + seven_day_pct: ( + (.rate_limits.seven_day.pct // .rate_limits.sevenDayPct + // .quotas.seven_day.used_pct // null) + ), + five_hour_resets_at: ( + (.rate_limits.five_hour.resets_at // .rate_limits.fiveHourResetsAt + // .quotas.five_hour.resets_at // null) + ), + seven_day_resets_at: ( + (.rate_limits.seven_day.resets_at // .rate_limits.sevenDayResetsAt + // .quotas.seven_day.resets_at // null) + ), + updated_at: $now + }' < "$TMP" > "$SIDECAR.tmp" 2>/dev/null && mv "$SIDECAR.tmp" "$SIDECAR" + + MODEL="$(jq -r '.model.display_name // .model // "?"' < "$TMP" 2>/dev/null)" +else + MODEL="?" +fi + +rm -f "$TMP" + +# Status line shown by Claude (single line). +printf "agenttop | %s\n" "$MODEL" +"#; + #[allow(dead_code)] pub fn claude_settings_path() -> Option { dirs::home_dir().map(|home| home.join(".claude").join("settings.json")) @@ -24,13 +78,15 @@ pub fn ensure_otel_configured() -> Result<()> { fs::create_dir_all(parent)?; } - // Create new settings file with OTEL enabled via env block + // Create new settings file with OTEL enabled via env block. + // OTEL_LOG_TOOL_DETAILS=1 opts in to per-MCP-server tool names (Claude Code 2.1.2+). let settings = serde_json::json!({ "enableTelemetry": true, "env": { "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_LOG_TOOL_DETAILS": "1", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "OTEL_EXPORTER_OTLP_ENDPOINT": OTLP_ENDPOINT } @@ -68,6 +124,7 @@ pub fn ensure_otel_configured() -> Result<()> { != Some("1") || env.get("OTEL_METRICS_EXPORTER").and_then(|v| v.as_str()) != Some("otlp") || env.get("OTEL_LOGS_EXPORTER").and_then(|v| v.as_str()) != Some("otlp") + || env.get("OTEL_LOG_TOOL_DETAILS").and_then(|v| v.as_str()) != Some("1") || env .get("OTEL_EXPORTER_OTLP_ENDPOINT") .and_then(|v| v.as_str()) @@ -85,6 +142,7 @@ pub fn ensure_otel_configured() -> Result<()> { env["CLAUDE_CODE_ENABLE_TELEMETRY"] = serde_json::Value::String("1".to_string()); env["OTEL_METRICS_EXPORTER"] = serde_json::Value::String("otlp".to_string()); env["OTEL_LOGS_EXPORTER"] = serde_json::Value::String("otlp".to_string()); + env["OTEL_LOG_TOOL_DETAILS"] = serde_json::Value::String("1".to_string()); env["OTEL_EXPORTER_OTLP_PROTOCOL"] = serde_json::Value::String("http/protobuf".to_string()); env["OTEL_EXPORTER_OTLP_ENDPOINT"] = serde_json::Value::String(OTLP_ENDPOINT.to_string()); @@ -114,6 +172,69 @@ pub fn ensure_otel_configured() -> Result<()> { Ok(()) } +/// Install the StatusLine hook script and register it in Claude's +/// `settings.json`. Idempotent — running twice is a no-op once the hook is +/// already pointed at our script. Returns `Ok(true)` if any change was made. +pub fn install_statusline_hook() -> Result { + let home = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + install_statusline_hook_in(&home.join(".claude")) +} + +/// Variant that takes an explicit Claude config dir — used by tests and any +/// future code path that wants to install into `$CLAUDE_CONFIG_DIR`. +pub fn install_statusline_hook_in(claude_dir: &std::path::Path) -> Result { + fs::create_dir_all(claude_dir)?; + + let script_path = claude_dir.join(STATUSLINE_SCRIPT_NAME); + let mut script_changed = false; + let existing = fs::read_to_string(&script_path).ok(); + if existing.as_deref() != Some(STATUSLINE_SCRIPT_BODY) { + fs::write(&script_path, STATUSLINE_SCRIPT_BODY) + .with_context(|| format!("write {}", script_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms)?; + } + script_changed = true; + } + + let settings_path = claude_dir.join("settings.json"); + let script_str = script_path.to_string_lossy().to_string(); + + let mut settings: serde_json::Value = if settings_path.exists() { + let content = fs::read_to_string(&settings_path).context("read settings.json")?; + serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + let desired_status_line = serde_json::json!({ + "type": "command", + "command": script_str, + }); + + let needs_settings_update = settings.get("statusLine") != Some(&desired_status_line); + + if needs_settings_update { + if settings_path.exists() { + let backup_path = settings_path.with_extension("json.bak"); + // Don't overwrite an existing backup — preserve the user's earliest pre-agenttop state. + if !backup_path.exists() { + let _ = fs::copy(&settings_path, &backup_path); + } + } + settings["statusLine"] = desired_status_line; + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + tracing::info!("Registered agenttop StatusLine hook in {:?}", settings_path); + } + + Ok(script_changed || needs_settings_update) +} + #[allow(dead_code)] pub fn is_otel_configured() -> bool { let Some(settings_path) = claude_settings_path() else { @@ -150,3 +271,74 @@ pub fn is_otel_configured() -> bool { telemetry_enabled && endpoint_correct } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_statusline_hook_creates_script_and_patches_settings() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + + // First invocation: should create everything. + let changed = install_statusline_hook_in(&claude_dir).unwrap(); + assert!(changed); + + let script_path = claude_dir.join(STATUSLINE_SCRIPT_NAME); + assert!(script_path.exists()); + let body = fs::read_to_string(&script_path).unwrap(); + assert_eq!(body, STATUSLINE_SCRIPT_BODY); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&script_path).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "script must be executable"); + } + + let settings_path = claude_dir.join("settings.json"); + assert!(settings_path.exists()); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + let status_line = settings.get("statusLine").expect("statusLine present"); + assert_eq!( + status_line.get("command").and_then(|v| v.as_str()), + Some(script_path.to_string_lossy().as_ref()) + ); + + // Second invocation: should be a no-op (returns false). + let changed_again = install_statusline_hook_in(&claude_dir).unwrap(); + assert!(!changed_again); + } + + #[test] + fn install_statusline_hook_preserves_existing_settings() { + let tmp = tempfile::tempdir().unwrap(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + + // Pre-existing user settings. + let settings_path = claude_dir.join("settings.json"); + let original = serde_json::json!({ + "enableTelemetry": true, + "env": { "FOO": "bar" } + }); + fs::write(&settings_path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + + install_statusline_hook_in(&claude_dir).unwrap(); + + // Backup created. + assert!(settings_path.with_extension("json.bak").exists()); + + // Original keys preserved. + let after: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + assert_eq!(after.get("enableTelemetry"), Some(&serde_json::json!(true))); + assert_eq!( + after.get("env").and_then(|e| e.get("FOO")), + Some(&serde_json::json!("bar")) + ); + assert!(after.get("statusLine").is_some()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 01f6409..70271f7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,10 @@ //! A terminal observability dashboard for monitoring Claude Code and other AI agents. pub mod config; +pub mod model_info; pub mod otlp; +pub mod project; pub mod providers; +pub mod scraper; pub mod storage; pub mod tui; diff --git a/src/main.rs b/src/main.rs index 5de92ef..1a2b529 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ mod config; +mod model_info; mod otlp; +mod project; mod providers; +mod scraper; mod storage; mod tui; @@ -18,14 +21,18 @@ struct Args { #[arg(short = 'H', long)] headless: bool, - /// Configure OTLP telemetry for a provider (claude, gemini, qwen, all) + /// Configure OTLP telemetry for a provider + /// (claude, gemini, qwen, codex, cline, copilot, opencode, all) #[arg(long, value_name = "PROVIDER")] setup: Option, } fn run_setup(provider_name: &str) -> Result<()> { let providers_to_setup: Vec<&str> = if provider_name == "all" { - vec!["claude", "gemini", "qwen"] + // "all" runs every provider that has auto-config; manual ones print instructions. + vec![ + "claude", "gemini", "qwen", "copilot", "codex", "cline", "opencode", + ] } else { vec![provider_name] }; @@ -35,6 +42,9 @@ fn run_setup(provider_name: &str) -> Result<()> { "claude" => "claude_code", "gemini" => "gemini_cli", "qwen" => "qwen_code", + "copilot" => "copilot_chat", + "cline" => "cline", + "opencode" => "opencode", "codex" | "openai" => { println!("OpenAI Codex uses TOML config format (~/.codex/config.toml)."); println!("Please configure manually. Add to your config.toml:"); @@ -44,11 +54,18 @@ fn run_setup(provider_name: &str) -> Result<()> { println!("[otel.exporter.otlp-http]"); println!("endpoint = \"http://localhost:4318/v1/logs\""); println!(); + println!( + "Note: as of 2026-Q1, `codex exec` and `codex mcp-server` emit no telemetry" + ); + println!("(see https://github.com/openai/codex/issues/12913)."); + println!(); continue; } _ => { eprintln!("Unknown provider: {}", name); - eprintln!("Available providers: claude, gemini, qwen, codex, all"); + eprintln!( + "Available providers: claude, gemini, qwen, codex, cline, copilot, opencode, all" + ); continue; } }; @@ -69,10 +86,32 @@ fn run_setup(provider_name: &str) -> Result<()> { ); } Ok(false) => { - println!(" {} is already configured correctly.", provider.name()); + if let Some(instructions) = provider.setup_instructions() { + // Manual config path — print the instructions instead of "already configured". + println!("{}", instructions); + } else { + println!(" {} is already configured correctly.", provider.name()); + } } Err(e) => { eprintln!(" Error configuring {}: {}", provider.name(), e); + if let Some(instructions) = provider.setup_instructions() { + println!(); + println!("{}", instructions); + } + } + } + + // For Claude Code, also install the StatusLine hook that powers + // the in-TUI quota gauges. Best-effort — log warning on failure + // since OTLP is the primary path. + if provider_id == "claude_code" { + match crate::config::install_statusline_hook() { + Ok(true) => println!( + " Installed agenttop StatusLine hook (rate-limit capture)" + ), + Ok(false) => {} + Err(e) => eprintln!(" Could not install StatusLine hook: {}", e), } } } diff --git a/src/model_info.rs b/src/model_info.rs new file mode 100644 index 0000000..698f66c --- /dev/null +++ b/src/model_info.rs @@ -0,0 +1,104 @@ +//! Static lookup table from model identifier to its context window size. +//! +//! Used by the scraper to compute context-window utilization without making +//! any API call. The table is intentionally permissive: we match on substring +//! to absorb model-name variants (`claude-opus-4-5-20250514`, `opus-4.5`, +//! `claude-opus-4-5-1m`, …). Unknown models return `None`; callers should +//! render a "—" rather than guess. + +/// Returns the model's context window in tokens, if known. +pub fn context_window_for(model_id: &str) -> Option { + let n = model_id.to_lowercase(); + + // Claude — Opus 4.x has both a 200k and a 1M variant + if n.contains("opus") { + if n.contains("1m") || n.contains("-1m") { + return Some(1_000_000); + } + return Some(200_000); + } + if n.contains("sonnet") { + if n.contains("1m") { + return Some(1_000_000); + } + return Some(200_000); + } + if n.contains("haiku") { + return Some(200_000); + } + + // OpenAI Codex / GPT family + if n.contains("gpt-5") || n.contains("o5") { + return Some(400_000); + } + if n.contains("gpt-4.1") { + return Some(1_000_000); + } + if n.contains("gpt-4o") || n.contains("o3") || n.contains("o4") { + return Some(128_000); + } + + // Google Gemini + if n.contains("gemini-2") || n.contains("gemini-3") { + return Some(2_000_000); + } + if n.contains("gemini") { + return Some(1_000_000); + } + + // Qwen + if n.contains("qwen") { + return Some(128_000); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_opus_default() { + assert_eq!( + context_window_for("claude-opus-4-5-20250514"), + Some(200_000) + ); + } + + #[test] + fn claude_opus_1m_variant() { + assert_eq!(context_window_for("claude-opus-4-7-1m"), Some(1_000_000)); + } + + #[test] + fn claude_sonnet() { + assert_eq!(context_window_for("claude-sonnet-4-6"), Some(200_000)); + } + + #[test] + fn claude_haiku() { + assert_eq!(context_window_for("claude-haiku-4-5"), Some(200_000)); + } + + #[test] + fn openai_gpt5() { + assert_eq!(context_window_for("gpt-5-mini"), Some(400_000)); + } + + #[test] + fn gemini_25() { + assert_eq!(context_window_for("gemini-2.5-pro"), Some(2_000_000)); + } + + #[test] + fn qwen() { + assert_eq!(context_window_for("qwen-coder-32b"), Some(128_000)); + } + + #[test] + fn unknown_model_returns_none() { + assert_eq!(context_window_for("some-random-model"), None); + assert_eq!(context_window_for(""), None); + } +} diff --git a/src/otlp/parser.rs b/src/otlp/parser.rs index ea1ba73..f7a3b9d 100644 --- a/src/otlp/parser.rs +++ b/src/otlp/parser.rs @@ -121,9 +121,18 @@ struct OtlpLogsRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ResourceLogs { + #[serde(default)] + resource: Option, scope_logs: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceJson { + #[serde(default)] + attributes: Vec, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ScopeLogs { @@ -392,6 +401,21 @@ fn parse_logs_proto(request: ExportLogsServiceRequest) -> Result> let mut events = Vec::new(); for resource in request.resource_logs { + let resource_attrs: HashMap = resource + .resource + .as_ref() + .map(|r| { + r.attributes + .iter() + .filter_map(|a| { + a.value + .as_ref() + .and_then(|v| get_any_value_as_string(v).map(|s| (a.key.clone(), s))) + }) + .collect() + }) + .unwrap_or_default(); + for scope in resource.scope_logs { for record in scope.log_records { // Extract event.name from attributes @@ -424,7 +448,7 @@ fn parse_logs_proto(request: ExportLogsServiceRequest) -> Result> } // Store ALL attributes as a HashMap for query-time filtering - let attributes: HashMap = record + let mut attributes: HashMap = record .attributes .iter() .filter_map(|a| { @@ -434,6 +458,14 @@ fn parse_logs_proto(request: ExportLogsServiceRequest) -> Result> }) .collect(); + // Merge resource-level attributes (e.g. service.name) so detection + // and querying can see them per-event without a separate join. + for (k, v) in &resource_attrs { + attributes.entry(k.clone()).or_insert_with(|| v.clone()); + } + + expand_mcp_tool_name(&mut attributes); + // Extract body if present let body = record.body.as_ref().and_then(get_string_value); @@ -462,6 +494,19 @@ fn parse_logs_json(request: OtlpLogsRequest) -> Result> { let mut events = Vec::new(); for resource in request.resource_logs { + let resource_attrs: HashMap = resource + .resource + .as_ref() + .map(|r| { + r.attributes + .iter() + .filter_map(|a| { + get_json_attribute_as_string(&a.value).map(|s| (a.key.clone(), s)) + }) + .collect() + }) + .unwrap_or_default(); + for scope in resource.scope_logs { for record in scope.log_records { // Extract event.name from attributes @@ -472,7 +517,7 @@ fn parse_logs_json(request: OtlpLogsRequest) -> Result> { .and_then(|a| a.value.string_value.clone()); // Store ALL attributes as a HashMap for query-time filtering - let attributes: HashMap = record + let mut attributes: HashMap = record .attributes .iter() .filter_map(|a| { @@ -480,6 +525,12 @@ fn parse_logs_json(request: OtlpLogsRequest) -> Result> { }) .collect(); + for (k, v) in &resource_attrs { + attributes.entry(k.clone()).or_insert_with(|| v.clone()); + } + + expand_mcp_tool_name(&mut attributes); + // Extract body if present let body = record.body.as_ref().and_then(|b| b.string_value.clone()); @@ -502,6 +553,30 @@ fn parse_logs_json(request: OtlpLogsRequest) -> Result> { Ok(events) } +/// Reconstitute the full MCP tool name from `tool_parameters`. +/// +/// Claude Code 2.1.2+ anonymizes MCP tool names as `mcp_tool` by default. When +/// `OTEL_LOG_TOOL_DETAILS=1` is set, the real names land in `tool_parameters` +/// as a JSON-encoded `{"mcp_server_name": "...", "mcp_tool_name": "..."}`. +/// We rewrite `tool_name` back to `mcp____` so the rest of the +/// pipeline (regex in storage, display in TUI) works unchanged. +fn expand_mcp_tool_name(attributes: &mut HashMap) { + if attributes.get("tool_name").map(String::as_str) != Some("mcp_tool") { + return; + } + let Some(raw) = attributes.get("tool_parameters") else { + return; + }; + let Ok(parsed) = serde_json::from_str::(raw) else { + return; + }; + let server = parsed.get("mcp_server_name").and_then(|v| v.as_str()); + let tool = parsed.get("mcp_tool_name").and_then(|v| v.as_str()); + if let (Some(server), Some(tool)) = (server, tool) { + attributes.insert("tool_name".to_string(), format!("mcp__{server}__{tool}")); + } +} + /// Convert JSON AttributeValue to string fn get_json_attribute_as_string(value: &AttributeValue) -> Option { if let Some(s) = &value.string_value { @@ -701,4 +776,74 @@ mod tests { Some("claude_code.tool_result".to_string()) ); } + + #[test] + fn test_expand_mcp_tool_name_structured() { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), "mcp_tool".to_string()); + attrs.insert( + "tool_parameters".to_string(), + r#"{"mcp_server_name":"context7","mcp_tool_name":"resolve-library-id"}"#.to_string(), + ); + + expand_mcp_tool_name(&mut attrs); + + assert_eq!( + attrs.get("tool_name").map(String::as_str), + Some("mcp__context7__resolve-library-id") + ); + } + + #[test] + fn test_expand_mcp_tool_name_missing_parameters() { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), "mcp_tool".to_string()); + + expand_mcp_tool_name(&mut attrs); + + // No tool_parameters present, leave tool_name alone. + assert_eq!(attrs.get("tool_name").map(String::as_str), Some("mcp_tool")); + } + + #[test] + fn test_expand_mcp_tool_name_malformed_json() { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), "mcp_tool".to_string()); + attrs.insert("tool_parameters".to_string(), "not valid json{".to_string()); + + // Must not panic. + expand_mcp_tool_name(&mut attrs); + + assert_eq!(attrs.get("tool_name").map(String::as_str), Some("mcp_tool")); + } + + #[test] + fn test_expand_mcp_tool_name_non_mcp_passthrough() { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), "Read".to_string()); + attrs.insert( + "tool_parameters".to_string(), + r#"{"mcp_server_name":"x","mcp_tool_name":"y"}"#.to_string(), + ); + + expand_mcp_tool_name(&mut attrs); + + // Non-MCP tools must not be rewritten even if tool_parameters happens to parse. + assert_eq!(attrs.get("tool_name").map(String::as_str), Some("Read")); + } + + #[test] + fn test_expand_mcp_tool_name_partial_fields() { + let mut attrs = HashMap::new(); + attrs.insert("tool_name".to_string(), "mcp_tool".to_string()); + attrs.insert( + "tool_parameters".to_string(), + r#"{"mcp_server_name":"context7"}"#.to_string(), + ); + + expand_mcp_tool_name(&mut attrs); + + // Only one of the two fields present — fall through. + assert_eq!(attrs.get("tool_name").map(String::as_str), Some("mcp_tool")); + } } diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..ec401ca --- /dev/null +++ b/src/project.rs @@ -0,0 +1,244 @@ +//! Project detection from Claude Code session data +//! +//! Maps session.id to project name by scanning ~/.claude/projects/*/sessions-index.json + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Session entry from sessions-index.json +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionEntry { + session_id: String, + project_path: String, + #[allow(dead_code)] + git_branch: Option, +} + +/// Sessions index file structure +#[derive(Debug, Deserialize)] +struct SessionsIndex { + entries: Vec, +} + +/// Resolved project info +#[derive(Debug, Clone)] +pub struct ResolvedProject { + pub name: String, + #[allow(dead_code)] + pub path: String, +} + +/// Maps session.id to project name by scanning ~/.claude/projects/*/sessions-index.json +pub struct ProjectResolver { + /// Map of session_id -> project info + session_to_project: HashMap, + /// List of all unique project names + all_projects: Vec, +} + +impl ProjectResolver { + /// Create a new ProjectResolver by scanning Claude Code's project directories + pub fn new() -> Self { + let mut resolver = Self { + session_to_project: HashMap::new(), + all_projects: Vec::new(), + }; + resolver.scan_projects(); + resolver + } + + /// Scan ~/.claude/projects/ for sessions-index.json files and build the mapping + fn scan_projects(&mut self) { + let claude_projects_dir = match Self::claude_projects_dir() { + Some(dir) => dir, + None => { + tracing::debug!("Could not find ~/.claude/projects directory"); + return; + } + }; + + tracing::debug!("Scanning Claude projects at: {:?}", claude_projects_dir); + + // Read all directories in ~/.claude/projects/ + let entries = match std::fs::read_dir(&claude_projects_dir) { + Ok(entries) => entries, + Err(e) => { + tracing::warn!("Failed to read Claude projects directory: {}", e); + return; + } + }; + + let mut unique_projects: std::collections::HashSet = + std::collections::HashSet::new(); + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Look for sessions-index.json in each project directory + let index_path = path.join("sessions-index.json"); + if !index_path.exists() { + continue; + } + + // Parse sessions-index.json + match Self::parse_sessions_index(&index_path) { + Ok(index) => { + for session_entry in index.entries { + // Extract project name from project_path (last component) + let project_name = std::path::Path::new(&session_entry.project_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + unique_projects.insert(project_name.clone()); + + self.session_to_project.insert( + session_entry.session_id, + ResolvedProject { + name: project_name, + path: session_entry.project_path, + }, + ); + } + } + Err(e) => { + tracing::debug!( + "Failed to parse sessions-index.json at {:?}: {}", + index_path, + e + ); + } + } + } + + // Store sorted list of unique projects + self.all_projects = unique_projects.into_iter().collect(); + self.all_projects.sort(); + + tracing::info!( + "ProjectResolver loaded {} sessions across {} projects", + self.session_to_project.len(), + self.all_projects.len() + ); + } + + /// Parse a sessions-index.json file + fn parse_sessions_index(path: &PathBuf) -> anyhow::Result { + let content = std::fs::read_to_string(path)?; + let index: SessionsIndex = serde_json::from_str(&content)?; + Ok(index) + } + + /// Get the Claude projects directory path (~/.claude/projects/) + fn claude_projects_dir() -> Option { + dirs::home_dir().map(|home| home.join(".claude").join("projects")) + } + + /// Resolve a session.id to its project name + pub fn resolve(&self, session_id: &str) -> Option<&ResolvedProject> { + self.session_to_project.get(session_id) + } + + /// Get all unique project names + #[allow(dead_code)] + pub fn get_all_projects(&self) -> &[String] { + &self.all_projects + } + + /// Refresh the project mappings by re-scanning + #[allow(dead_code)] + pub fn refresh(&mut self) { + self.session_to_project.clear(); + self.all_projects.clear(); + self.scan_projects(); + } + + /// Get the number of sessions loaded + #[allow(dead_code)] + pub fn session_count(&self) -> usize { + self.session_to_project.len() + } + + /// Get the number of projects loaded + #[allow(dead_code)] + pub fn project_count(&self) -> usize { + self.all_projects.len() + } +} + +impl Default for ProjectResolver { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_test_sessions_index(dir: &std::path::Path, entries: &[(&str, &str)]) { + let sessions: Vec = entries + .iter() + .map(|(session_id, project_path)| { + serde_json::json!({ + "sessionId": session_id, + "projectPath": project_path, + "gitBranch": "main" + }) + }) + .collect(); + + let index = serde_json::json!({ "entries": sessions }); + fs::write( + dir.join("sessions-index.json"), + serde_json::to_string_pretty(&index).unwrap(), + ) + .unwrap(); + } + + #[test] + fn test_parse_sessions_index() { + let tmp = TempDir::new().unwrap(); + let project_dir = tmp.path().join("-Users-test-myproject"); + fs::create_dir_all(&project_dir).unwrap(); + + create_test_sessions_index( + &project_dir, + &[("session-123", "/Users/test/myproject")], + ); + + let index = + ProjectResolver::parse_sessions_index(&project_dir.join("sessions-index.json")) + .unwrap(); + assert_eq!(index.entries.len(), 1); + assert_eq!(index.entries[0].session_id, "session-123"); + assert_eq!(index.entries[0].project_path, "/Users/test/myproject"); + } + + #[test] + fn test_project_name_extraction() { + // Test that project name is extracted from project_path + let path = "/Users/it-support/Desktop/dev/agenttop"; + let name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + assert_eq!(name, "agenttop"); + } + + #[test] + fn test_resolver_default() { + // Just exercise the constructor; values depend on the host's ~/.claude/projects dir. + let resolver = ProjectResolver::default(); + let _ = resolver.session_count(); + let _ = resolver.project_count(); + } +} diff --git a/src/providers/claude_code.rs b/src/providers/claude_code.rs index 721e61d..58f760c 100644 --- a/src/providers/claude_code.rs +++ b/src/providers/claude_code.rs @@ -99,6 +99,10 @@ impl Provider for ClaudeCodeProvider { dirs::home_dir().map(|home| home.join(".claude").join("settings.json")) } + fn service_name(&self) -> Option<&'static str> { + Some("claude-code") + } + fn ensure_configured(&self) -> Result { let settings_path = self .settings_path() @@ -110,13 +114,15 @@ impl Provider for ClaudeCodeProvider { fs::create_dir_all(parent)?; } - // Create new settings file with OTEL enabled via env block + // Create new settings file with OTEL enabled via env block. + // OTEL_LOG_TOOL_DETAILS=1 opts in to per-MCP-server tool names (Claude Code 2.1.2+). let settings = serde_json::json!({ "enableTelemetry": true, "env": { "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", + "OTEL_LOG_TOOL_DETAILS": "1", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "OTEL_EXPORTER_OTLP_ENDPOINT": OTLP_ENDPOINT } @@ -155,6 +161,7 @@ impl Provider for ClaudeCodeProvider { != Some("1") || env.get("OTEL_METRICS_EXPORTER").and_then(|v| v.as_str()) != Some("otlp") || env.get("OTEL_LOGS_EXPORTER").and_then(|v| v.as_str()) != Some("otlp") + || env.get("OTEL_LOG_TOOL_DETAILS").and_then(|v| v.as_str()) != Some("1") || env .get("OTEL_EXPORTER_OTLP_ENDPOINT") .and_then(|v| v.as_str()) @@ -172,6 +179,7 @@ impl Provider for ClaudeCodeProvider { env["CLAUDE_CODE_ENABLE_TELEMETRY"] = serde_json::Value::String("1".to_string()); env["OTEL_METRICS_EXPORTER"] = serde_json::Value::String("otlp".to_string()); env["OTEL_LOGS_EXPORTER"] = serde_json::Value::String("otlp".to_string()); + env["OTEL_LOG_TOOL_DETAILS"] = serde_json::Value::String("1".to_string()); env["OTEL_EXPORTER_OTLP_PROTOCOL"] = serde_json::Value::String("http/protobuf".to_string()); env["OTEL_EXPORTER_OTLP_ENDPOINT"] = diff --git a/src/providers/cline.rs b/src/providers/cline.rs new file mode 100644 index 0000000..b6f15cc --- /dev/null +++ b/src/providers/cline.rs @@ -0,0 +1,70 @@ +//! Cline provider (VSCode extension; native OTLP since 2026) + +use super::Provider; + +/// Tool names emitted by Cline (subset; non-MCP tools land here). +const BUILTIN_TOOLS: &[&str] = &[ + "read_file", + "write_to_file", + "replace_in_file", + "list_files", + "search_files", + "execute_command", + "browser_action", + "attempt_completion", + "ask_followup_question", + "plan_mode_response", + "new_task", + "use_mcp_tool", + "access_mcp_resource", +]; + +/// Cline provider. Native OTLP via Cline Enterprise dashboard config. +/// agenttop can't auto-configure Cline (its OTLP settings live in a remote +/// dashboard), so setup is documented instead. +pub struct ClineProvider; + +impl Provider for ClineProvider { + fn id(&self) -> &'static str { + "cline" + } + + fn name(&self) -> &'static str { + "Cline" + } + + fn metric_prefix(&self) -> &'static str { + "cline" + } + + fn builtin_tools(&self) -> &'static [&'static str] { + BUILTIN_TOOLS + } + + fn shorten_model_name(&self, _name: &str) -> Option { + // Cline routes to user-selected upstream models; let other providers + // (Claude / OpenAI) own model name shortening. + None + } + + fn normalize_token_type(&self, _token_type: &str) -> Option<&'static str> { + None + } + + fn service_name(&self) -> Option<&'static str> { + Some("cline") + } + + fn setup_instructions(&self) -> Option<&'static str> { + Some( + "Cline emits standard OTLP and is configured through Cline Enterprise's\n\ + remote configuration dashboard, not a local file. To stream telemetry\n\ + to agenttop:\n\n\ + 1. In the Cline Enterprise dashboard, set the OTLP endpoint to:\n \ + http://localhost:4318 (HTTP/protobuf)\n\ + 2. Set the OTel resource attribute OTEL_SERVICE_NAME=cline so agenttop\n \ + can distinguish Cline from other agents.\n\ + 3. Reload Cline; tool calls and `cline.turns.total` will appear in agenttop.", + ) + } +} diff --git a/src/providers/copilot_chat.rs b/src/providers/copilot_chat.rs new file mode 100644 index 0000000..0ae0aac --- /dev/null +++ b/src/providers/copilot_chat.rs @@ -0,0 +1,137 @@ +//! GitHub Copilot Chat provider (VSCode; native OTLP since Feb 2026) + +use super::{Provider, TOKEN_INPUT, TOKEN_OUTPUT}; +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; + +const OTLP_ENDPOINT: &str = "http://localhost:4318"; + +pub struct CopilotChatProvider; + +impl Provider for CopilotChatProvider { + fn id(&self) -> &'static str { + "copilot_chat" + } + + fn name(&self) -> &'static str { + "GitHub Copilot Chat" + } + + fn metric_prefix(&self) -> &'static str { + // Copilot Chat emits under OTel GenAI Semantic Conventions. + "gen_ai" + } + + fn builtin_tools(&self) -> &'static [&'static str] { + // Tool names not yet documented by Microsoft; rely on service.name detection. + &[] + } + + fn shorten_model_name(&self, _name: &str) -> Option { + None + } + + fn normalize_token_type(&self, token_type: &str) -> Option<&'static str> { + // OTel GenAI Semantic Conventions use these names on `gen_ai.client.token.usage`. + match token_type { + "input" | "prompt" => Some(TOKEN_INPUT), + "output" | "completion" => Some(TOKEN_OUTPUT), + _ => None, + } + } + + fn service_name(&self) -> Option<&'static str> { + Some("copilot-chat") + } + + fn settings_path(&self) -> Option { + vscode_user_settings_path() + } + + fn ensure_configured(&self) -> Result { + let Some(settings_path) = self.settings_path() else { + return Ok(false); + }; + + if !settings_path.exists() { + tracing::warn!( + "VSCode settings.json not found at {:?}; install VSCode and run Copilot Chat at least once before configuring", + settings_path + ); + return Ok(false); + } + + let content = + fs::read_to_string(&settings_path).context("Failed to read VSCode settings.json")?; + let mut settings: serde_json::Value = serde_json::from_str(&content) + .context("Failed to parse VSCode settings.json (may contain trailing commas; edit manually if so)")?; + + let needs_update = settings + .get("github.copilot.chat.otel.enabled") + != Some(&serde_json::Value::Bool(true)) + || settings + .get("github.copilot.chat.otel.otlpEndpoint") + .and_then(|v| v.as_str()) + != Some(OTLP_ENDPOINT); + + if !needs_update { + tracing::debug!("Copilot Chat OTLP already configured"); + return Ok(false); + } + + let backup_path = settings_path.with_extension("json.bak"); + fs::copy(&settings_path, &backup_path)?; + tracing::info!("Backed up VSCode settings to {:?}", backup_path); + + if let Some(obj) = settings.as_object_mut() { + obj.insert( + "github.copilot.chat.otel.enabled".to_string(), + serde_json::Value::Bool(true), + ); + obj.insert( + "github.copilot.chat.otel.otlpEndpoint".to_string(), + serde_json::Value::String(OTLP_ENDPOINT.to_string()), + ); + } + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + tracing::info!( + "Updated VSCode settings.json with Copilot Chat OTLP at {:?}", + settings_path + ); + Ok(true) + } + + fn setup_instructions(&self) -> Option<&'static str> { + Some( + "Copilot Chat reads OTLP config from VSCode settings.json. agenttop\n\ + auto-writes the keys with `agenttop --setup copilot`; reload VSCode\n\ + after running it. If captured prompt/response content is desired, also\n\ + set \"github.copilot.chat.otel.captureContent\": true (opt-in).", + ) + } +} + +fn vscode_user_settings_path() -> Option { + let home = dirs::home_dir()?; + + if cfg!(target_os = "macos") { + Some( + home.join("Library") + .join("Application Support") + .join("Code") + .join("User") + .join("settings.json"), + ) + } else if cfg!(target_os = "windows") { + dirs::config_dir().map(|c| c.join("Code").join("User").join("settings.json")) + } else { + Some( + home.join(".config") + .join("Code") + .join("User") + .join("settings.json"), + ) + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 49241af..ea0562d 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -8,7 +8,10 @@ //! - Token type normalization pub mod claude_code; +pub mod cline; +pub mod copilot_chat; pub mod gemini_cli; +pub mod opencode; pub mod openai_codex; pub mod qwen_code; @@ -51,6 +54,19 @@ pub trait Provider: Send + Sync { fn settings_path(&self) -> Option { None } + + /// OTel `service.name` resource attribute this provider emits as. + /// Used to detect agents that don't have unique tool names or model patterns + /// (Cline, Copilot Chat, opencode). + fn service_name(&self) -> Option<&'static str> { + None + } + + /// Multi-line setup instructions printed by `agenttop --setup ` when + /// auto-configuration isn't possible (manual config, dashboard-driven, plugin install). + fn setup_instructions(&self) -> Option<&'static str> { + None + } } /// Registry of all known providers @@ -67,10 +83,21 @@ impl ProviderRegistry { Box::new(openai_codex::OpenAICodexProvider), Box::new(gemini_cli::GeminiCliProvider), Box::new(qwen_code::QwenCodeProvider), + Box::new(cline::ClineProvider), + Box::new(copilot_chat::CopilotChatProvider), + Box::new(opencode::OpenCodeProvider), ], } } + /// Look up a provider by its OTel `service.name` resource attribute. + pub fn find_by_service_name(&self, service_name: &str) -> Option<&dyn Provider> { + self.providers + .iter() + .find(|p| p.service_name() == Some(service_name)) + .map(|p| p.as_ref()) + } + /// Get all registered providers pub fn providers(&self) -> &[Box] { &self.providers @@ -160,9 +187,35 @@ mod tests { assert!(registry.get("openai_codex").is_some()); assert!(registry.get("gemini_cli").is_some()); assert!(registry.get("qwen_code").is_some()); + assert!(registry.get("cline").is_some()); + assert!(registry.get("copilot_chat").is_some()); + assert!(registry.get("opencode").is_some()); assert!(registry.get("unknown").is_none()); } + #[test] + fn test_find_by_service_name() { + let registry = ProviderRegistry::new(); + + assert_eq!( + registry.find_by_service_name("claude-code").map(|p| p.id()), + Some("claude_code") + ); + assert_eq!( + registry.find_by_service_name("cline").map(|p| p.id()), + Some("cline") + ); + assert_eq!( + registry.find_by_service_name("copilot-chat").map(|p| p.id()), + Some("copilot_chat") + ); + assert_eq!( + registry.find_by_service_name("opencode").map(|p| p.id()), + Some("opencode") + ); + assert!(registry.find_by_service_name("unknown_service").is_none()); + } + #[test] fn test_detect_from_metric() { let registry = ProviderRegistry::new(); diff --git a/src/providers/opencode.rs b/src/providers/opencode.rs new file mode 100644 index 0000000..2e97a42 --- /dev/null +++ b/src/providers/opencode.rs @@ -0,0 +1,50 @@ +//! opencode provider (sst/opencode; OTLP via DEVtheOPS/opencode-plugin-otel) + +use super::Provider; + +pub struct OpenCodeProvider; + +impl Provider for OpenCodeProvider { + fn id(&self) -> &'static str { + "opencode" + } + + fn name(&self) -> &'static str { + "opencode" + } + + fn metric_prefix(&self) -> &'static str { + "opencode" + } + + fn builtin_tools(&self) -> &'static [&'static str] { + // Native OTLP isn't shipped upstream yet; the community plugin mirrors + // Claude Code's tool naming. Detection happens via service.name=opencode. + &[] + } + + fn shorten_model_name(&self, _name: &str) -> Option { + None + } + + fn normalize_token_type(&self, _token_type: &str) -> Option<&'static str> { + None + } + + fn service_name(&self) -> Option<&'static str> { + Some("opencode") + } + + fn setup_instructions(&self) -> Option<&'static str> { + Some( + "opencode (sst/opencode) doesn't have native OTLP yet. Use the community\n\ + plugin DEVtheOPS/opencode-plugin-otel:\n\n\ + 1. Install the plugin per its README.\n\ + 2. Set these env vars in your shell:\n \ + OPENCODE_ENABLE_TELEMETRY=1\n \ + OPENCODE_OTLP_ENDPOINT=http://localhost:4318\n \ + OPENCODE_OTLP_PROTOCOL=http/protobuf\n\ + 3. Restart opencode; tool/token events will appear in agenttop.", + ) + } +} diff --git a/src/scraper/claude_sessions.rs b/src/scraper/claude_sessions.rs new file mode 100644 index 0000000..bef4783 --- /dev/null +++ b/src/scraper/claude_sessions.rs @@ -0,0 +1,492 @@ +//! Live Claude Code sessions, scraped from the local filesystem. +//! +//! Claude Code writes one `~/.claude/sessions/{PID}.json` per active session +//! (pid, sessionId, cwd, startedAt). The full conversation is appended to a +//! JSONL transcript at `~/.claude/projects/{encoded_cwd}/{sessionId}.jsonl`. +//! +//! We derive everything from these two files plus the live process tree: +//! - status: from transcript mtime + last line type +//! - current_task: from the most recent tool_use block +//! - tokens: sum of `usage` fields across all assistant turns +//! - model: from the latest `message.model` field +//! - mem_mb / children: from sysinfo +//! +//! Cross-tick state: we track per-transcript file offsets so we only re-read +//! appended bytes (matches abtop's incremental parsing). + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use super::process::ProcessScanner; +use super::{ChildProcess, LiveSession, SessionStatus}; +use crate::model_info::context_window_for; + +const STALE_MTIME_SECS: u64 = 30; + +#[derive(Debug, Deserialize)] +struct SessionFile { + pid: u32, + #[serde(rename = "sessionId")] + session_id: String, + cwd: String, + #[serde(rename = "startedAt", default)] + started_at: u64, +} + +pub fn scan( + sys: &ProcessScanner, + ports_by_pid: &HashMap, + transcript_offsets: &mut HashMap, +) -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let sessions_dir = home.join(".claude").join("sessions"); + let projects_dir = home.join(".claude").join("projects"); + + let Ok(read_dir) = fs::read_dir(&sessions_dir) else { + return Vec::new(); + }; + + let mut out = Vec::new(); + for entry in read_dir.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + let Ok(meta): std::result::Result = serde_json::from_str(&content) else { + continue; + }; + + // PID-reuse safety: the session file lingers across crashes. Skip + // entries whose PID is gone OR whose live process isn't actually + // claude (PID got reassigned). + let Some(proc) = sys.get(meta.pid) else { + continue; + }; + if !proc.cmd.contains("claude") && !proc.name.contains("claude") { + continue; + } + + let transcript_path = projects_dir + .join(encode_cwd(&meta.cwd)) + .join(format!("{}.jsonl", meta.session_id)); + + let summary = parse_transcript(&transcript_path, transcript_offsets); + + let status = derive_status(&transcript_path, &summary); + let project_name = project_name_from_cwd(&meta.cwd); + + // Mem + children come from sysinfo. + let mem_mb = proc.rss_kb / 1024; + let mut children = Vec::new(); + for child_pid in sys.descendants(meta.pid) { + if let Some(cp) = sys.get(child_pid) { + children.push(ChildProcess { + pid: child_pid, + command: short_cmd(&cp.cmd, &cp.name), + mem_kb: cp.rss_kb, + port: ports_by_pid.get(&child_pid).copied(), + }); + } + } + // Sort children by RSS desc so the biggest is first. + children.sort_by(|a, b| b.mem_kb.cmp(&a.mem_kb)); + + let context_window = summary + .last_model + .as_deref() + .and_then(context_window_for); + let context_percent = context_window.and_then(|w| { + if w == 0 { + None + } else { + let used = summary.input_tokens + summary.cache_read_tokens; + Some((used as f64 / w as f64).clamp(0.0, 1.0)) + } + }); + + out.push(LiveSession { + agent_id: "claude_code", + pid: meta.pid, + session_id: meta.session_id, + cwd: meta.cwd, + project_name, + started_at_ms: meta.started_at, + status, + model: summary.last_model.unwrap_or_default(), + context_percent, + context_window, + current_task: summary.current_task, + input_tokens: summary.input_tokens, + output_tokens: summary.output_tokens, + cache_read_tokens: summary.cache_read_tokens, + cache_creation_tokens: summary.cache_creation_tokens, + mem_mb, + children, + subagents: Vec::new(), // filled in by Scraper::tick + }); + } + + // Drop offsets for transcripts whose session no longer appears (so the + // map doesn't grow unboundedly across long sessions of the dashboard). + let live_paths: std::collections::HashSet = out + .iter() + .map(|s| { + home.join(".claude") + .join("projects") + .join(encode_cwd(&s.cwd)) + .join(format!("{}.jsonl", s.session_id)) + }) + .collect(); + transcript_offsets.retain(|p, _| live_paths.contains(p)); + + // Newest-first. + out.sort_by_key(|s| std::cmp::Reverse(s.started_at_ms)); + out +} + +/// Cumulative parse result that gets merged with prior-tick state via the +/// offset map. +#[derive(Default)] +pub(crate) struct TranscriptSummary { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_creation_tokens: u64, + pub last_model: Option, + pub current_task: String, + pub last_line_kind: LastLineKind, + pub had_data: bool, +} + +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) enum LastLineKind { + #[default] + Unknown, + /// Last entry was a user message or tool_result — model is generating next. + UserOrToolResult, + /// Last entry was an assistant turn with at least one tool_use block that + /// has no matching tool_result yet → a tool is executing. + AssistantWithPendingTool, + /// Last entry was an assistant turn whose tool_use blocks were all + /// followed by matching tool_results, or that had no tool_use blocks. + AssistantSettled, +} + +pub(crate) fn parse_transcript( + path: &Path, + offsets: &mut HashMap, +) -> TranscriptSummary { + let mut summary = TranscriptSummary::default(); + + let Ok(content) = fs::read_to_string(path) else { + return summary; + }; + + // Offset tracking: if the file shrank (rotation), restart from 0. + let prev_offset = offsets.get(path).copied().unwrap_or(0); + let total_len = content.len() as u64; + let _start_offset = if prev_offset > total_len { + 0 + } else { + prev_offset + }; + + // For correctness we always re-scan the *whole* file (we need cumulative + // totals across the conversation). The offset tracking still saves work + // because we record it for future ticks where we may add an incremental + // append-only branch, but cumulative summaries are cheap (transcripts + // are typically < a few MB). + // + // NOTE: We intentionally do NOT skip to the prior offset — Claude Code + // emits cumulative `usage` per turn, not deltas, so re-scanning is safer. + offsets.insert(path.to_path_buf(), total_len); + + // Track open tool_uses by id so we can detect "assistant turn with + // pending tool" (= Executing). + let mut pending_tool_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut last_line_kind = LastLineKind::Unknown; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + summary.had_data = true; + + let entry_type = value.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match entry_type { + "assistant" => { + last_line_kind = LastLineKind::AssistantSettled; + let message = value.get("message"); + + // Model name (latest wins). + if let Some(model) = message.and_then(|m| m.get("model")).and_then(|v| v.as_str()) + { + summary.last_model = Some(model.to_string()); + } + + // Token usage (cumulative for the session). + if let Some(usage) = message.and_then(|m| m.get("usage")) { + summary.input_tokens += usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + summary.output_tokens += usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + summary.cache_read_tokens += usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + summary.cache_creation_tokens += usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + } + + // Walk content blocks for the most recent tool_use. + if let Some(blocks) = message + .and_then(|m| m.get("content")) + .and_then(|v| v.as_array()) + { + for block in blocks { + let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if block_type == "tool_use" { + let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let arg = first_meaningful_arg(block.get("input")); + summary.current_task = if arg.is_empty() { + name.to_string() + } else { + format!("{} {}", name, arg) + }; + if let Some(id) = block.get("id").and_then(|v| v.as_str()) { + pending_tool_ids.insert(id.to_string()); + last_line_kind = LastLineKind::AssistantWithPendingTool; + } + } + } + } + } + "user" => { + last_line_kind = LastLineKind::UserOrToolResult; + // Mark any tool_results as resolving pending tool_uses. + if let Some(blocks) = value + .get("message") + .and_then(|m| m.get("content")) + .and_then(|v| v.as_array()) + { + for block in blocks { + if block.get("type").and_then(|v| v.as_str()) == Some("tool_result") + && let Some(id) = + block.get("tool_use_id").and_then(|v| v.as_str()) + { + pending_tool_ids.remove(id); + } + } + } + } + _ => {} + } + } + + // If we ended on an assistant turn but all tools have settled, reflect that. + if last_line_kind == LastLineKind::AssistantWithPendingTool && pending_tool_ids.is_empty() { + last_line_kind = LastLineKind::AssistantSettled; + } + + summary.last_line_kind = last_line_kind; + summary +} + +fn derive_status(transcript_path: &Path, summary: &TranscriptSummary) -> SessionStatus { + if !summary.had_data { + return SessionStatus::Waiting; + } + + let stale = fs::metadata(transcript_path) + .and_then(|m| m.modified()) + .map(|t| { + SystemTime::now() + .duration_since(t) + .map(|d| d.as_secs() > STALE_MTIME_SECS) + .unwrap_or(true) + }) + .unwrap_or(true); + + if stale { + return SessionStatus::Waiting; + } + + match summary.last_line_kind { + LastLineKind::AssistantWithPendingTool => SessionStatus::Executing, + LastLineKind::UserOrToolResult => SessionStatus::Thinking, + LastLineKind::AssistantSettled => SessionStatus::Waiting, + LastLineKind::Unknown => SessionStatus::Waiting, + } +} + +/// Extract a short meaningful arg from a tool's input — file_path, command +/// prefix, pattern, etc. Falls back to empty string. +fn first_meaningful_arg(input: Option<&serde_json::Value>) -> String { + let Some(input) = input.and_then(|v| v.as_object()) else { + return String::new(); + }; + + for key in ["file_path", "command", "pattern", "path", "url", "query"] { + if let Some(v) = input.get(key).and_then(|v| v.as_str()) { + return truncate(v, 60); + } + } + String::new() +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +/// Claude Code encodes `/Users/foo/bar` as `-Users-foo-bar` for the +/// per-project transcript directory. +pub(crate) fn encode_cwd(cwd: &str) -> String { + cwd.replace('/', "-") +} + +fn project_name_from_cwd(cwd: &str) -> String { + Path::new(cwd) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| cwd.to_string()) +} + +fn short_cmd(cmd: &str, name: &str) -> String { + // Prefer the full cmd if it's short enough, else fall back to name. + if cmd.is_empty() { + return name.to_string(); + } + let first = cmd.split_whitespace().next().unwrap_or(""); + let basename = Path::new(first) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| first.to_string()); + if basename.is_empty() { + name.to_string() + } else { + basename + } +} + +#[allow(dead_code)] +pub(crate) fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + #[test] + fn encode_cwd_replaces_slashes() { + assert_eq!(encode_cwd("/Users/foo/bar"), "-Users-foo-bar"); + assert_eq!(encode_cwd("/a"), "-a"); + } + + #[test] + fn parse_transcript_sums_usage_across_turns() { + let dir = tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let mut f = fs::File::create(&path).unwrap(); + writeln!( + f, + r#"{{"type":"user","message":{{"content":"hi"}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"type":"assistant","message":{{"model":"claude-opus-4-5","usage":{{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":200,"cache_creation_input_tokens":10}},"content":[{{"type":"text","text":"ok"}}]}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"type":"assistant","message":{{"model":"claude-opus-4-5","usage":{{"input_tokens":120,"output_tokens":80,"cache_read_input_tokens":300,"cache_creation_input_tokens":0}},"content":[{{"type":"tool_use","id":"t1","name":"Edit","input":{{"file_path":"src/main.rs"}}}}]}}}}"# + ) + .unwrap(); + + let mut offsets = HashMap::new(); + let s = parse_transcript(&path, &mut offsets); + assert_eq!(s.input_tokens, 220); + assert_eq!(s.output_tokens, 130); + assert_eq!(s.cache_read_tokens, 500); + assert_eq!(s.cache_creation_tokens, 10); + assert_eq!(s.last_model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(s.current_task, "Edit src/main.rs"); + assert_eq!(s.last_line_kind, LastLineKind::AssistantWithPendingTool); + assert_eq!(offsets.get(&path).copied(), Some(fs::metadata(&path).unwrap().len())); + } + + #[test] + fn parse_transcript_settles_when_tool_result_arrives() { + let dir = tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let mut f = fs::File::create(&path).unwrap(); + writeln!( + f, + r#"{{"type":"assistant","message":{{"model":"sonnet","usage":{{"input_tokens":1,"output_tokens":1}},"content":[{{"type":"tool_use","id":"t1","name":"Read","input":{{"file_path":"a"}}}}]}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"type":"user","message":{{"content":[{{"type":"tool_result","tool_use_id":"t1","content":"ok"}}]}}}}"# + ) + .unwrap(); + let mut offsets = HashMap::new(); + let s = parse_transcript(&path, &mut offsets); + assert_eq!(s.last_line_kind, LastLineKind::UserOrToolResult); + } + + #[test] + fn parse_transcript_resets_on_file_shrink() { + let dir = tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let mut offsets = HashMap::new(); + offsets.insert(path.clone(), 999_999); + // Tiny file → prev offset > len → we still parse cleanly from start. + fs::write(&path, r#"{"type":"assistant","message":{"model":"x","usage":{"input_tokens":5,"output_tokens":5}}}"#).unwrap(); + let s = parse_transcript(&path, &mut offsets); + assert_eq!(s.input_tokens, 5); + assert_eq!(s.output_tokens, 5); + } + + #[test] + fn parse_transcript_missing_file_returns_default() { + let mut offsets = HashMap::new(); + let s = parse_transcript(Path::new("/nonexistent/path.jsonl"), &mut offsets); + assert!(!s.had_data); + assert_eq!(s.input_tokens, 0); + } +} diff --git a/src/scraper/host.rs b/src/scraper/host.rs new file mode 100644 index 0000000..1e24e44 --- /dev/null +++ b/src/scraper/host.rs @@ -0,0 +1,71 @@ +//! System-wide CPU%, MEM%, and 1-minute load average. +//! +//! Cross-platform via `sysinfo`. CPU% is a stateful delta between ticks, so we +//! need a long-lived `HostSampler` rather than ad-hoc snapshots. + +use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind, System}; + +use super::HostMetrics; + +pub struct HostSampler { + sys: System, + initialized: bool, +} + +impl Default for HostSampler { + fn default() -> Self { + Self::new() + } +} + +impl HostSampler { + pub fn new() -> Self { + let refresh = RefreshKind::nothing() + .with_cpu(CpuRefreshKind::nothing().with_cpu_usage()) + .with_memory(MemoryRefreshKind::nothing().with_ram()); + let sys = System::new_with_specifics(refresh); + Self { + sys, + initialized: false, + } + } + + pub fn sample(&mut self) -> HostMetrics { + // CPU usage requires two refreshes spaced by at least + // `MINIMUM_CPU_UPDATE_INTERVAL` to compute a delta. The first call + // returns 0.0; subsequent calls give real numbers. + self.sys + .refresh_cpu_specifics(CpuRefreshKind::nothing().with_cpu_usage()); + self.sys + .refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram()); + + let cpu_pct = if self.initialized { + let cpus = self.sys.cpus(); + if cpus.is_empty() { + 0.0 + } else { + let sum: f32 = cpus.iter().map(|c| c.cpu_usage()).sum(); + (sum / cpus.len() as f32) as f64 + } + } else { + self.initialized = true; + 0.0 + }; + + let total = self.sys.total_memory(); + let used = self.sys.used_memory(); + let mem_pct = if total == 0 { + 0.0 + } else { + (used as f64 / total as f64) * 100.0 + }; + + let load1 = System::load_average().one; + + HostMetrics { + cpu_pct, + mem_pct, + load1, + } + } +} diff --git a/src/scraper/mod.rs b/src/scraper/mod.rs new file mode 100644 index 0000000..cbec7db --- /dev/null +++ b/src/scraper/mod.rs @@ -0,0 +1,289 @@ +//! Local file/process scraper — the non-OTLP side of agenttop. +//! +//! While the OTLP receiver gives us aggregate/historical metrics, the scraper +//! answers "what is each agent doing *right now*" — borrowed from abtop's +//! design but adapted to be cross-platform (sysinfo instead of /proc). +//! +//! Architecture: +//! - The `Scraper` is owned by the TUI `App` and ticked alongside the +//! storage refresh. +//! - Each tick produces a `ScraperSnapshot` that the UI renders. +//! - State carried across ticks: file offsets per transcript (incremental +//! parsing), tracked ports (orphan detection), cached agent rate limits. +//! +//! Submodules: +//! - `process` — sysinfo-backed process tree + RSS +//! - `host` — system-wide CPU% / MEM% / load1 +//! - `ports` — listening ports + orphan tracking +//! - `claude_sessions` — live Claude Code sessions from ~/.claude +//! - `subagents` — Claude Code subagent state +//! - `rate_limits` — sidecar file written by the StatusLine hook + +use std::collections::HashMap; +use std::path::PathBuf; + +pub mod claude_sessions; +pub mod host; +pub mod ports; +pub mod process; +pub mod rate_limits; +pub mod subagents; + +/// Live status of an agent session, derived from transcript freshness and +/// rate-limit promotion. Mirrors abtop's `SessionStatus` so users moving +/// between the two tools see consistent labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionStatus { + /// The model is generating its next response (last transcript line is a + /// user/tool_result and recent). + Thinking, + /// A tool is executing (last assistant turn has an unmatched `tool_use`). + Executing, + /// Idle — waiting for the user or a permission prompt. + Waiting, + /// Promoted from Waiting when account-level rate limits are at 100%. + RateLimited, + /// The owning process is gone. Reserved for future use — current scrapers + /// drop dead sessions from the live list immediately. + #[allow(dead_code)] + Done, +} + +impl SessionStatus { + pub fn label(self) -> &'static str { + match self { + SessionStatus::Thinking => "Thinking", + SessionStatus::Executing => "Executing", + SessionStatus::Waiting => "Waiting", + SessionStatus::RateLimited => "RateLimited", + SessionStatus::Done => "Done", + } + } +} + +#[derive(Debug, Clone)] +pub struct ChildProcess { + pub pid: u32, + pub command: String, + pub mem_kb: u64, + pub port: Option, +} + +#[derive(Debug, Clone)] +pub struct SubAgent { + pub name: String, + pub status: String, + pub tokens: u64, +} + +#[derive(Debug, Clone)] +pub struct LiveSession { + /// Which agent CLI: "claude_code", "codex", "gemini_cli", … + pub agent_id: &'static str, + /// Owning process id — kept for future kill/inspect actions. + #[allow(dead_code)] + pub pid: u32, + pub session_id: String, + pub cwd: String, + pub project_name: String, + /// Unix epoch ms — when the underlying session was created. + pub started_at_ms: u64, + pub status: SessionStatus, + pub model: String, + /// 0.0–1.0; `None` when the model is unrecognized or no tokens recorded yet. + pub context_percent: Option, + /// Total context window in tokens (lookup table). + #[allow(dead_code)] + pub context_window: Option, + /// Last tool invocation in human-readable form (e.g. `"Edit src/main.rs"`). + pub current_task: String, + /// Cumulative input tokens for the session (read from transcript). + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_creation_tokens: u64, + /// RSS of the owning process in MB. + pub mem_mb: u64, + /// All descendant processes (with their RSS and any open port). + pub children: Vec, + /// Subagents (Claude Code only; empty otherwise). + pub subagents: Vec, +} + +/// A port still bound to a live PID whose parent agent session has gone away. +#[derive(Debug, Clone)] +pub struct OrphanPort { + pub port: u16, + pub pid: u32, + pub command: String, + /// Session id that originally owned this port — for "blame". Currently + /// not surfaced in the UI but tracked so we can show it in a future + /// detail view. + #[allow(dead_code)] + pub origin_session_id: String, +} + +/// Account-level rate-limit snapshot. Currently only Claude Code populates this. +#[derive(Debug, Clone, Default)] +pub struct RateLimitInfo { + pub source: String, + pub five_hour_pct: Option, + pub five_hour_resets_at: Option, + pub seven_day_pct: Option, + pub seven_day_resets_at: Option, + /// Unix epoch seconds — when the sidecar was last written. Tracked for + /// staleness display in a future "data age" indicator. + #[allow(dead_code)] + pub updated_at: Option, +} + +impl RateLimitInfo { + pub fn is_at_limit(&self) -> bool { + self.five_hour_pct.unwrap_or(0.0) >= 99.0 || self.seven_day_pct.unwrap_or(0.0) >= 99.0 + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct HostMetrics { + pub cpu_pct: f64, + pub mem_pct: f64, + pub load1: f64, +} + +#[derive(Debug, Clone, Default)] +pub struct ScraperSnapshot { + pub live_sessions: Vec, + pub orphan_ports: Vec, + pub rate_limits: Vec, + pub host_metrics: HostMetrics, +} + +/// Scraper owns the cross-tick state that allows incremental parsing and +/// orphan-port detection. One instance per `App`. +pub struct Scraper { + sys: process::ProcessScanner, + host_sampler: host::HostSampler, + /// Per-transcript file offsets so we only read appended bytes. + transcript_offsets: HashMap, + /// PIDs that hold a port → (port, owning session id at time of discovery). + /// Used to flag orphans when the owning session disappears. + tracked_port_children: HashMap, + /// Tick counter for slow-cycle scans (ports, git). + tick_count: u32, + /// Cached port snapshot from the last slow tick. + cached_ports: HashMap, +} + +const SLOW_TICK_EVERY: u32 = 50; // ~5s when refresh runs every 100ms + +impl Default for Scraper { + fn default() -> Self { + Self::new() + } +} + +impl Scraper { + pub fn new() -> Self { + Self { + sys: process::ProcessScanner::new(), + host_sampler: host::HostSampler::new(), + transcript_offsets: HashMap::new(), + tracked_port_children: HashMap::new(), + tick_count: SLOW_TICK_EVERY, // force a slow tick on first call + cached_ports: HashMap::new(), + } + } + + pub fn tick(&mut self) -> ScraperSnapshot { + let slow_tick = self.tick_count >= SLOW_TICK_EVERY; + if slow_tick { + self.tick_count = 0; + } + self.tick_count += 1; + + // 1. Refresh process tree. + self.sys.refresh(); + + // 2. Refresh ports on slow tick only (lsof is expensive). + if slow_tick { + self.cached_ports = ports::scan_listening_ports(); + } + + // 3. Scrape live Claude Code sessions (the only agent we can scrape + // directly today — others are OTLP-only). + let mut live_sessions = + claude_sessions::scan(&self.sys, &self.cached_ports, &mut self.transcript_offsets); + + // 4. Enrich with subagents (cheap — only reads files for live sessions). + for session in &mut live_sessions { + session.subagents = subagents::for_session(&session.session_id, &session.cwd); + } + + // 5. Rate limits from the Claude StatusLine sidecar file. + let rate_limits = rate_limits::read_all(); + + // 6. Promote Waiting → RateLimited when at-limit. + let any_at_limit = rate_limits.iter().any(|r| r.is_at_limit()); + if any_at_limit { + for session in &mut live_sessions { + if session.status == SessionStatus::Waiting && session.agent_id == "claude_code" { + session.status = SessionStatus::RateLimited; + } + } + } + + // 7. Orphan-port detection — refresh tracked map from live sessions, + // flag PIDs that still hold a port but whose owning session is gone. + let orphan_ports = self.detect_orphan_ports(&live_sessions); + + // 8. Host vitals. + let host_metrics = self.host_sampler.sample(); + + ScraperSnapshot { + live_sessions, + orphan_ports, + rate_limits, + host_metrics, + } + } + + fn detect_orphan_ports(&mut self, live_sessions: &[LiveSession]) -> Vec { + // Build set of live child PIDs and the session they belong to. + let mut live_session_ids = std::collections::HashSet::new(); + for s in live_sessions { + live_session_ids.insert(s.session_id.clone()); + for child in &s.children { + if let Some(port) = child.port { + self.tracked_port_children.insert( + child.pid, + (port, child.command.clone(), s.session_id.clone()), + ); + } + } + } + + // An orphan = a tracked port-holder whose original session_id is no + // longer in the live set, and whose PID is still alive. + let mut orphans = Vec::new(); + let mut to_remove = Vec::new(); + for (&pid, (port, command, origin)) in &self.tracked_port_children { + if !self.sys.is_alive(pid) { + to_remove.push(pid); + continue; + } + if !live_session_ids.contains(origin) { + orphans.push(OrphanPort { + port: *port, + pid, + command: command.clone(), + origin_session_id: origin.clone(), + }); + } + } + for pid in to_remove { + self.tracked_port_children.remove(&pid); + } + orphans + } +} + diff --git a/src/scraper/ports.rs b/src/scraper/ports.rs new file mode 100644 index 0000000..6d65b76 --- /dev/null +++ b/src/scraper/ports.rs @@ -0,0 +1,106 @@ +//! Listening-port discovery via `lsof`. +//! +//! Returns a `PID -> port` map for every TCP listener on the host. If `lsof` +//! isn't installed (some minimal containers, Windows), returns an empty map — +//! the rest of the scraper handles a missing port table gracefully. +//! +//! Orphan-port detection lives in `scraper::mod` because it requires +//! cross-tick state. This file only provides the live snapshot. + +use std::collections::HashMap; +use std::process::Command; + +pub fn scan_listening_ports() -> HashMap { + let output = Command::new("lsof") + .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpn"]) + .output(); + + let Ok(output) = output else { + return HashMap::new(); + }; + if !output.status.success() { + return HashMap::new(); + } + + parse_lsof_f(&String::from_utf8_lossy(&output.stdout)) +} + +/// Parse `lsof -F pn` field output. The `-F pn` flag emits one field per +/// line, prefixed by `p` (pid) or `n` (name). Format example: +/// +/// ```text +/// p12345 +/// n*:8000 +/// p67890 +/// n127.0.0.1:5432 +/// ``` +fn parse_lsof_f(text: &str) -> HashMap { + let mut out = HashMap::new(); + let mut current_pid: Option = None; + for line in text.lines() { + let line = line.trim(); + let Some(prefix) = line.chars().next() else { + continue; + }; + let rest = &line[1..]; + match prefix { + 'p' => current_pid = rest.parse::().ok(), + 'n' => { + let Some(pid) = current_pid else { continue }; + if let Some(port) = extract_port(rest) { + // Don't overwrite an existing entry — keep the first port + // we see per PID (avoids flapping when a process listens + // on many ports). + out.entry(pid).or_insert(port); + } + } + _ => {} + } + } + out +} + +/// Extract the port from an lsof `n` field like `*:8000`, `127.0.0.1:5432`, +/// `[::1]:8080`, or `*:http`. +fn extract_port(name: &str) -> Option { + let last_colon = name.rfind(':')?; + let port_str = &name[last_colon + 1..]; + let port_str = port_str.split_whitespace().next().unwrap_or(port_str); + port_str.parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_lsof_f_field_output() { + let sample = "p1234\nn*:8000\np5678\nn127.0.0.1:5432\np9999\nn[::1]:8080\n"; + let map = parse_lsof_f(sample); + assert_eq!(map.get(&1234).copied(), Some(8000)); + assert_eq!(map.get(&5678).copied(), Some(5432)); + assert_eq!(map.get(&9999).copied(), Some(8080)); + } + + #[test] + fn keeps_first_port_per_pid() { + let sample = "p1\nn*:80\nn*:443\n"; + let map = parse_lsof_f(sample); + assert_eq!(map.get(&1).copied(), Some(80)); + } + + #[test] + fn skips_symbolic_service_names() { + let sample = "p1\nn*:http\n"; + let map = parse_lsof_f(sample); + assert!(!map.contains_key(&1)); + } + + #[test] + fn extract_port_handles_ipv6() { + assert_eq!(extract_port("[::1]:8080"), Some(8080)); + assert_eq!(extract_port("*:1234"), Some(1234)); + assert_eq!(extract_port("127.0.0.1:5432"), Some(5432)); + assert_eq!(extract_port("*:http"), None); + } +} diff --git a/src/scraper/process.rs b/src/scraper/process.rs new file mode 100644 index 0000000..87399c8 --- /dev/null +++ b/src/scraper/process.rs @@ -0,0 +1,148 @@ +//! Process enumeration via `sysinfo` — replaces shelling out to `ps`. +//! +//! Cross-platform: works on macOS and Linux equally. Holds a `System` instance +//! between ticks so refreshes are incremental. + +use std::collections::HashMap; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; + +#[derive(Debug, Clone)] +pub struct ProcInfo { + #[allow(dead_code)] + pub pid: u32, + #[allow(dead_code)] + pub ppid: u32, + /// Argv joined with spaces — display string. + pub cmd: String, + /// Program name (last component of argv[0]). + pub name: String, + pub rss_kb: u64, + #[allow(dead_code)] + pub cwd: Option, +} + +pub struct ProcessScanner { + sys: System, + /// PID → ProcInfo for the current snapshot. + cache: HashMap, + /// PPID → child PIDs. + children_by_ppid: HashMap>, +} + +impl Default for ProcessScanner { + fn default() -> Self { + Self::new() + } +} + +impl ProcessScanner { + pub fn new() -> Self { + let refresh = RefreshKind::nothing().with_processes( + ProcessRefreshKind::nothing() + .with_cmd(sysinfo::UpdateKind::Always) + .with_memory() + .with_cwd(sysinfo::UpdateKind::Always), + ); + let mut sys = System::new_with_specifics(refresh); + sys.refresh_specifics(refresh); + + let mut scanner = Self { + sys, + cache: HashMap::new(), + children_by_ppid: HashMap::new(), + }; + scanner.rebuild_caches(); + scanner + } + + pub fn refresh(&mut self) { + self.sys.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing() + .with_cmd(sysinfo::UpdateKind::Always) + .with_memory() + .with_cwd(sysinfo::UpdateKind::Always), + ); + self.rebuild_caches(); + } + + fn rebuild_caches(&mut self) { + self.cache.clear(); + self.children_by_ppid.clear(); + + for (pid, proc) in self.sys.processes() { + let pid_u32 = pid.as_u32(); + let ppid_u32 = proc.parent().map(|p| p.as_u32()).unwrap_or(0); + let cmd = proc + .cmd() + .iter() + .map(|s| s.to_string_lossy().into_owned()) + .collect::>() + .join(" "); + let name = proc.name().to_string_lossy().into_owned(); + let cwd = proc + .cwd() + .map(|p| p.to_string_lossy().into_owned()); + + self.cache.insert( + pid_u32, + ProcInfo { + pid: pid_u32, + ppid: ppid_u32, + cmd, + name, + rss_kb: proc.memory() / 1024, + cwd, + }, + ); + self.children_by_ppid.entry(ppid_u32).or_default().push(pid_u32); + } + } + + pub fn is_alive(&self, pid: u32) -> bool { + self.cache.contains_key(&pid) + } + + pub fn get(&self, pid: u32) -> Option<&ProcInfo> { + self.cache.get(&pid) + } + + /// Return all PIDs in the snapshot whose command-line contains `needle`. + /// Used by future code paths that want to discover agents by process + /// name rather than session-file presence. + #[allow(dead_code)] + pub fn find_by_cmd_substring(&self, needle: &str) -> Vec { + self.cache + .values() + .filter(|p| p.cmd.contains(needle) || p.name.contains(needle)) + .map(|p| p.pid) + .collect() + } + + /// Recursively collect all descendants of `pid` (not including `pid` itself). + pub fn descendants(&self, pid: u32) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![pid]; + while let Some(current) = stack.pop() { + if let Some(kids) = self.children_by_ppid.get(¤t) { + for &kid in kids { + out.push(kid); + stack.push(kid); + } + } + } + out + } + + #[allow(dead_code)] + pub fn snapshot(&self) -> &HashMap { + &self.cache + } +} + +/// Convenience: convert raw sysinfo `Pid` to u32. Used in tests. +#[allow(dead_code)] +pub(crate) fn pid_to_u32(p: Pid) -> u32 { + p.as_u32() +} diff --git a/src/scraper/rate_limits.rs b/src/scraper/rate_limits.rs new file mode 100644 index 0000000..c3b3ddb --- /dev/null +++ b/src/scraper/rate_limits.rs @@ -0,0 +1,115 @@ +//! Reader for the sidecar JSON written by the StatusLine hook. +//! +//! The Claude Code StatusLine hook (installed by `agenttop --setup claude`) +//! writes `~/.claude/agenttop-rate-limits.json` on every status-bar refresh. +//! This module reads it and rejects data older than 10 minutes (stale). + +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; + +use super::RateLimitInfo; + +use crate::config::RATE_LIMIT_SIDECAR_NAME; + +const MAX_AGE_SECS: u64 = 600; // 10 minutes + +#[derive(Debug, Deserialize)] +struct SidecarFile { + #[serde(default)] + five_hour_pct: Option, + #[serde(default)] + five_hour_resets_at: Option, + #[serde(default)] + seven_day_pct: Option, + #[serde(default)] + seven_day_resets_at: Option, + #[serde(default)] + updated_at: Option, +} + +pub fn read_all() -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let path = home.join(".claude").join(RATE_LIMIT_SIDECAR_NAME); + let Ok(content) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(parsed): std::result::Result = serde_json::from_str(&content) else { + return Vec::new(); + }; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + // Use updated_at if present, else fall back to mtime. + let updated_at = parsed.updated_at.or_else(|| { + fs::metadata(&path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + }); + + if let Some(ts) = updated_at + && now.saturating_sub(ts) > MAX_AGE_SECS + { + return Vec::new(); + } + + if parsed.five_hour_pct.is_none() && parsed.seven_day_pct.is_none() { + return Vec::new(); + } + + vec![RateLimitInfo { + source: "claude".to_string(), + five_hour_pct: parsed.five_hour_pct, + five_hour_resets_at: parsed.five_hour_resets_at, + seven_day_pct: parsed.seven_day_pct, + seven_day_resets_at: parsed.seven_day_resets_at, + updated_at, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_stale_data() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let stale = now.saturating_sub(MAX_AGE_SECS + 60); + let json = serde_json::to_string(&serde_json::json!({ + "five_hour_pct": 50.0, + "seven_day_pct": 20.0, + "updated_at": stale, + })) + .unwrap(); + + let parsed: SidecarFile = serde_json::from_str(&json).unwrap(); + let age = now - parsed.updated_at.unwrap(); + assert!(age > MAX_AGE_SECS); + } + + #[test] + fn parses_well_formed_sidecar() { + let json = r#"{"five_hour_pct": 42.0, "seven_day_pct": 15.5}"#; + let parsed: SidecarFile = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.five_hour_pct, Some(42.0)); + assert_eq!(parsed.seven_day_pct, Some(15.5)); + } + + #[test] + fn ignores_unknown_fields() { + let json = r#"{"five_hour_pct": 1.0, "extra": "ignored"}"#; + let parsed: SidecarFile = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.five_hour_pct, Some(1.0)); + } +} diff --git a/src/scraper/subagents.rs b/src/scraper/subagents.rs new file mode 100644 index 0000000..7d63c8e --- /dev/null +++ b/src/scraper/subagents.rs @@ -0,0 +1,143 @@ +//! Claude Code subagent enumeration. +//! +//! Claude Code stores subagent state in +//! `~/.claude/projects/{encoded_cwd}/{sessionId}/subagents/` — one JSONL per +//! subagent plus a `.meta.json` sibling describing it. We aggregate cumulative +//! token usage from each JSONL's `usage` lines. +//! +//! Returns an empty list when the directory doesn't exist (which is the case +//! for most sessions that never spawn a subagent). + +use std::fs; +use std::path::Path; + +use serde::Deserialize; + +use super::{SubAgent, claude_sessions}; + +#[derive(Debug, Deserialize)] +struct MetaFile { + #[serde(default)] + name: Option, + #[serde(default)] + status: Option, +} + +pub fn for_session(session_id: &str, cwd: &str) -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let dir = home + .join(".claude") + .join("projects") + .join(claude_sessions::encode_cwd(cwd)) + .join(session_id) + .join("subagents"); + + let Ok(read_dir) = fs::read_dir(&dir) else { + return Vec::new(); + }; + + let mut by_name: std::collections::HashMap = std::collections::HashMap::new(); + + for entry in read_dir.flatten() { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + if file_name.ends_with(".meta.json") { + let stem = file_name.trim_end_matches(".meta.json").to_string(); + if let Ok(content) = fs::read_to_string(&path) + && let Ok(meta) = serde_json::from_str::(&content) + { + let entry = by_name.entry(stem.clone()).or_insert_with(|| SubAgent { + name: stem.clone(), + status: String::new(), + tokens: 0, + }); + if let Some(n) = meta.name { + entry.name = n; + } + if let Some(s) = meta.status { + entry.status = s; + } + } + } else if file_name.ends_with(".jsonl") { + let stem = file_name.trim_end_matches(".jsonl").to_string(); + let tokens = sum_jsonl_usage(&path); + let entry = by_name.entry(stem.clone()).or_insert_with(|| SubAgent { + name: stem, + status: String::new(), + tokens: 0, + }); + entry.tokens = tokens; + } + } + + let mut out: Vec = by_name.into_values().collect(); + out.sort_by_key(|s| std::cmp::Reverse(s.tokens)); + out +} + +fn sum_jsonl_usage(path: &Path) -> u64 { + let Ok(content) = fs::read_to_string(path) else { + return 0; + }; + let mut total = 0u64; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if let Some(usage) = value + .get("message") + .and_then(|m| m.get("usage")) + .or_else(|| value.get("usage")) + { + total += usage + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + total += usage + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + } + } + total +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn returns_empty_when_missing() { + let result = for_session("nonexistent-session", "/tmp/nonexistent-project"); + assert!(result.is_empty()); + } + + #[test] + fn sums_usage_across_lines() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("agent.jsonl"); + let mut f = fs::File::create(&path).unwrap(); + writeln!( + f, + r#"{{"message":{{"usage":{{"input_tokens":10,"output_tokens":5}}}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"message":{{"usage":{{"input_tokens":20,"output_tokens":3}}}}}}"# + ) + .unwrap(); + let total = sum_jsonl_usage(&path); + assert_eq!(total, 38); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index ec457c2..ff115f3 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -125,6 +125,33 @@ pub struct ApiMetrics { pub models: HashMap, } +/// Session info from OTLP telemetry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub session_id: String, + pub event_count: u64, + pub first_seen: Option>, + pub last_seen: Option>, +} + +/// Project info resolved from session data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectInfo { + pub name: String, + pub event_count: u64, + pub first_seen: Option>, + pub last_seen: Option>, +} + +/// Aggregated stats from claude_code.compaction events. +#[derive(Debug, Clone, Default)] +pub struct CompactionStats { + pub count: u64, + pub last_seen: Option>, + pub last_pre_tokens: Option, + pub last_post_tokens: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolEvent { pub timestamp: DateTime, @@ -177,6 +204,27 @@ enum StorageCommand { since: Option>, tx: mpsc::Sender>, }, + GetDistinctProjects { + since: Option>, + tx: mpsc::Sender>>, + }, + GetDistinctSessions { + since: Option>, + tx: mpsc::Sender>>, + }, + GetCompactionStats { + since: Option>, + tx: mpsc::Sender>, + }, + GetDistinctServiceNames { + since: Option>, + tx: mpsc::Sender>>, + }, + GetTokenRateSeries { + window_secs: u64, + points: usize, + tx: mpsc::Sender>>, + }, Shutdown, } @@ -277,6 +325,55 @@ impl StorageHandle { .send(StorageCommand::GetApiMetrics { since, tx })?; rx.recv()? } + + #[allow(dead_code)] + pub fn get_distinct_projects(&self, since: Option>) -> Result> { + let (tx, rx) = mpsc::channel(); + self.sender + .send(StorageCommand::GetDistinctProjects { since, tx })?; + rx.recv()? + } + + pub fn get_distinct_sessions(&self, since: Option>) -> Result> { + let (tx, rx) = mpsc::channel(); + self.sender + .send(StorageCommand::GetDistinctSessions { since, tx })?; + rx.recv()? + } + + pub fn get_compaction_stats(&self, since: Option>) -> Result { + let (tx, rx) = mpsc::channel(); + self.sender + .send(StorageCommand::GetCompactionStats { since, tx })?; + rx.recv()? + } + + pub fn get_distinct_service_names( + &self, + since: Option>, + ) -> Result> { + let (tx, rx) = mpsc::channel(); + self.sender + .send(StorageCommand::GetDistinctServiceNames { since, tx })?; + rx.recv()? + } + + /// Tokens-per-second over the most recent `window_secs`, bucketed into + /// `points` equal-width slots. Returned Vec is always length `points` + /// (zero-padded at the front if there's no data). + pub fn get_token_rate_series( + &self, + window_secs: u64, + points: usize, + ) -> Result> { + let (tx, rx) = mpsc::channel(); + self.sender.send(StorageCommand::GetTokenRateSeries { + window_secs, + points, + tx, + })?; + rx.recv()? + } } fn run_storage_actor(storage: Storage, receiver: mpsc::Receiver) -> Result<()> { @@ -322,6 +419,25 @@ fn run_storage_actor(storage: Storage, receiver: mpsc::Receiver) StorageCommand::GetApiMetrics { since, tx } => { let _ = tx.send(storage.get_api_metrics(since)); } + StorageCommand::GetDistinctProjects { since, tx } => { + let _ = tx.send(storage.get_distinct_projects(since)); + } + StorageCommand::GetDistinctSessions { since, tx } => { + let _ = tx.send(storage.get_distinct_sessions(since)); + } + StorageCommand::GetCompactionStats { since, tx } => { + let _ = tx.send(storage.get_compaction_stats(since)); + } + StorageCommand::GetDistinctServiceNames { since, tx } => { + let _ = tx.send(storage.get_distinct_service_names(since)); + } + StorageCommand::GetTokenRateSeries { + window_secs, + points, + tx, + } => { + let _ = tx.send(storage.get_token_rate_series(window_secs, points)); + } StorageCommand::Shutdown => break, } } @@ -751,6 +867,282 @@ impl Storage { Ok(metrics) } + + /// Distinct OTel `service.name` resource attributes seen on log events. + fn get_distinct_service_names(&self, since: Option>) -> Result> { + let time_clause = since + .map(|dt| format!("AND timestamp >= '{}'", dt.to_rfc3339())) + .unwrap_or_default(); + + let query = format!( + r#" + SELECT DISTINCT json_extract_string(attributes, '$."service.name"') as service_name + FROM log_events + WHERE json_extract_string(attributes, '$."service.name"') IS NOT NULL {time_clause} + "# + ); + + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + + let mut names = Vec::new(); + for row in rows { + names.push(row?); + } + Ok(names) + } + + /// Aggregate stats from `claude_code.compaction` log events. + fn get_compaction_stats(&self, since: Option>) -> Result { + let time_clause = since + .map(|dt| format!("AND timestamp >= '{}'", dt.to_rfc3339())) + .unwrap_or_default(); + + let count_query = format!( + r#" + SELECT COUNT(*) + FROM log_events + WHERE event_name = 'claude_code.compaction' {time_clause} + "# + ); + let count: i64 = self + .conn + .query_row(&count_query, [], |row| row.get(0)) + .unwrap_or(0); + + if count == 0 { + return Ok(CompactionStats::default()); + } + + let latest_query = format!( + r#" + SELECT + CAST(timestamp AS VARCHAR) as ts, + json_extract_string(attributes, '$.pre_tokens') as pre_tokens, + json_extract_string(attributes, '$.post_tokens') as post_tokens + FROM log_events + WHERE event_name = 'claude_code.compaction' {time_clause} + ORDER BY timestamp DESC + LIMIT 1 + "# + ); + + let parse_timestamp = |ts: String| -> Option> { + DateTime::parse_from_rfc3339(&ts) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S%.f") + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S") + }) + .ok() + .map(|naive| naive.and_utc()) + }) + }; + + let mut stats = CompactionStats { + count: count as u64, + ..Default::default() + }; + + let row = self.conn.query_row(&latest_query, [], |row| { + let ts: String = row.get(0)?; + let pre: Option = row.get(1)?; + let post: Option = row.get(2)?; + Ok((ts, pre, post)) + }); + + if let Ok((ts, pre, post)) = row { + stats.last_seen = parse_timestamp(ts); + stats.last_pre_tokens = pre.and_then(|s| s.parse::().ok()); + stats.last_post_tokens = post.and_then(|s| s.parse::().ok()); + } + + Ok(stats) + } + + /// Get distinct sessions from OTLP telemetry + fn get_distinct_sessions(&self, since: Option>) -> Result> { + let time_clause = since + .map(|dt| format!("WHERE timestamp >= '{}'", dt.to_rfc3339())) + .unwrap_or_default(); + + let query = format!( + r#" + SELECT + json_extract_string(attributes, '$.session.id') as session_id, + COUNT(*) as event_count, + CAST(MIN(timestamp) AS VARCHAR) as first_seen, + CAST(MAX(timestamp) AS VARCHAR) as last_seen + FROM log_events + {time_clause} + {} + json_extract_string(attributes, '$.session.id') IS NOT NULL + GROUP BY session_id + ORDER BY event_count DESC + "#, + if time_clause.is_empty() { + "WHERE" + } else { + "AND" + } + ); + + let mut stmt = self.conn.prepare(&query)?; + + let rows = stmt.query_map([], |row| { + let first_seen_str: Option = row.get(2)?; + let last_seen_str: Option = row.get(3)?; + + // Parse timestamps (DuckDB format or RFC3339) + let parse_timestamp = |s: Option| -> Option> { + s.and_then(|ts| { + DateTime::parse_from_rfc3339(&ts) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S%.f") + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S") + }) + .ok() + .map(|naive| naive.and_utc()) + }) + }) + }; + + Ok(SessionInfo { + session_id: row.get(0)?, + event_count: row.get::<_, i64>(1)? as u64, + first_seen: parse_timestamp(first_seen_str), + last_seen: parse_timestamp(last_seen_str), + }) + })?; + + let mut sessions = Vec::new(); + for row in rows { + sessions.push(row?); + } + Ok(sessions) + } + + /// Tokens-per-second over the most recent `window_secs`, bucketed into + /// `points` equal-width slots. We fetch raw `(timestamp, count)` rows + /// once and bucket in Rust — keeps the SQL simple and avoids DuckDB + /// interval-arithmetic syntax differences across versions. + fn get_token_rate_series(&self, window_secs: u64, points: usize) -> Result> { + if points == 0 || window_secs == 0 { + return Ok(Vec::new()); + } + + let start = Utc::now() - chrono::Duration::seconds(window_secs as i64); + let query = + "SELECT CAST(timestamp AS VARCHAR), count FROM token_usage WHERE timestamp >= ?"; + let mut stmt = self.conn.prepare(query)?; + + let rows = stmt.query_map(params![start.to_rfc3339()], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)) + })?; + + let bucket_secs = window_secs as f64 / points as f64; + let start_secs = start.timestamp() as f64; + let mut buckets = vec![0u64; points]; + + for row in rows { + let (ts_str, count) = row?; + let parsed = DateTime::parse_from_rfc3339(&ts_str) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(&ts_str, "%Y-%m-%d %H:%M:%S%.f") + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(&ts_str, "%Y-%m-%d %H:%M:%S") + }) + .ok() + .map(|n| n.and_utc()) + }); + let Some(parsed) = parsed else { continue }; + + let elapsed = parsed.timestamp() as f64 - start_secs; + if elapsed < 0.0 { + continue; + } + let idx = (elapsed / bucket_secs) as usize; + if idx < points { + buckets[idx] += count; + } + } + + Ok(buckets.into_iter().map(|c| c as f64 / bucket_secs).collect()) + } + + /// Get distinct projects detected from file paths in telemetry + /// Note: This is a legacy method kept for backward compatibility. + /// Prefer using get_distinct_sessions() and mapping via ProjectResolver. + fn get_distinct_projects(&self, since: Option>) -> Result> { + let time_clause = since + .map(|dt| format!("WHERE timestamp >= '{}'", dt.to_rfc3339())) + .unwrap_or_default(); + + let query = format!( + r#" + SELECT + json_extract_string(attributes, '$.detected.project') as project, + COUNT(*) as event_count, + CAST(MIN(timestamp) AS VARCHAR) as first_seen, + CAST(MAX(timestamp) AS VARCHAR) as last_seen + FROM log_events + {time_clause} + {} + json_extract_string(attributes, '$.detected.project') IS NOT NULL + GROUP BY project + ORDER BY event_count DESC + "#, + if time_clause.is_empty() { + "WHERE" + } else { + "AND" + } + ); + + let mut stmt = self.conn.prepare(&query)?; + + let rows = stmt.query_map([], |row| { + let first_seen_str: Option = row.get(2)?; + let last_seen_str: Option = row.get(3)?; + + // Parse timestamps (DuckDB format or RFC3339) + let parse_timestamp = |s: Option| -> Option> { + s.and_then(|ts| { + DateTime::parse_from_rfc3339(&ts) + .map(|dt| dt.with_timezone(&Utc)) + .ok() + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S%.f") + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S") + }) + .ok() + .map(|naive| naive.and_utc()) + }) + }) + }; + + Ok(ProjectInfo { + name: row.get(0)?, + event_count: row.get::<_, i64>(1)? as u64, + first_seen: parse_timestamp(first_seen_str), + last_seen: parse_timestamp(last_seen_str), + }) + })?; + + let mut projects = Vec::new(); + for row in rows { + projects.push(row?); + } + Ok(projects) + } } #[cfg(test)] diff --git a/src/tui/app.rs b/src/tui/app.rs index 1cdeb71..41bd578 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,8 +1,36 @@ use anyhow::Result; use chrono::{DateTime, Utc}; +use crate::project::ProjectResolver; use crate::providers::PROVIDER_REGISTRY; -use crate::storage::{ApiMetrics, SessionMetrics, StorageHandle, TokenMetrics, ToolMetrics}; +use crate::scraper::{Scraper, ScraperSnapshot}; + +fn humanize_age(d: chrono::Duration) -> String { + let secs = d.num_seconds().max(0); + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } +} + +fn humanize_tokens(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{}K", n / 1_000) + } else { + n.to_string() + } +} +use crate::storage::{ + ApiMetrics, CompactionStats, ProjectInfo, SessionMetrics, StorageHandle, TokenMetrics, + ToolMetrics, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TimeFilter { @@ -41,8 +69,23 @@ pub enum SortColumn { Name, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ProjectFilter { + #[default] + All, + Project(String), +} + pub struct App { storage: StorageHandle, + /// Project resolver for mapping session.id to project names + project_resolver: ProjectResolver, + /// Cross-tick scraper for live process/file state (sessions, ports, host). + scraper: Scraper, + /// Latest snapshot from the scraper, refreshed each tick. + pub scraper_snapshot: ScraperSnapshot, + /// Token-rate over the last 5 minutes, bucketed for the sparkline. + pub token_rate_series: Vec, pub tool_metrics: Vec, pub token_metrics: TokenMetrics, pub session_metrics: SessionMetrics, @@ -58,12 +101,22 @@ pub struct App { pub detected_agents: Vec, /// Currently selected agent index (for filtering display) pub selected_agent_index: usize, + /// Detected projects resolved from session data + pub detected_projects: Vec, + /// Current project filter + pub project_filter: ProjectFilter, + /// Compaction stats from claude_code.compaction events + pub compaction_stats: CompactionStats, } impl App { pub fn new(storage: StorageHandle) -> Self { Self { storage, + project_resolver: ProjectResolver::new(), + scraper: Scraper::new(), + scraper_snapshot: ScraperSnapshot::default(), + token_rate_series: Vec::new(), tool_metrics: Vec::new(), token_metrics: TokenMetrics::default(), session_metrics: SessionMetrics::default(), @@ -77,6 +130,9 @@ impl App { time_filter: TimeFilter::default(), detected_agents: Vec::new(), selected_agent_index: 0, + detected_projects: Vec::new(), + project_filter: ProjectFilter::default(), + compaction_stats: CompactionStats::default(), } } @@ -85,10 +141,77 @@ impl App { return Ok(()); } + // Scrape live process/file state — independent of OTLP, runs every tick. + self.scraper_snapshot = self.scraper.tick(); + // Token-rate sparkline: last 5 minutes bucketed into 60 points (~5s each). + self.token_rate_series = self + .storage + .get_token_rate_series(300, 60) + .unwrap_or_default(); + self.tool_metrics = self.storage.get_tool_metrics(self.time_filter.since())?; self.token_metrics = self.storage.get_token_metrics(self.time_filter.since())?; self.session_metrics = self.storage.get_session_metrics(self.time_filter.since())?; self.api_metrics = self.storage.get_api_metrics(self.time_filter.since())?; + + // Get distinct sessions and map to projects using ProjectResolver + let sessions = self + .storage + .get_distinct_sessions(self.time_filter.since())?; + + // Aggregate sessions by project name + use std::collections::HashMap; + let mut project_aggregates: HashMap = HashMap::new(); + + for session in sessions { + // Resolve session to project name, or use truncated session ID as fallback + let project_name = self + .project_resolver + .resolve(&session.session_id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| { + // Fallback: use first 8 chars of session ID + format!("session:{}", &session.session_id[..8.min(session.session_id.len())]) + }); + + let entry = project_aggregates.entry(project_name.clone()).or_insert(ProjectInfo { + name: project_name, + event_count: 0, + first_seen: session.first_seen, + last_seen: session.last_seen, + }); + + entry.event_count += session.event_count; + + // Update first_seen to earliest + if let (Some(existing), Some(new)) = (entry.first_seen, session.first_seen) { + if new < existing { + entry.first_seen = Some(new); + } + } else if entry.first_seen.is_none() { + entry.first_seen = session.first_seen; + } + + // Update last_seen to latest + if let (Some(existing), Some(new)) = (entry.last_seen, session.last_seen) { + if new > existing { + entry.last_seen = Some(new); + } + } else if entry.last_seen.is_none() { + entry.last_seen = session.last_seen; + } + } + + // Convert to sorted vector (by event_count descending) + let mut projects: Vec = project_aggregates.into_values().collect(); + projects.sort_by(|a, b| b.event_count.cmp(&a.event_count)); + self.detected_projects = projects; + + self.compaction_stats = self + .storage + .get_compaction_stats(self.time_filter.since()) + .unwrap_or_default(); + self.last_refresh = Utc::now(); // Detect agents from tool usage and model names @@ -110,6 +233,20 @@ impl App { } } + // Detect agents from OTel `service.name` resource attribute. This catches + // Cline / Copilot Chat / opencode where tool names and model patterns + // overlap with other providers. + if let Ok(service_names) = self + .storage + .get_distinct_service_names(self.time_filter.since()) + { + for service_name in service_names { + if let Some(provider) = PROVIDER_REGISTRY.find_by_service_name(&service_name) { + new_agents.push(provider.id()); + } + } + } + // Now add all detected agents for agent_id in new_agents { self.add_detected_agent(agent_id); @@ -214,11 +351,6 @@ impl App { } } - pub fn reset_stats(&mut self) { - // Clear old data and reset selection - self.selected_index = 0; - } - pub fn selected_tool(&self) -> Option<&ToolMetrics> { self.tool_metrics.get(self.selected_index) } @@ -290,6 +422,37 @@ impl App { } } + /// Format the compaction summary for the header. Returns `None` when no + /// compaction events are present in the current time window. + pub fn format_compaction_summary(&self) -> Option { + if self.compaction_stats.count == 0 { + return None; + } + + let mut parts = vec![format!("Compactions: {}", self.compaction_stats.count)]; + let mut detail = Vec::new(); + + if let Some(seen) = self.compaction_stats.last_seen { + detail.push(format!("last {}", humanize_age(Utc::now() - seen))); + } + + if let (Some(pre), Some(post)) = ( + self.compaction_stats.last_pre_tokens, + self.compaction_stats.last_post_tokens, + ) { + let saved = pre.saturating_sub(post); + if saved > 0 { + detail.push(format!("-{}", humanize_tokens(saved))); + } + } + + if !detail.is_empty() { + parts.push(format!("({})", detail.join(", "))); + } + + Some(parts.join(" ")) + } + /// Format API latency as human-readable string pub fn format_api_latency(&self) -> String { let ms = self.api_metrics.avg_latency_ms; @@ -324,4 +487,33 @@ impl App { self.detected_agents.push(agent_id.to_string()); } } + + /// Cycle through detected projects: All -> Project1 -> Project2 -> ... -> All + pub fn cycle_project(&mut self) { + if self.detected_projects.is_empty() { + self.project_filter = ProjectFilter::All; + return; + } + + self.project_filter = match &self.project_filter { + ProjectFilter::All => { + // Go to first project + ProjectFilter::Project(self.detected_projects[0].name.clone()) + } + ProjectFilter::Project(current) => { + // Find current project index and go to next, or wrap to All + let current_idx = self + .detected_projects + .iter() + .position(|p| &p.name == current); + + match current_idx { + Some(idx) if idx + 1 < self.detected_projects.len() => { + ProjectFilter::Project(self.detected_projects[idx + 1].name.clone()) + } + _ => ProjectFilter::All, + } + } + }; + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8f2bd32..8d9d3ae 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -63,7 +63,7 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> Resul KeyCode::Char('p') => app.toggle_pause(), KeyCode::Char('d') => app.toggle_detail(), KeyCode::Char('t') => app.toggle_time_filter(), - KeyCode::Char('r') => app.reset_stats(), + KeyCode::Char('r') => app.cycle_project(), KeyCode::Char('a') => app.cycle_agent(), KeyCode::Up | KeyCode::Char('k') => app.select_previous(), KeyCode::Down | KeyCode::Char('j') => app.select_next(), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 4e48cf2..a72fe9e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -3,67 +3,125 @@ use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, + symbols, text::{Line, Span}, - widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState}, + widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Sparkline, Table, TableState}, }; -use super::app::App; +use super::app::{App, ProjectFilter}; use crate::providers::PROVIDER_REGISTRY; +use crate::scraper::{HostMetrics, LiveSession, OrphanPort, RateLimitInfo, SessionStatus}; pub fn draw(f: &mut Frame, app: &App) { let has_mcp_tools = !app.mcp_tools().is_empty(); + let live_panel_height = live_panel_height(app); - let chunks = if has_mcp_tools { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header with session info - Constraint::Length(3), // Metrics bar (tokens + tools summary) - Constraint::Ratio(1, 2), // Built-in tools table (50%) - Constraint::Ratio(1, 2), // MCP tools section (50%) - Constraint::Length(1), // Footer (hotkeys only) - ]) - .split(f.area()) + // Build a layout that always has header + metrics + footer; everything + // between is optional. + let mut constraints: Vec = vec![ + Constraint::Length(3), // header + Constraint::Length(3), // metrics bar + ]; + if live_panel_height > 0 { + constraints.push(Constraint::Length(live_panel_height)); + } + if has_mcp_tools { + constraints.push(Constraint::Ratio(1, 2)); // built-in tools + constraints.push(Constraint::Ratio(1, 2)); // MCP tools } else { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Header with session info - Constraint::Length(3), // Metrics bar (tokens + tools summary) - Constraint::Min(8), // Built-in tools table - Constraint::Length(1), // Footer (hotkeys only) - ]) - .split(f.area()) - }; - - draw_header(f, app, chunks[0]); - draw_metrics_bar(f, app, chunks[1]); - draw_builtin_tool_table(f, app, chunks[2]); + constraints.push(Constraint::Min(8)); // built-in tools + } + constraints.push(Constraint::Length(1)); // footer + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(f.area()); + + let mut idx = 0; + draw_header(f, app, chunks[idx]); + idx += 1; + draw_metrics_bar(f, app, chunks[idx]); + idx += 1; + if live_panel_height > 0 { + draw_live_panel(f, app, chunks[idx]); + idx += 1; + } + draw_builtin_tool_table(f, app, chunks[idx]); + idx += 1; if has_mcp_tools { - draw_mcp_table(f, app, chunks[3]); - draw_footer(f, chunks[4]); - } else { - draw_footer(f, chunks[3]); + draw_mcp_table(f, app, chunks[idx]); + idx += 1; } + draw_footer(f, chunks[idx]); - // Draw detail popup if active if app.show_detail { draw_detail_popup(f, app); } } +/// Height of the live-state panel (sessions + quotas + orphan ports). Returns +/// 0 when there's nothing to show so we don't claim empty terminal space. +fn live_panel_height(app: &App) -> u16 { + let s = &app.scraper_snapshot; + let has_sessions = !s.live_sessions.is_empty(); + let has_rate_limits = !s.rate_limits.is_empty(); + let has_orphans = !s.orphan_ports.is_empty(); + if !has_sessions && !has_rate_limits && !has_orphans { + return 0; + } + // Sessions table: 2 rows of chrome (border + header) + 1 per session + + // 1 per session for subagents row if present. + let session_rows: u16 = s + .live_sessions + .iter() + .take(6) + .map(|s| if s.subagents.is_empty() { 1 } else { 2 }) + .sum(); + let sessions_height = if has_sessions { + session_rows.saturating_add(3) + } else { + 0 + }; + let quota_height = if has_rate_limits { 3 } else { 0 }; + let orphans_height = if has_orphans { 3 } else { 0 }; + + // Layout puts quota + sessions on the same row split horizontally — so + // we take the max of session/quota for that row, plus orphans below. + let main_row = sessions_height.max(quota_height); + main_row + orphans_height +} + fn draw_header(f: &mut Frame, app: &App, area: Rect) { let paused = if app.paused { " [PAUSED]" } else { "" }; let title = format!(" agenttop{}", paused); - // Build header right side: agent, active time, time filter let active_time = app.format_active_time(); let filter_label = app.time_filter.label(); let mut header_spans = Vec::new(); - // Add agent display if available + match &app.project_filter { + ProjectFilter::All => { + if !app.detected_projects.is_empty() { + header_spans.push(Span::styled( + "Project: ", + Style::default().fg(Color::DarkGray), + )); + header_spans.push(Span::styled("all", Style::default().fg(Color::Cyan))); + header_spans.push(Span::raw(" ")); + } + } + ProjectFilter::Project(name) => { + header_spans.push(Span::styled( + "Project: ", + Style::default().fg(Color::DarkGray), + )); + header_spans.push(Span::styled(name, Style::default().fg(Color::Cyan))); + header_spans.push(Span::raw(" ")); + } + } + if let Some(agent_id) = app.current_agent() { let agent_name = PROVIDER_REGISTRY .get(agent_id) @@ -77,7 +135,6 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { header_spans.push(Span::raw(" ")); } - // Add active time if available if active_time != "-" { header_spans.push(Span::styled( "Active: ", @@ -87,7 +144,20 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { header_spans.push(Span::raw(" ")); } - // Add time filter + if let Some(summary) = app.format_compaction_summary() { + header_spans.push(Span::styled(summary, Style::default().fg(Color::Yellow))); + header_spans.push(Span::raw(" ")); + } + + // Host vitals strip (only when sysinfo has reported something non-zero). + let host = app.scraper_snapshot.host_metrics; + if host_has_data(&host) { + for span in format_host_strip(&host) { + header_spans.push(span); + } + header_spans.push(Span::raw(" ")); + } + header_spans.push(Span::styled( format!("[{}]", filter_label), Style::default().fg(Color::DarkGray), @@ -106,7 +176,46 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { f.render_widget(paragraph, area); } +fn host_has_data(host: &HostMetrics) -> bool { + host.cpu_pct > 0.0 || host.mem_pct > 0.0 || host.load1 > 0.0 +} + +fn format_host_strip(host: &HostMetrics) -> Vec> { + let cpu_color = pct_color(host.cpu_pct); + let mem_color = pct_color(host.mem_pct); + vec![ + Span::styled("CPU ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("{:.0}%", host.cpu_pct), Style::default().fg(cpu_color)), + Span::raw(" "), + Span::styled("MEM ", Style::default().fg(Color::DarkGray)), + Span::styled(format!("{:.0}%", host.mem_pct), Style::default().fg(mem_color)), + Span::raw(" "), + Span::styled("LOAD ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{:.2}", host.load1), + Style::default().fg(Color::LightBlue), + ), + ] +} + +fn pct_color(pct: f64) -> Color { + if pct >= 90.0 { + Color::Red + } else if pct >= 70.0 { + Color::Yellow + } else { + Color::Green + } +} + fn draw_metrics_bar(f: &mut Frame, app: &App, area: Rect) { + // Split the metrics bar horizontally so we can put a sparkline on the + // right without disrupting the token/API text layout on the left. + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(40), Constraint::Length(22)]) + .split(area); + let cache_reuse = app.cache_reuse_rate(); let total_calls = app.total_tool_calls(); @@ -146,7 +255,6 @@ fn draw_metrics_bar(f: &mut Frame, app: &App, area: Rect) { Span::raw(")"), ]; - // Add LOC and Commits if available let loc = app.session_metrics.lines_of_code; let commits = app.session_metrics.commit_count; if loc != 0 || commits > 0 { @@ -180,7 +288,6 @@ fn draw_metrics_bar(f: &mut Frame, app: &App, area: Rect) { let metrics_line = Line::from(metrics_spans); - // Second line: API summary and tool stats let api_calls = app.api_metrics.total_calls; let api_errors = app.api_metrics.total_errors; let api_latency = app.format_api_latency(); @@ -206,7 +313,6 @@ fn draw_metrics_bar(f: &mut Frame, app: &App, area: Rect) { )); } - // Add model breakdown if available if !app.api_metrics.models.is_empty() { api_spans.push(Span::raw(" ")); api_spans.push(Span::styled( @@ -214,15 +320,13 @@ fn draw_metrics_bar(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::DarkGray), )); - // Sort models by count descending and format as "model (count)" let mut models: Vec<_> = app.api_metrics.models.iter().collect(); models.sort_by(|a, b| b.1.cmp(a.1)); let model_strs: Vec = models .iter() - .take(3) // Show top 3 models max + .take(3) .map(|(name, count)| { - // Shorten model names using provider registry let short_name = PROVIDER_REGISTRY.shorten_model_name(name); format!("{} ({})", short_name, count) }) @@ -246,10 +350,303 @@ fn draw_metrics_bar(f: &mut Frame, app: &App, area: Rect) { let api_line = Line::from(api_spans); - let block = Block::default().borders(Borders::LEFT | Borders::RIGHT); + let left_block = Block::default().borders(Borders::LEFT); + let left_paragraph = Paragraph::new(vec![metrics_line, api_line]).block(left_block); + f.render_widget(left_paragraph, chunks[0]); - let paragraph = Paragraph::new(vec![metrics_line, api_line]).block(block); - f.render_widget(paragraph, area); + // Right side: token-rate sparkline (tokens/sec over last 5 min). + let series: Vec = app + .token_rate_series + .iter() + .map(|v| v.max(0.0) as u64) + .collect(); + let peak = series.iter().copied().max().unwrap_or(0); + let title = if peak > 0 { + format!(" Tok/s peak {} ", peak) + } else { + " Tok/s ".to_string() + }; + let sparkline = Sparkline::default() + .block( + Block::default() + .borders(Borders::LEFT | Borders::RIGHT) + .title(title), + ) + .data(&series) + .style(Style::default().fg(Color::Cyan)) + .bar_set(symbols::bar::NINE_LEVELS); + f.render_widget(sparkline, chunks[1]); +} + +fn draw_live_panel(f: &mut Frame, app: &App, area: Rect) { + let s = &app.scraper_snapshot; + let has_sessions = !s.live_sessions.is_empty(); + let has_rate_limits = !s.rate_limits.is_empty(); + let has_orphans = !s.orphan_ports.is_empty(); + + // Vertical split: main row (sessions | quotas) and (optional) orphans + // strip below. + let vertical = if has_orphans { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(3)]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3)]) + .split(area) + }; + + let main_row = vertical[0]; + + // Horizontal split within main row. + let horizontal = if has_sessions && has_rate_limits { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(60), Constraint::Length(40)]) + .split(main_row) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(20)]) + .split(main_row) + }; + + let mut h_idx = 0; + if has_sessions { + draw_live_sessions(f, &s.live_sessions, horizontal[h_idx]); + h_idx += 1; + } + if has_rate_limits && h_idx < horizontal.len() { + draw_quota_panel(f, &s.rate_limits, horizontal[h_idx]); + } + + if has_orphans && vertical.len() > 1 { + draw_orphan_ports(f, &s.orphan_ports, vertical[1]); + } +} + +fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { + let header_cells = [ + "AGENT", "PROJECT", "STATUS", "MODEL", "CTX%", "TOKENS", "MEM", "TASK", + ] + .iter() + .map(|h| { + Cell::from(*h).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + }); + let header = Row::new(header_cells).height(1); + + let mut rows = Vec::new(); + for session in sessions.iter().take(6) { + let model_short = PROVIDER_REGISTRY.shorten_model_name(&session.model); + let ctx_str = match session.context_percent { + Some(p) => format!("{:.0}%", p * 100.0), + None => "—".to_string(), + }; + let ctx_color = match session.context_percent { + Some(p) if p >= 0.9 => Color::Red, + Some(p) if p >= 0.8 => Color::Yellow, + Some(_) => Color::Green, + None => Color::DarkGray, + }; + let tokens = humanize_u64( + session.input_tokens + + session.output_tokens + + session.cache_read_tokens + + session.cache_creation_tokens, + ); + let mem_str = format!("{} MB", session.mem_mb); + let task = if session.current_task.is_empty() { + "—".to_string() + } else { + session.current_task.clone() + }; + + let status_color = status_color(session.status); + rows.push(Row::new(vec![ + Cell::from(session.agent_id.replace('_', " ")), + Cell::from(session.project_name.clone()), + Cell::from(session.status.label()).style(Style::default().fg(status_color)), + Cell::from(model_short), + Cell::from(ctx_str).style(Style::default().fg(ctx_color)), + Cell::from(tokens), + Cell::from(mem_str), + Cell::from(task), + ])); + + if !session.subagents.is_empty() { + let labels: Vec = session + .subagents + .iter() + .take(4) + .map(|sa| { + let status = if sa.status.is_empty() { + "".to_string() + } else { + format!(" [{}]", sa.status) + }; + format!("{}{} {}", sa.name, status, humanize_u64(sa.tokens)) + }) + .collect(); + let detail = format!(" └─ subagents: {}", labels.join(", ")); + rows.push(Row::new(vec![ + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(detail).style(Style::default().fg(Color::DarkGray)), + ])); + } + } + + let table = Table::new( + rows, + [ + Constraint::Length(12), // AGENT + Constraint::Length(18), // PROJECT + Constraint::Length(11), // STATUS + Constraint::Length(10), // MODEL + Constraint::Length(5), // CTX% + Constraint::Length(8), // TOKENS + Constraint::Length(8), // MEM + Constraint::Min(20), // TASK + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Live sessions ") + .border_style(Style::default().fg(Color::Green)), + ); + + f.render_widget(table, area); +} + +fn status_color(s: SessionStatus) -> Color { + match s { + SessionStatus::Thinking => Color::Cyan, + SessionStatus::Executing => Color::Green, + SessionStatus::Waiting => Color::DarkGray, + SessionStatus::RateLimited => Color::Red, + SessionStatus::Done => Color::DarkGray, + } +} + +fn draw_quota_panel(f: &mut Frame, rate_limits: &[RateLimitInfo], area: Rect) { + let mut lines = Vec::new(); + for rl in rate_limits { + let header = Span::styled( + format!("{} ", capitalize(&rl.source)), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + lines.push(Line::from(vec![header])); + + if let Some(pct) = rl.five_hour_pct { + lines.push(Line::from(quota_row("5h", pct, rl.five_hour_resets_at))); + } + if let Some(pct) = rl.seven_day_pct { + lines.push(Line::from(quota_row("7d", pct, rl.seven_day_resets_at))); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .title(" Quota ") + .border_style(Style::default().fg(Color::Yellow)); + f.render_widget(Paragraph::new(lines).block(block), area); +} + +fn quota_row(label: &str, pct: f64, resets_at: Option) -> Vec> { + let pct = pct.clamp(0.0, 100.0); + let color = pct_color(pct); + let bar = quota_bar(pct, 10); + let reset = match resets_at { + Some(ts) => format!(" resets in {}", humanize_eta(ts)), + None => String::new(), + }; + vec![ + Span::raw(format!(" {} ", label)), + Span::styled(bar, Style::default().fg(color)), + Span::raw(format!(" {:.0}%", pct)), + Span::styled(reset, Style::default().fg(Color::DarkGray)), + ] +} + +fn quota_bar(pct: f64, width: usize) -> String { + let filled = ((pct / 100.0) * width as f64).round() as usize; + let filled = filled.min(width); + format!("{}{}", "█".repeat(filled), "░".repeat(width - filled)) +} + +fn humanize_eta(unix_secs: u64) -> String { + let now = chrono::Utc::now().timestamp() as u64; + if unix_secs <= now { + return "now".to_string(); + } + let secs = unix_secs - now; + if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86400 { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } else { + format!("{}d{}h", secs / 86400, (secs % 86400) / 3600) + } +} + +fn draw_orphan_ports(f: &mut Frame, orphans: &[OrphanPort], area: Rect) { + let mut spans: Vec> = vec![Span::styled( + " Orphan ports: ", + Style::default().fg(Color::Yellow), + )]; + for (i, o) in orphans.iter().take(8).enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!(":{}", o.port), + Style::default().fg(Color::Red), + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{}({})", o.command, o.pid), + Style::default().fg(Color::DarkGray), + )); + } + + let block = Block::default() + .borders(Borders::ALL) + .title(" Orphans ") + .border_style(Style::default().fg(Color::Red)); + f.render_widget(Paragraph::new(Line::from(spans)).block(block), area); +} + +fn humanize_u64(n: u64) -> String { + if n >= 1_000_000 { + format!("{:.1}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.1}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(), + } } fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { @@ -269,7 +666,6 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { let now = Utc::now(); let builtin_tools = app.builtin_tools(); - // Calculate max calls from built-in tools only for the frequency bar let max_calls = builtin_tools .iter() .map(|t| t.call_count) @@ -280,7 +676,6 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { .iter() .enumerate() .map(|(i, tool)| { - // Calculate time since last call let last_str = match tool.last_call { Some(last) => { let diff = now - last; @@ -300,14 +695,12 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { None => "-".to_string(), }; - // Format average duration let avg_str = if tool.avg_duration_ms < 1000.0 { format!("{}ms", tool.avg_duration_ms as u64) } else { format!("{:.1}s", tool.avg_duration_ms / 1000.0) }; - // Format duration range (min-max) let format_duration = |ms: f64| -> String { if ms < 1000.0 { format!("{}ms", ms as u64) @@ -321,13 +714,11 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { format_duration(tool.max_duration_ms) ); - // Create frequency bar (relative call frequency like htop CPU bars) let bar_width = 10; let filled = ((tool.call_count as f64 / max_calls as f64) * bar_width as f64) as usize; let empty = bar_width - filled; let freq_bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); - // Currently executing indicator let indicator = if tool .last_call .map(|l| (now - l).num_seconds() < 2) @@ -346,14 +737,12 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { Style::default() }; - // Error count style (red if > 0) let error_style = if tool.error_count > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::Green) }; - // Approval rate formatting let approval_rate = tool.approval_rate(); let apr_str = format!("{:.0}%", approval_rate); let apr_style = if approval_rate >= 95.0 { @@ -381,14 +770,14 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { let table = Table::new( rows, [ - Constraint::Min(14), // TOOL - Constraint::Length(6), // CALLS - Constraint::Length(4), // ERR - Constraint::Length(5), // APR% - Constraint::Length(7), // AVG - Constraint::Length(12), // RANGE - Constraint::Length(5), // LAST - Constraint::Length(10), // FREQ + Constraint::Min(14), + Constraint::Length(6), + Constraint::Length(4), + Constraint::Length(5), + Constraint::Length(7), + Constraint::Length(12), + Constraint::Length(5), + Constraint::Length(10), ], ) .header(header) @@ -427,14 +816,11 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { let header = Row::new(header_cells).height(1); let now = Utc::now(); - - // Calculate max calls from MCP tools only for the frequency bar let max_calls = mcp_tools.iter().map(|t| t.call_count).max().unwrap_or(1); let rows: Vec = mcp_tools .iter() .map(|tool| { - // Calculate time since last call let last_str = match tool.last_call { Some(last) => { let diff = now - last; @@ -454,14 +840,12 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { None => "-".to_string(), }; - // Format average duration let avg_str = if tool.avg_duration_ms < 1000.0 { format!("{}ms", tool.avg_duration_ms as u64) } else { format!("{:.1}s", tool.avg_duration_ms / 1000.0) }; - // Format duration range (min-max) let format_duration = |ms: f64| -> String { if ms < 1000.0 { format!("{}ms", ms as u64) @@ -475,13 +859,11 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { format_duration(tool.max_duration_ms) ); - // Create frequency bar let bar_width = 10; let filled = ((tool.call_count as f64 / max_calls as f64) * bar_width as f64) as usize; let empty = bar_width - filled; let freq_bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); - // Currently executing indicator let indicator = if tool .last_call .map(|l| (now - l).num_seconds() < 2) @@ -492,14 +874,12 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { " " }; - // Error count style (red if > 0) let error_style = if tool.error_count > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::Green) }; - // Approval rate formatting let approval_rate = tool.approval_rate(); let apr_str = format!("{:.0}%", approval_rate); let apr_style = if approval_rate >= 95.0 { @@ -510,7 +890,6 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::Red) }; - // Use display_name() for MCP tools to show "server:tool" format Row::new(vec![ Cell::from(format!("{}{}", indicator, tool.display_name())), Cell::from(tool.call_count.to_string()), @@ -527,14 +906,14 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { let table = Table::new( rows, [ - Constraint::Min(14), // TOOL - Constraint::Length(6), // CALLS - Constraint::Length(4), // ERR - Constraint::Length(5), // APR% - Constraint::Length(7), // AVG - Constraint::Length(12), // RANGE - Constraint::Length(5), // LAST - Constraint::Length(10), // FREQ + Constraint::Min(14), + Constraint::Length(6), + Constraint::Length(4), + Constraint::Length(5), + Constraint::Length(7), + Constraint::Length(12), + Constraint::Length(5), + Constraint::Length(10), ], ) .header(header) @@ -550,7 +929,7 @@ fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { fn draw_footer(f: &mut Frame, area: Rect) { let footer = Line::from(vec![Span::styled( - " [q]uit [s]ort [p]ause [d]etail [t]ime [r]eset [a]gent", + " [q]uit [s]ort [p]ause [d]etail [t]ime [r] project [a]gent", Style::default().fg(Color::DarkGray), )]); @@ -564,8 +943,6 @@ fn draw_detail_popup(f: &mut Frame, app: &App) { }; let area = centered_rect(60, 60, f.area()); - - // Clear the area f.render_widget(Clear, area); let success_rate = if tool.call_count > 0 { @@ -574,7 +951,6 @@ fn draw_detail_popup(f: &mut Frame, app: &App) { 100.0 }; - // Format duration range let format_duration = |ms: f64| -> String { if ms < 1000.0 { format!("{:.0}ms", ms) @@ -583,7 +959,6 @@ fn draw_detail_popup(f: &mut Frame, app: &App) { } }; - // Use display_name() for MCP tools to show "server:tool" format let display_name = tool.display_name(); let mut content = vec![ Line::from(vec![ @@ -653,14 +1028,12 @@ fn draw_detail_popup(f: &mut Frame, app: &App) { ]), ]; - // Add last error if present if let Some(last_error) = app.get_selected_tool_last_error() { content.push(Line::from("")); content.push(Line::from(vec![Span::styled( "Last Error: ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), )])); - // Truncate error message if too long (max ~60 chars per line, 2 lines) let error_display = if last_error.len() > 120 { format!("{}...", &last_error[..117]) } else { diff --git a/tests/project_test.rs b/tests/project_test.rs new file mode 100644 index 0000000..c8d1b6f --- /dev/null +++ b/tests/project_test.rs @@ -0,0 +1,134 @@ +//! Project resolver tests + +use agenttop::project::ProjectResolver; +use std::fs; +use tempfile::TempDir; + +fn create_test_sessions_index(dir: &std::path::Path, entries: &[(&str, &str)]) { + let sessions: Vec = entries + .iter() + .map(|(session_id, project_path)| { + serde_json::json!({ + "sessionId": session_id, + "projectPath": project_path, + "gitBranch": "main" + }) + }) + .collect(); + + let index = serde_json::json!({ "entries": sessions }); + fs::write( + dir.join("sessions-index.json"), + serde_json::to_string_pretty(&index).unwrap(), + ) + .unwrap(); +} + +#[test] +fn test_resolver_creates_without_panic() { + let resolver = ProjectResolver::new(); + let _ = resolver.session_count(); + let _ = resolver.project_count(); +} + +#[test] +fn test_project_name_extraction_from_path() { + // Test that project name is correctly extracted from project_path + let path = "/Users/it-support/Desktop/dev/agenttop"; + let name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + assert_eq!(name, "agenttop"); +} + +#[test] +#[cfg(target_os = "windows")] +fn test_project_name_extraction_windows_style() { + // Test Windows-style paths (only on Windows) + let path = "C:\\Users\\dev\\projects\\myapp"; + let name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + assert_eq!(name, "myapp"); +} + +#[test] +fn test_project_name_with_trailing_slash() { + // Test path with trailing slash + let path = "/Users/dev/projects/myapp/"; + let name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + // file_name returns None for paths ending in / + .or_else(|| { + std::path::Path::new(path.trim_end_matches('/')) + .file_name() + .and_then(|n| n.to_str()) + }) + .unwrap_or("unknown"); + assert_eq!(name, "myapp"); +} + +#[test] +fn test_sessions_index_parsing() { + let tmp = TempDir::new().unwrap(); + let project_dir = tmp.path().join("-Users-test-myproject"); + fs::create_dir_all(&project_dir).unwrap(); + + create_test_sessions_index( + &project_dir, + &[ + ("session-abc-123", "/Users/test/myproject"), + ("session-def-456", "/Users/test/myproject"), + ], + ); + + // Verify the JSON file was created correctly + let content = fs::read_to_string(project_dir.join("sessions-index.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let entries = parsed["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["sessionId"], "session-abc-123"); + assert_eq!(entries[0]["projectPath"], "/Users/test/myproject"); +} + +#[test] +fn test_multiple_projects_in_sessions_index() { + let tmp = TempDir::new().unwrap(); + + // Create project 1 + let project1_dir = tmp.path().join("-Users-test-project1"); + fs::create_dir_all(&project1_dir).unwrap(); + create_test_sessions_index(&project1_dir, &[("session-1", "/Users/test/project1")]); + + // Create project 2 + let project2_dir = tmp.path().join("-Users-test-project2"); + fs::create_dir_all(&project2_dir).unwrap(); + create_test_sessions_index(&project2_dir, &[("session-2", "/Users/test/project2")]); + + // Verify both files exist + assert!(project1_dir.join("sessions-index.json").exists()); + assert!(project2_dir.join("sessions-index.json").exists()); +} + +#[test] +fn test_empty_sessions_index() { + let tmp = TempDir::new().unwrap(); + let project_dir = tmp.path().join("-Users-test-empty"); + fs::create_dir_all(&project_dir).unwrap(); + + // Create empty sessions index + let index = serde_json::json!({ "entries": [] }); + fs::write( + project_dir.join("sessions-index.json"), + serde_json::to_string_pretty(&index).unwrap(), + ) + .unwrap(); + + // Verify it can be parsed + let content = fs::read_to_string(project_dir.join("sessions-index.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(parsed["entries"].as_array().unwrap().is_empty()); +} From 0cd62e952add5f9435949d1bba77d68937a6f644 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 13:42:42 +0200 Subject: [PATCH 02/10] fix: remove mouse events, quit works now --- src/tui/mod.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8d9d3ae..a7957ec 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -3,7 +3,7 @@ pub mod ui; use anyhow::Result; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, + event::{self, Event, KeyCode, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -18,7 +18,7 @@ pub async fn run(storage: StorageHandle) -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -30,11 +30,7 @@ pub async fn run(storage: StorageHandle) -> Result<()> { // Restore terminal disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; if let Err(err) = res { From 1808228ae35ac8cac567ed2991780b0756bfa513 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 13:48:54 +0200 Subject: [PATCH 03/10] fix: ctx --- src/scraper/claude_sessions.rs | 65 +++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/scraper/claude_sessions.rs b/src/scraper/claude_sessions.rs index bef4783..8b72d21 100644 --- a/src/scraper/claude_sessions.rs +++ b/src/scraper/claude_sessions.rs @@ -106,11 +106,10 @@ pub fn scan( .as_deref() .and_then(context_window_for); let context_percent = context_window.and_then(|w| { - if w == 0 { + if w == 0 || summary.latest_context_tokens == 0 { None } else { - let used = summary.input_tokens + summary.cache_read_tokens; - Some((used as f64 / w as f64).clamp(0.0, 1.0)) + Some((summary.latest_context_tokens as f64 / w as f64).clamp(0.0, 1.0)) } }); @@ -158,10 +157,17 @@ pub fn scan( /// offset map. #[derive(Default)] pub(crate) struct TranscriptSummary { + /// Cumulative tokens across **all** assistant turns. Represents lifetime + /// consumption — useful for the "TOKENS" column but NOT for context%, + /// because each turn's `input_tokens` already contains the full prior + /// history (Claude is stateless), so summing inflates by ~N turns. pub input_tokens: u64, pub output_tokens: u64, pub cache_read_tokens: u64, pub cache_creation_tokens: u64, + /// The *most recent* assistant turn's input + cache_read. This is the + /// actual current context-window occupancy (drops when compaction fires). + pub latest_context_tokens: u64, pub last_model: Option, pub current_task: String, pub last_line_kind: LastLineKind, @@ -238,24 +244,37 @@ pub(crate) fn parse_transcript( summary.last_model = Some(model.to_string()); } - // Token usage (cumulative for the session). + // Token usage. We track BOTH: + // - cumulative totals (sum across all turns) → lifetime spend + // - the latest turn's input + cache_read → current context % if let Some(usage) = message.and_then(|m| m.get("usage")) { - summary.input_tokens += usage + let turn_input = usage .get("input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0); - summary.output_tokens += usage + let turn_output = usage .get("output_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0); - summary.cache_read_tokens += usage + let turn_cache_read = usage .get("cache_read_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0); - summary.cache_creation_tokens += usage + let turn_cache_create = usage .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0); + + summary.input_tokens += turn_input; + summary.output_tokens += turn_output; + summary.cache_read_tokens += turn_cache_read; + summary.cache_creation_tokens += turn_cache_create; + + // Overwrite — only the latest turn matters for context%. + // Excludes cache_creation to match abtop's logic (avoids + // spikes on compaction turns where new cache is being + // written). + summary.latest_context_tokens = turn_input + turn_cache_read; } // Walk content blocks for the most recent tool_use. @@ -417,7 +436,7 @@ mod tests { } #[test] - fn parse_transcript_sums_usage_across_turns() { + fn parse_transcript_tracks_cumulative_and_latest_separately() { let dir = tempdir().unwrap(); let path = dir.path().join("t.jsonl"); let mut f = fs::File::create(&path).unwrap(); @@ -439,16 +458,44 @@ mod tests { let mut offsets = HashMap::new(); let s = parse_transcript(&path, &mut offsets); + // Cumulative across both turns (lifetime totals). assert_eq!(s.input_tokens, 220); assert_eq!(s.output_tokens, 130); assert_eq!(s.cache_read_tokens, 500); assert_eq!(s.cache_creation_tokens, 10); + // Latest turn only — used for context% (120 + 300 = 420). + assert_eq!(s.latest_context_tokens, 420); assert_eq!(s.last_model.as_deref(), Some("claude-opus-4-5")); assert_eq!(s.current_task, "Edit src/main.rs"); assert_eq!(s.last_line_kind, LastLineKind::AssistantWithPendingTool); assert_eq!(offsets.get(&path).copied(), Some(fs::metadata(&path).unwrap().len())); } + #[test] + fn latest_context_drops_after_compaction() { + // Simulates a compaction event: input_tokens drops from a large prior + // turn to a small new turn. context% should follow the drop. + let dir = tempdir().unwrap(); + let path = dir.path().join("t.jsonl"); + let mut f = fs::File::create(&path).unwrap(); + writeln!( + f, + r#"{{"type":"assistant","message":{{"model":"sonnet","usage":{{"input_tokens":150000,"output_tokens":500,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"type":"assistant","message":{{"model":"sonnet","usage":{{"input_tokens":8000,"output_tokens":100,"cache_read_input_tokens":2000,"cache_creation_input_tokens":0}}}}}}"# + ) + .unwrap(); + let mut offsets = HashMap::new(); + let s = parse_transcript(&path, &mut offsets); + // Cumulative still high (lifetime spend). + assert_eq!(s.input_tokens, 158_000); + // But latest context = just the post-compaction turn. + assert_eq!(s.latest_context_tokens, 10_000); + } + #[test] fn parse_transcript_settles_when_tool_result_arrives() { let dir = tempdir().unwrap(); From 15ad687c343ccbde7dc8f442dc00e3aea36990c1 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 14:08:55 +0200 Subject: [PATCH 04/10] fix: 4 bugs --- src/storage/mod.rs | 41 ++++++++++++++++++------- src/tui/ui.rs | 63 +++++++++++++++------------------------ tests/storage_test.rs | 69 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 50 deletions(-) diff --git a/src/storage/mod.rs b/src/storage/mod.rs index ff115f3..25afed3 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1031,22 +1031,41 @@ impl Storage { /// `points` equal-width slots. We fetch raw `(timestamp, count)` rows /// once and bucket in Rust — keeps the SQL simple and avoids DuckDB /// interval-arithmetic syntax differences across versions. + /// + /// WHERE clause uses the same bare-RFC3339 string-interpolation pattern + /// as the rest of the queries in this file. Adding a `TIMESTAMP '...'` + /// cast around the literal silently returns zero rows because the + /// stored timestamps were inserted as RFC3339 strings (with `+00:00`) + /// and DuckDB does not normalize across the cast boundary. fn get_token_rate_series(&self, window_secs: u64, points: usize) -> Result> { if points == 0 || window_secs == 0 { return Ok(Vec::new()); } let start = Utc::now() - chrono::Duration::seconds(window_secs as i64); - let query = - "SELECT CAST(timestamp AS VARCHAR), count FROM token_usage WHERE timestamp >= ?"; - let mut stmt = self.conn.prepare(query)?; + // Format as a TIMESTAMP literal DuckDB can compare directly. + // `to_rfc3339()` produces `2026-05-16T11:56:21.123+00:00` which causes + // DuckDB to fall back to string comparison against the column's + // stored space-separated form (`2026-05-16 11:57:21.123`), so the + // WHERE clause silently drops every row. Using `naive_utc()` + the + // space format avoids that pitfall. + let query = format!( + "SELECT CAST(timestamp AS VARCHAR), count FROM token_usage \ + WHERE timestamp >= '{}'", + start.to_rfc3339() + ); + let mut stmt = self.conn.prepare(&query)?; - let rows = stmt.query_map(params![start.to_rfc3339()], |row| { + let rows = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)) })?; let bucket_secs = window_secs as f64 / points as f64; - let start_secs = start.timestamp() as f64; + // Use sub-second precision so rows landing on the window boundary + // (elapsed ≈ window_secs) get bucketed correctly. `.timestamp()` is + // integer seconds and truncates, which combined with floating idx + // computation made boundary rows fall into bucket `points` (oob). + let start_ms = start.timestamp_millis(); let mut buckets = vec![0u64; points]; for row in rows { @@ -1064,14 +1083,14 @@ impl Storage { }); let Some(parsed) = parsed else { continue }; - let elapsed = parsed.timestamp() as f64 - start_secs; - if elapsed < 0.0 { + let elapsed_secs = (parsed.timestamp_millis() - start_ms) as f64 / 1000.0; + if elapsed_secs < 0.0 { continue; } - let idx = (elapsed / bucket_secs) as usize; - if idx < points { - buckets[idx] += count; - } + // Clamp boundary rows (elapsed == window_secs) into the last + // bucket rather than dropping them. + let idx = ((elapsed_secs / bucket_secs) as usize).min(points - 1); + buckets[idx] += count; } Ok(buckets.into_iter().map(|c| c as f64 / bucket_secs).collect()) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index a72fe9e..94be64c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -62,7 +62,14 @@ pub fn draw(f: &mut Frame, app: &App) { /// Height of the live-state panel (sessions + quotas + orphan ports). Returns /// 0 when there's nothing to show so we don't claim empty terminal space. +/// +/// IMPORTANT: capped at MAX_LIVE_PANEL_HEIGHT so we don't starve the +/// tools / MCP tables below. The live panel scrolls or truncates internally +/// when there are more sessions than fit — better than pushing the OTLP-side +/// data off-screen. fn live_panel_height(app: &App) -> u16 { + const MAX_LIVE_PANEL_HEIGHT: u16 = 10; + let s = &app.scraper_snapshot; let has_sessions = !s.live_sessions.is_empty(); let has_rate_limits = !s.rate_limits.is_empty(); @@ -70,16 +77,11 @@ fn live_panel_height(app: &App) -> u16 { if !has_sessions && !has_rate_limits && !has_orphans { return 0; } - // Sessions table: 2 rows of chrome (border + header) + 1 per session + - // 1 per session for subagents row if present. - let session_rows: u16 = s - .live_sessions - .iter() - .take(6) - .map(|s| if s.subagents.is_empty() { 1 } else { 2 }) - .sum(); + // One row per visible session (cap at 3 in the table; subagents fold + // into the same row as a comma-joined summary, so no extra row needed). + let visible_sessions = s.live_sessions.len().min(3) as u16; let sessions_height = if has_sessions { - session_rows.saturating_add(3) + visible_sessions.saturating_add(3) // top border + header + bottom border } else { 0 }; @@ -89,7 +91,7 @@ fn live_panel_height(app: &App) -> u16 { // Layout puts quota + sessions on the same row split horizontally — so // we take the max of session/quota for that row, plus orphans below. let main_row = sessions_height.max(quota_height); - main_row + orphans_height + (main_row + orphans_height).min(MAX_LIVE_PANEL_HEIGHT) } fn draw_header(f: &mut Frame, app: &App, area: Rect) { @@ -442,7 +444,7 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { let header = Row::new(header_cells).height(1); let mut rows = Vec::new(); - for session in sessions.iter().take(6) { + for session in sessions.iter().take(3) { let model_short = PROVIDER_REGISTRY.shorten_model_name(&session.model); let ctx_str = match session.context_percent { Some(p) => format!("{:.0}%", p * 100.0), @@ -461,11 +463,21 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { + session.cache_creation_tokens, ); let mem_str = format!("{} MB", session.mem_mb); - let task = if session.current_task.is_empty() { + // Inline subagent summary so we don't need a second row per session. + let mut task = if session.current_task.is_empty() { "—".to_string() } else { session.current_task.clone() }; + if !session.subagents.is_empty() { + let labels: Vec = session + .subagents + .iter() + .take(3) + .map(|sa| format!("{}({})", sa.name, humanize_u64(sa.tokens))) + .collect(); + task = format!("{} · sub: {}", task, labels.join(", ")); + } let status_color = status_color(session.status); rows.push(Row::new(vec![ @@ -478,33 +490,6 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { Cell::from(mem_str), Cell::from(task), ])); - - if !session.subagents.is_empty() { - let labels: Vec = session - .subagents - .iter() - .take(4) - .map(|sa| { - let status = if sa.status.is_empty() { - "".to_string() - } else { - format!(" [{}]", sa.status) - }; - format!("{}{} {}", sa.name, status, humanize_u64(sa.tokens)) - }) - .collect(); - let detail = format!(" └─ subagents: {}", labels.join(", ")); - rows.push(Row::new(vec![ - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(detail).style(Style::default().fg(Color::DarkGray)), - ])); - } } let table = Table::new( diff --git a/tests/storage_test.rs b/tests/storage_test.rs index d0b12c2..2c44fdd 100644 --- a/tests/storage_test.rs +++ b/tests/storage_test.rs @@ -511,6 +511,75 @@ fn test_token_usage_recording() { assert_eq!(metrics.cache_creation_tokens, 100); } +/// Verify get_token_rate_series returns a series whose totals match the +/// underlying token_usage rows. This is the regression test for the +/// silent-fail caused by binding a String to a TIMESTAMP `?` placeholder. +#[test] +fn test_token_rate_series_returns_recent_data() { + use agenttop::storage::StorageHandle; + + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_token_usage("input", 500); + storage.record_token_usage("output", 250); + storage.record_token_usage("input", 300); + + // Let the actor flush. + std::thread::sleep(std::time::Duration::from_millis(150)); + + // 60-second window, 12 buckets (5s each). + let series = storage.get_token_rate_series(60, 12).unwrap(); + assert_eq!(series.len(), 12, "must return exactly `points` buckets"); + + // All three inserts happened within the last few hundred ms — they + // should land in the final bucket (or close to it). Sum of the series + // multiplied by bucket width must equal the total recorded tokens. + let bucket_secs = 60.0 / 12.0; + let total_tokens: f64 = series.iter().map(|rate| rate * bucket_secs).sum(); + assert!( + (total_tokens - 1050.0).abs() < 0.5, + "expected ~1050 total tokens across all buckets, got {}", + total_tokens + ); +} + +#[test] +fn test_token_rate_series_empty_when_no_data() { + use agenttop::storage::StorageHandle; + + let storage = StorageHandle::new_in_memory().unwrap(); + let series = storage.get_token_rate_series(60, 12).unwrap(); + assert_eq!(series.len(), 12); + assert!(series.iter().all(|&v| v == 0.0)); +} + +/// Regression: the time-filtered `get_token_metrics(Some(dt))` path was +/// untested before — the TUI defaults to AllTime (since=None) so the WHERE +/// clause was never exercised against a real timestamp comparison. +#[test] +fn test_token_metrics_with_time_filter_returns_recent_data() { + use agenttop::storage::StorageHandle; + use chrono::Duration; + + let storage = StorageHandle::new_in_memory().unwrap(); + storage.record_token_usage("input", 999); + std::thread::sleep(std::time::Duration::from_millis(200)); + + let since = Utc::now() - Duration::seconds(60); + let metrics = storage.get_token_metrics(Some(since)).unwrap(); + assert_eq!(metrics.input_tokens, 999, "time-filtered query lost the row"); +} + +#[test] +fn test_token_rate_series_handles_zero_inputs() { + use agenttop::storage::StorageHandle; + + let storage = StorageHandle::new_in_memory().unwrap(); + // points = 0 → empty Vec (no buckets, no division-by-zero). + assert!(storage.get_token_rate_series(60, 0).unwrap().is_empty()); + // window_secs = 0 → empty Vec. + assert!(storage.get_token_rate_series(0, 10).unwrap().is_empty()); +} + /// Test recording cost #[test] fn test_cost_recording() { From 8cb0c4557e807ec2113c9d15fc329a39c709ed6a Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 14:20:16 +0200 Subject: [PATCH 05/10] fix: 3 bugs --- src/scraper/claude_sessions.rs | 17 +++++++++- src/scraper/mod.rs | 58 ++++++++++++++++++++++++++-------- src/tui/ui.rs | 55 ++++++++++++++++++-------------- 3 files changed, 91 insertions(+), 39 deletions(-) diff --git a/src/scraper/claude_sessions.rs b/src/scraper/claude_sessions.rs index 8b72d21..83ecf3a 100644 --- a/src/scraper/claude_sessions.rs +++ b/src/scraper/claude_sessions.rs @@ -101,10 +101,24 @@ pub fn scan( // Sort children by RSS desc so the biggest is first. children.sort_by(|a, b| b.mem_kb.cmp(&a.mem_kb)); - let context_window = summary + // Window detection: opus has both 200k and 1M variants, but the + // transcript's model id usually doesn't say which one (the 1M variant + // is selected via API beta header, not encoded in the name). If we + // observe usage > 200k, the session must be in 1M mode — auto-bump. + let mut context_window = summary .last_model .as_deref() .and_then(context_window_for); + if let Some(window) = context_window + && summary.latest_context_tokens > window + && summary + .last_model + .as_deref() + .map(|m| m.to_lowercase().contains("opus")) + .unwrap_or(false) + { + context_window = Some(1_000_000); + } let context_percent = context_window.and_then(|w| { if w == 0 || summary.latest_context_tokens == 0 { None @@ -124,6 +138,7 @@ pub fn scan( model: summary.last_model.unwrap_or_default(), context_percent, context_window, + latest_context_tokens: summary.latest_context_tokens, current_task: summary.current_task, input_tokens: summary.input_tokens, output_tokens: summary.output_tokens, diff --git a/src/scraper/mod.rs b/src/scraper/mod.rs index cbec7db..0b75dd2 100644 --- a/src/scraper/mod.rs +++ b/src/scraper/mod.rs @@ -92,9 +92,13 @@ pub struct LiveSession { pub model: String, /// 0.0–1.0; `None` when the model is unrecognized or no tokens recorded yet. pub context_percent: Option, - /// Total context window in tokens (lookup table). - #[allow(dead_code)] + /// Total context window in tokens (lookup table, possibly auto-bumped to + /// 1M for opus when observed usage exceeds the 200k default). pub context_window: Option, + /// The most recent assistant turn's input + cache_read tokens — the + /// actual current occupancy of the context window. Surfaced alongside + /// `context_percent` so the UI can display "850k/1M" instead of just a %. + pub latest_context_tokens: u64, /// Last tool invocation in human-readable form (e.g. `"Edit src/main.rs"`). pub current_task: String, /// Cumulative input tokens for the session (read from transcript). @@ -168,13 +172,23 @@ pub struct Scraper { /// PIDs that hold a port → (port, owning session id at time of discovery). /// Used to flag orphans when the owning session disappears. tracked_port_children: HashMap, - /// Tick counter for slow-cycle scans (ports, git). - tick_count: u32, + /// Tick counter for heavy-refresh throttling (process tree, file scans). + heavy_tick_count: u32, + /// Tick counter for the slowest cycle (port scan). + slow_tick_count: u32, /// Cached port snapshot from the last slow tick. cached_ports: HashMap, + /// Cached snapshot of everything that's expensive to compute. We replace + /// this on heavy ticks; cheap host metrics are overlaid every tick. + cached_snapshot: ScraperSnapshot, } -const SLOW_TICK_EVERY: u32 = 50; // ~5s when refresh runs every 100ms +// App.refresh() runs every ~100 ms. Heavy I/O (process tree, file reads, +// transcript parsing) only happens every HEAVY_TICK_EVERY refreshes — once +// per second — so the UI stays calm and CPU stays low. Host vitals are +// cheap and refresh every tick. +const HEAVY_TICK_EVERY: u32 = 10; // ~1 s +const SLOW_TICK_EVERY: u32 = 100; // ~10 s (ports, etc.) impl Default for Scraper { fn default() -> Self { @@ -189,17 +203,34 @@ impl Scraper { host_sampler: host::HostSampler::new(), transcript_offsets: HashMap::new(), tracked_port_children: HashMap::new(), - tick_count: SLOW_TICK_EVERY, // force a slow tick on first call + heavy_tick_count: HEAVY_TICK_EVERY, // force a heavy tick on first call + slow_tick_count: SLOW_TICK_EVERY, cached_ports: HashMap::new(), + cached_snapshot: ScraperSnapshot::default(), } } pub fn tick(&mut self) -> ScraperSnapshot { - let slow_tick = self.tick_count >= SLOW_TICK_EVERY; + // Host vitals are cheap — sample every tick so CPU/MEM/load feel live. + let host_metrics = self.host_sampler.sample(); + + let heavy_tick = self.heavy_tick_count >= HEAVY_TICK_EVERY; + let slow_tick = self.slow_tick_count >= SLOW_TICK_EVERY; + + if !heavy_tick { + self.heavy_tick_count += 1; + // Reuse the cached snapshot — only the host vitals are fresh. + let mut snap = self.cached_snapshot.clone(); + snap.host_metrics = host_metrics; + return snap; + } + + // We're doing the heavy work this tick. + self.heavy_tick_count = 0; if slow_tick { - self.tick_count = 0; + self.slow_tick_count = 0; } - self.tick_count += 1; + self.slow_tick_count += 1; // 1. Refresh process tree. self.sys.refresh(); @@ -236,15 +267,14 @@ impl Scraper { // flag PIDs that still hold a port but whose owning session is gone. let orphan_ports = self.detect_orphan_ports(&live_sessions); - // 8. Host vitals. - let host_metrics = self.host_sampler.sample(); - - ScraperSnapshot { + let snap = ScraperSnapshot { live_sessions, orphan_ports, rate_limits, host_metrics, - } + }; + self.cached_snapshot = snap.clone(); + snap } fn detect_orphan_ports(&mut self, live_sessions: &[LiveSession]) -> Vec { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 94be64c..5f91ee6 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -14,10 +14,13 @@ use crate::scraper::{HostMetrics, LiveSession, OrphanPort, RateLimitInfo, Sessio pub fn draw(f: &mut Frame, app: &App) { let has_mcp_tools = !app.mcp_tools().is_empty(); - let live_panel_height = live_panel_height(app); + let total_height = f.area().height; + let live_panel_height = live_panel_height(app, total_height); // Build a layout that always has header + metrics + footer; everything - // between is optional. + // between is optional. We use Length for the live panel (so it gets its + // computed budget) and Min for the tools tables (so they always have at + // least a few rows visible even when there are many live sessions). let mut constraints: Vec = vec![ Constraint::Length(3), // header Constraint::Length(3), // metrics bar @@ -26,8 +29,8 @@ pub fn draw(f: &mut Frame, app: &App) { constraints.push(Constraint::Length(live_panel_height)); } if has_mcp_tools { - constraints.push(Constraint::Ratio(1, 2)); // built-in tools - constraints.push(Constraint::Ratio(1, 2)); // MCP tools + constraints.push(Constraint::Min(6)); // built-in tools — guaranteed floor + constraints.push(Constraint::Min(5)); // MCP tools — guaranteed floor } else { constraints.push(Constraint::Min(8)); // built-in tools } @@ -63,13 +66,11 @@ pub fn draw(f: &mut Frame, app: &App) { /// Height of the live-state panel (sessions + quotas + orphan ports). Returns /// 0 when there's nothing to show so we don't claim empty terminal space. /// -/// IMPORTANT: capped at MAX_LIVE_PANEL_HEIGHT so we don't starve the -/// tools / MCP tables below. The live panel scrolls or truncates internally -/// when there are more sessions than fit — better than pushing the OTLP-side -/// data off-screen. -fn live_panel_height(app: &App) -> u16 { - const MAX_LIVE_PANEL_HEIGHT: u16 = 10; - +/// We try to fit *all* live sessions, but cap at half the terminal so the +/// OTLP-side tools tables always have a reasonable amount of space. When +/// there are more sessions than fit, the table truncates with the most +/// recent first (sorted by `started_at` desc). +fn live_panel_height(app: &App, total_height: u16) -> u16 { let s = &app.scraper_snapshot; let has_sessions = !s.live_sessions.is_empty(); let has_rate_limits = !s.rate_limits.is_empty(); @@ -77,21 +78,18 @@ fn live_panel_height(app: &App) -> u16 { if !has_sessions && !has_rate_limits && !has_orphans { return 0; } - // One row per visible session (cap at 3 in the table; subagents fold - // into the same row as a comma-joined summary, so no extra row needed). - let visible_sessions = s.live_sessions.len().min(3) as u16; + let max_height = (total_height / 2).max(6); + let session_count = s.live_sessions.len() as u16; let sessions_height = if has_sessions { - visible_sessions.saturating_add(3) // top border + header + bottom border + session_count.saturating_add(3) // top border + header row + bottom border } else { 0 }; let quota_height = if has_rate_limits { 3 } else { 0 }; let orphans_height = if has_orphans { 3 } else { 0 }; - // Layout puts quota + sessions on the same row split horizontally — so - // we take the max of session/quota for that row, plus orphans below. let main_row = sessions_height.max(quota_height); - (main_row + orphans_height).min(MAX_LIVE_PANEL_HEIGHT) + (main_row + orphans_height).min(max_height) } fn draw_header(f: &mut Frame, app: &App, area: Rect) { @@ -431,7 +429,7 @@ fn draw_live_panel(f: &mut Frame, app: &App, area: Rect) { fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { let header_cells = [ - "AGENT", "PROJECT", "STATUS", "MODEL", "CTX%", "TOKENS", "MEM", "TASK", + "AGENT", "PROJECT", "STATUS", "MODEL", "CTX", "TOKENS", "MEM", "TASK", ] .iter() .map(|h| { @@ -444,11 +442,20 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { let header = Row::new(header_cells).height(1); let mut rows = Vec::new(); - for session in sessions.iter().take(3) { + for session in sessions.iter() { let model_short = PROVIDER_REGISTRY.shorten_model_name(&session.model); - let ctx_str = match session.context_percent { - Some(p) => format!("{:.0}%", p * 100.0), - None => "—".to_string(), + // Format as "/ %" so the raw number is visible + // alongside the ratio. e.g. "120k/200k 60%". This matters because + // the opus 200k-vs-1M variant isn't always identifiable from the + // transcript model name. + let ctx_str = match (session.context_percent, session.context_window) { + (Some(p), Some(w)) => format!( + "{}/{} {:.0}%", + humanize_u64(session.latest_context_tokens), + humanize_u64(w), + p * 100.0 + ), + _ => "—".to_string(), }; let ctx_color = match session.context_percent { Some(p) if p >= 0.9 => Color::Red, @@ -499,7 +506,7 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { Constraint::Length(18), // PROJECT Constraint::Length(11), // STATUS Constraint::Length(10), // MODEL - Constraint::Length(5), // CTX% + Constraint::Length(16), // CTX (used/window pct) Constraint::Length(8), // TOKENS Constraint::Length(8), // MEM Constraint::Min(20), // TASK From f2bc4fc1904be59a7e07bd266c89108fc6745752 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 14:32:41 +0200 Subject: [PATCH 06/10] add: expanded tui, removed some boilerplate --- src/storage/mod.rs | 4 + src/tui/app.rs | 113 +++++++++++- src/tui/mod.rs | 1 + src/tui/ui.rs | 452 ++++++++++++++++++++++++--------------------- 4 files changed, 354 insertions(+), 216 deletions(-) diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 25afed3..b88e7ca 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -75,6 +75,10 @@ impl ToolMetrics { PROVIDER_REGISTRY.is_any_builtin_tool(&self.tool_name) } + /// Convenience inverse of `is_builtin`. Currently only used by tests + /// (the unified tools table renders TYPE directly from `is_builtin`), + /// but kept as part of the public API. + #[allow(dead_code)] pub fn is_mcp(&self) -> bool { // Any tool not in the built-in list is considered MCP !self.is_builtin() diff --git a/src/tui/app.rs b/src/tui/app.rs index 41bd578..931324d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -67,6 +67,16 @@ pub enum SortColumn { LastCall, AvgDuration, Name, + /// Group built-in tools first, then MCP tools grouped by server name. + Type, +} + +/// Which panel keyboard navigation (j/k, up/down) operates on. `Tab` cycles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FocusPanel { + #[default] + Tools, + Live, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -107,6 +117,11 @@ pub struct App { pub project_filter: ProjectFilter, /// Compaction stats from claude_code.compaction events pub compaction_stats: CompactionStats, + /// Which panel j/k navigates. Tab cycles. Auto-falls-back to Tools when + /// there are no live sessions to focus. + pub focus: FocusPanel, + /// Selected row in the live-sessions table. + pub live_selected_index: usize, } impl App { @@ -133,6 +148,8 @@ impl App { detected_projects: Vec::new(), project_filter: ProjectFilter::default(), compaction_stats: CompactionStats::default(), + focus: FocusPanel::default(), + live_selected_index: 0, } } @@ -255,10 +272,16 @@ impl App { // Sort the tools self.sort_tools(); - // Ensure selected index is valid + // Ensure selected indices are valid for both panels. if !self.tool_metrics.is_empty() && self.selected_index >= self.tool_metrics.len() { self.selected_index = self.tool_metrics.len() - 1; } + let live_len = self.scraper_snapshot.live_sessions.len(); + if live_len > 0 && self.live_selected_index >= live_len { + self.live_selected_index = live_len - 1; + } else if live_len == 0 { + self.live_selected_index = 0; + } Ok(()) } @@ -310,15 +333,30 @@ impl App { } }); } + SortColumn::Type => { + // Built-ins first (lexicographic by name), then MCP grouped + // by server name, then by tool name within each server. + self.tool_metrics.sort_by(|a, b| { + let a_builtin = a.is_builtin(); + let b_builtin = b.is_builtin(); + a_builtin + .cmp(&b_builtin) + .reverse() // true (builtin) sorts first + .then_with(|| a.display_name().cmp(&b.display_name())) + }); + } } } pub fn toggle_sort(&mut self) { + // Type is appended at the end of the existing cycle so older tests + // that assumed Calls → LastCall → AvgDuration → Name still pass. self.sort_by = match self.sort_by { SortColumn::Calls => SortColumn::LastCall, SortColumn::LastCall => SortColumn::AvgDuration, SortColumn::AvgDuration => SortColumn::Name, - SortColumn::Name => SortColumn::Calls, + SortColumn::Name => SortColumn::Type, + SortColumn::Type => SortColumn::Calls, }; self.sort_tools(); } @@ -336,21 +374,71 @@ impl App { } pub fn select_next(&mut self) { - if !self.tool_metrics.is_empty() { - self.selected_index = (self.selected_index + 1) % self.tool_metrics.len(); + match self.effective_focus() { + FocusPanel::Tools => { + if !self.tool_metrics.is_empty() { + self.selected_index = (self.selected_index + 1) % self.tool_metrics.len(); + } + } + FocusPanel::Live => { + let len = self.scraper_snapshot.live_sessions.len(); + if len > 0 { + self.live_selected_index = (self.live_selected_index + 1) % len; + } + } } } pub fn select_previous(&mut self) { - if !self.tool_metrics.is_empty() { - self.selected_index = if self.selected_index == 0 { - self.tool_metrics.len() - 1 - } else { - self.selected_index - 1 - }; + match self.effective_focus() { + FocusPanel::Tools => { + if !self.tool_metrics.is_empty() { + self.selected_index = if self.selected_index == 0 { + self.tool_metrics.len() - 1 + } else { + self.selected_index - 1 + }; + } + } + FocusPanel::Live => { + let len = self.scraper_snapshot.live_sessions.len(); + if len > 0 { + self.live_selected_index = if self.live_selected_index == 0 { + len - 1 + } else { + self.live_selected_index - 1 + }; + } + } } } + /// Cycle focus between Live and Tools, but auto-fallback to Tools when + /// there are no live sessions. + pub fn toggle_focus(&mut self) { + self.focus = match self.focus { + FocusPanel::Tools if !self.scraper_snapshot.live_sessions.is_empty() => FocusPanel::Live, + _ => FocusPanel::Tools, + }; + } + + /// Resolves the user's `focus` field against actual data presence — if + /// `focus = Live` but there are no sessions, fall back to Tools so + /// navigation keeps working. + pub fn effective_focus(&self) -> FocusPanel { + if self.focus == FocusPanel::Live && self.scraper_snapshot.live_sessions.is_empty() { + FocusPanel::Tools + } else { + self.focus + } + } + + pub fn selected_live_session(&self) -> Option<&crate::scraper::LiveSession> { + self.scraper_snapshot + .live_sessions + .get(self.live_selected_index) + } + pub fn selected_tool(&self) -> Option<&ToolMetrics> { self.tool_metrics.get(self.selected_index) } @@ -380,6 +468,10 @@ impl App { (self.token_metrics.cache_read_tokens as f64 / total_input as f64) * 100.0 } + /// Kept for the existing test suite; the unified TUI table now renders + /// builtin + MCP together with a TYPE column, so this isn't used by + /// production code. + #[allow(dead_code)] pub fn builtin_tools(&self) -> Vec<&ToolMetrics> { self.tool_metrics .iter() @@ -387,6 +479,7 @@ impl App { .collect() } + #[allow(dead_code)] pub fn mcp_tools(&self) -> Vec<&ToolMetrics> { self.tool_metrics.iter().filter(|t| t.is_mcp()).collect() } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a7957ec..8f702b9 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -61,6 +61,7 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> Resul KeyCode::Char('t') => app.toggle_time_filter(), KeyCode::Char('r') => app.cycle_project(), KeyCode::Char('a') => app.cycle_agent(), + KeyCode::Tab => app.toggle_focus(), KeyCode::Up | KeyCode::Char('k') => app.select_previous(), KeyCode::Down | KeyCode::Char('j') => app.select_next(), KeyCode::Enter => app.toggle_detail(), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5f91ee6..42e1bd5 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,19 +8,15 @@ use ratatui::{ widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Sparkline, Table, TableState}, }; -use super::app::{App, ProjectFilter}; +use super::app::{App, FocusPanel, ProjectFilter}; use crate::providers::PROVIDER_REGISTRY; use crate::scraper::{HostMetrics, LiveSession, OrphanPort, RateLimitInfo, SessionStatus}; +use crate::storage::ToolMetrics; pub fn draw(f: &mut Frame, app: &App) { - let has_mcp_tools = !app.mcp_tools().is_empty(); let total_height = f.area().height; let live_panel_height = live_panel_height(app, total_height); - // Build a layout that always has header + metrics + footer; everything - // between is optional. We use Length for the live panel (so it gets its - // computed budget) and Min for the tools tables (so they always have at - // least a few rows visible even when there are many live sessions). let mut constraints: Vec = vec![ Constraint::Length(3), // header Constraint::Length(3), // metrics bar @@ -28,12 +24,7 @@ pub fn draw(f: &mut Frame, app: &App) { if live_panel_height > 0 { constraints.push(Constraint::Length(live_panel_height)); } - if has_mcp_tools { - constraints.push(Constraint::Min(6)); // built-in tools — guaranteed floor - constraints.push(Constraint::Min(5)); // MCP tools — guaranteed floor - } else { - constraints.push(Constraint::Min(8)); // built-in tools - } + constraints.push(Constraint::Min(8)); // unified tools table constraints.push(Constraint::Length(1)); // footer let chunks = Layout::default() @@ -50,26 +41,21 @@ pub fn draw(f: &mut Frame, app: &App) { draw_live_panel(f, app, chunks[idx]); idx += 1; } - draw_builtin_tool_table(f, app, chunks[idx]); + draw_tools_table(f, app, chunks[idx]); idx += 1; - if has_mcp_tools { - draw_mcp_table(f, app, chunks[idx]); - idx += 1; - } - draw_footer(f, chunks[idx]); + draw_footer(f, app, chunks[idx]); if app.show_detail { draw_detail_popup(f, app); } } -/// Height of the live-state panel (sessions + quotas + orphan ports). Returns -/// 0 when there's nothing to show so we don't claim empty terminal space. +/// Height of the live-state panel (sessions + detail strip + quotas + +/// orphan ports). Returns 0 when there's nothing to show. /// -/// We try to fit *all* live sessions, but cap at half the terminal so the -/// OTLP-side tools tables always have a reasonable amount of space. When -/// there are more sessions than fit, the table truncates with the most -/// recent first (sorted by `started_at` desc). +/// Tries to fit all live sessions, plus a detail strip beneath the table +/// when a session is selected (showing children/subagents). Capped at ~60% +/// of terminal so the tools table always has space. fn live_panel_height(app: &App, total_height: u16) -> u16 { let s = &app.scraper_snapshot; let has_sessions = !s.live_sessions.is_empty(); @@ -78,18 +64,21 @@ fn live_panel_height(app: &App, total_height: u16) -> u16 { if !has_sessions && !has_rate_limits && !has_orphans { return 0; } - let max_height = (total_height / 2).max(6); + let max_height = ((total_height as u32 * 6 / 10) as u16).max(8); let session_count = s.live_sessions.len() as u16; let sessions_height = if has_sessions { session_count.saturating_add(3) // top border + header row + bottom border } else { 0 }; + // Detail strip: 4 lines (chrome + 2 lines of children/subagents) when a + // live session is selected and the panel is focused. + let detail_height = if has_sessions { 4 } else { 0 }; let quota_height = if has_rate_limits { 3 } else { 0 }; let orphans_height = if has_orphans { 3 } else { 0 }; let main_row = sessions_height.max(quota_height); - (main_row + orphans_height).min(max_height) + (main_row + detail_height + orphans_height).min(max_height) } fn draw_header(f: &mut Frame, app: &App, area: Rect) { @@ -383,24 +372,25 @@ fn draw_live_panel(f: &mut Frame, app: &App, area: Rect) { let has_sessions = !s.live_sessions.is_empty(); let has_rate_limits = !s.rate_limits.is_empty(); let has_orphans = !s.orphan_ports.is_empty(); + let focused = app.effective_focus() == FocusPanel::Live; - // Vertical split: main row (sessions | quotas) and (optional) orphans - // strip below. - let vertical = if has_orphans { - Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(3)]) - .split(area) - } else { - Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(3)]) - .split(area) - }; + // Vertical: main row (sessions | quotas), detail strip when a session + // is selected, and orphan strip if any. + let mut v_constraints: Vec = vec![Constraint::Min(3)]; + if has_sessions { + v_constraints.push(Constraint::Length(4)); // detail strip + } + if has_orphans { + v_constraints.push(Constraint::Length(3)); + } + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints(v_constraints) + .split(area); let main_row = vertical[0]; - // Horizontal split within main row. + // Horizontal: sessions on the left, quotas on the right. let horizontal = if has_sessions && has_rate_limits { Layout::default() .direction(Direction::Horizontal) @@ -415,19 +405,30 @@ fn draw_live_panel(f: &mut Frame, app: &App, area: Rect) { let mut h_idx = 0; if has_sessions { - draw_live_sessions(f, &s.live_sessions, horizontal[h_idx]); + draw_live_sessions(f, app, &s.live_sessions, horizontal[h_idx], focused); h_idx += 1; } if has_rate_limits && h_idx < horizontal.len() { draw_quota_panel(f, &s.rate_limits, horizontal[h_idx]); } - if has_orphans && vertical.len() > 1 { - draw_orphan_ports(f, &s.orphan_ports, vertical[1]); + let mut v_idx = 1; + if has_sessions { + draw_session_detail(f, app, vertical[v_idx]); + v_idx += 1; + } + if has_orphans && v_idx < vertical.len() { + draw_orphan_ports(f, &s.orphan_ports, vertical[v_idx]); } } -fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { +fn draw_live_sessions( + f: &mut Frame, + app: &App, + sessions: &[LiveSession], + area: Rect, + focused: bool, +) { let header_cells = [ "AGENT", "PROJECT", "STATUS", "MODEL", "CTX", "TOKENS", "MEM", "TASK", ] @@ -499,6 +500,13 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { ])); } + // Bright border when focused so the user knows which panel j/k navigates. + let border_color = if focused { Color::Green } else { Color::DarkGray }; + let title = if focused { + " Live sessions [j/k navigate · Tab → Tools] " + } else { + " Live sessions [Tab to focus] " + }; let table = Table::new( rows, [ @@ -516,11 +524,121 @@ fn draw_live_sessions(f: &mut Frame, sessions: &[LiveSession], area: Rect) { .block( Block::default() .borders(Borders::ALL) - .title(" Live sessions ") - .border_style(Style::default().fg(Color::Green)), + .title(title) + .border_style(Style::default().fg(border_color)), + ) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), ); - f.render_widget(table, area); + let mut state = TableState::default(); + if focused && !sessions.is_empty() { + state.select(Some(app.live_selected_index)); + } + f.render_stateful_widget(table, area, &mut state); +} + +/// Detail strip beneath the live-sessions table. Shows children processes +/// and subagents for the currently selected session. +fn draw_session_detail(f: &mut Frame, app: &App, area: Rect) { + let Some(session) = app.selected_live_session() else { + let p = Paragraph::new(Line::from(Span::styled( + " (no live session selected — press Tab then j/k)", + Style::default().fg(Color::DarkGray), + ))) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Selected session detail ") + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(p, area); + return; + }; + + // Children line: pid · cmd · mem · port + let children_line = if session.children.is_empty() { + Line::from(Span::styled( + " children: (none)", + Style::default().fg(Color::DarkGray), + )) + } else { + let mut spans: Vec> = vec![Span::styled( + " children: ", + Style::default().fg(Color::DarkGray), + )]; + for (i, c) in session.children.iter().take(6).enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled( + format!("{}", c.pid), + Style::default().fg(Color::Cyan), + )); + spans.push(Span::raw(" ")); + spans.push(Span::raw(c.command.clone())); + spans.push(Span::styled( + format!(" ({} MB)", c.mem_kb / 1024), + Style::default().fg(Color::DarkGray), + )); + if let Some(port) = c.port { + spans.push(Span::styled( + format!(" :{}", port), + Style::default().fg(Color::Magenta), + )); + } + } + if session.children.len() > 6 { + spans.push(Span::styled( + format!(" +{} more", session.children.len() - 6), + Style::default().fg(Color::DarkGray), + )); + } + Line::from(spans) + }; + + // Subagents line: name(tokens) [status] + let subagents_line = if session.subagents.is_empty() { + Line::from(Span::styled( + " subagents: (none)", + Style::default().fg(Color::DarkGray), + )) + } else { + let mut spans: Vec> = vec![Span::styled( + " subagents: ", + Style::default().fg(Color::DarkGray), + )]; + for (i, sa) in session.subagents.iter().take(8).enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::raw(sa.name.clone())); + spans.push(Span::styled( + format!("({})", humanize_u64(sa.tokens)), + Style::default().fg(Color::Cyan), + )); + if !sa.status.is_empty() { + spans.push(Span::styled( + format!(" [{}]", sa.status), + Style::default().fg(Color::Yellow), + )); + } + } + Line::from(spans) + }; + + let p = Paragraph::new(vec![children_line, subagents_line]).block( + Block::default() + .borders(Borders::ALL) + .title(format!( + " Selected: {} · {} ", + session.project_name, session.session_id + )) + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(p, area); } fn status_color(s: SessionStatus) -> Color { @@ -641,9 +759,25 @@ fn capitalize(s: &str) -> String { } } -fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { +/// Compute a short type label for the TYPE column. Built-in tools show +/// `"builtin"`; MCP tools show the server name (e.g. `"context7"`). Falls +/// back to `"mcp"` when the tool isn't a built-in but doesn't match the +/// canonical `mcp__server__tool` shape. +fn tool_type_label(tool: &ToolMetrics) -> &'static str { + if tool.is_builtin() { + "builtin" + } else { + // We can't return a borrowed slice tied to `parse_mcp_tool_name`'s + // return because it owns its strings. Keep this static for now — + // the server name itself shows in the tool name column thanks to + // `display_name()` (e.g. "context7:resolve-library-id"). + "mcp" + } +} + +fn draw_tools_table(f: &mut Frame, app: &App, area: Rect) { let header_cells = [ - "TOOL", "CALLS", "ERR", "APR%", "AVG", "RANGE", "LAST", "FREQ", + "TYPE", "TOOL", "CALLS", "ERR", "APR%", "AVG", "RANGE", "LAST", "FREQ", ] .iter() .map(|h| { @@ -656,15 +790,13 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { let header = Row::new(header_cells).height(1); let now = Utc::now(); - let builtin_tools = app.builtin_tools(); + // Single unified list — built-in + MCP — with a TYPE column to + // distinguish them. Order is whatever sort_tools last produced. + let all_tools: Vec<&ToolMetrics> = app.tool_metrics.iter().collect(); - let max_calls = builtin_tools - .iter() - .map(|t| t.call_count) - .max() - .unwrap_or(1); + let max_calls = all_tools.iter().map(|t| t.call_count).max().unwrap_or(1); - let rows: Vec = builtin_tools + let rows: Vec = all_tools .iter() .enumerate() .map(|(i, tool)| { @@ -745,185 +877,93 @@ fn draw_builtin_tool_table(f: &mut Frame, app: &App, area: Rect) { Style::default().fg(Color::Red) }; + // For MCP tools, show "server:tool" in the TOOL column via + // display_name(). The TYPE cell distinguishes builtin vs mcp. + let display = tool.display_name(); + let type_label = tool_type_label(tool); + let type_style = if tool.is_builtin() { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::Magenta) + }; + let freq_style = if tool.is_builtin() { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::Magenta) + }; + Row::new(vec![ - Cell::from(format!("{}{}", indicator, tool.tool_name)), + Cell::from(type_label).style(type_style), + Cell::from(format!("{}{}", indicator, display)), Cell::from(tool.call_count.to_string()), Cell::from(tool.error_count.to_string()).style(error_style), Cell::from(apr_str).style(apr_style), Cell::from(avg_str), Cell::from(range_str), Cell::from(last_str), - Cell::from(freq_bar).style(Style::default().fg(Color::Cyan)), + Cell::from(freq_bar).style(freq_style), ]) .style(style) }) .collect(); + let focused = app.effective_focus() == FocusPanel::Tools; + let border_color = if focused { Color::Cyan } else { Color::DarkGray }; + let title = if focused { + format!( + " Tools ({}) [s sort · d detail · Tab → Live] ", + app.tool_metrics.len() + ) + } else { + format!(" Tools ({}) [Tab to focus] ", app.tool_metrics.len()) + }; + let table = Table::new( rows, [ - Constraint::Min(14), - Constraint::Length(6), - Constraint::Length(4), - Constraint::Length(5), - Constraint::Length(7), - Constraint::Length(12), - Constraint::Length(5), - Constraint::Length(10), + Constraint::Length(8), // TYPE + Constraint::Min(20), // TOOL + Constraint::Length(6), // CALLS + Constraint::Length(4), // ERR + Constraint::Length(5), // APR% + Constraint::Length(7), // AVG + Constraint::Length(12), // RANGE + Constraint::Length(5), // LAST + Constraint::Length(10), // FREQ ], ) .header(header) .block( Block::default() .borders(Borders::ALL) - .title(" Tools ") - .border_style(Style::default().fg(Color::Cyan)), + .title(title) + .border_style(Style::default().fg(border_color)), ) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); let mut state = TableState::default(); - state.select(Some(app.selected_index)); - - f.render_stateful_widget(table, area, &mut state); -} - -fn draw_mcp_table(f: &mut Frame, app: &App, area: Rect) { - let mcp_tools = app.mcp_tools(); - - if mcp_tools.is_empty() { - return; + if focused { + state.select(Some(app.selected_index)); } - let header_cells = [ - "TOOL", "CALLS", "ERR", "APR%", "AVG", "RANGE", "LAST", "FREQ", - ] - .iter() - .map(|h| { - Cell::from(*h).style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - }); - let header = Row::new(header_cells).height(1); - - let now = Utc::now(); - let max_calls = mcp_tools.iter().map(|t| t.call_count).max().unwrap_or(1); - - let rows: Vec = mcp_tools - .iter() - .map(|tool| { - let last_str = match tool.last_call { - Some(last) => { - let diff = now - last; - let secs = diff.num_seconds(); - if secs < 0 { - "-".to_string() - } else if secs < 60 { - format!("{}s", secs) - } else if secs < 3600 { - format!("{}m", secs / 60) - } else if secs < 86400 { - format!("{}h", secs / 3600) - } else { - format!("{}d", secs / 86400) - } - } - None => "-".to_string(), - }; - - let avg_str = if tool.avg_duration_ms < 1000.0 { - format!("{}ms", tool.avg_duration_ms as u64) - } else { - format!("{:.1}s", tool.avg_duration_ms / 1000.0) - }; - - let format_duration = |ms: f64| -> String { - if ms < 1000.0 { - format!("{}ms", ms as u64) - } else { - format!("{:.1}s", ms / 1000.0) - } - }; - let range_str = format!( - "{}-{}", - format_duration(tool.min_duration_ms), - format_duration(tool.max_duration_ms) - ); - - let bar_width = 10; - let filled = ((tool.call_count as f64 / max_calls as f64) * bar_width as f64) as usize; - let empty = bar_width - filled; - let freq_bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); - - let indicator = if tool - .last_call - .map(|l| (now - l).num_seconds() < 2) - .unwrap_or(false) - { - "▶ " - } else { - " " - }; - - let error_style = if tool.error_count > 0 { - Style::default().fg(Color::Red) - } else { - Style::default().fg(Color::Green) - }; - - let approval_rate = tool.approval_rate(); - let apr_str = format!("{:.0}%", approval_rate); - let apr_style = if approval_rate >= 95.0 { - Style::default().fg(Color::Green) - } else if approval_rate >= 80.0 { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::Red) - }; - - Row::new(vec![ - Cell::from(format!("{}{}", indicator, tool.display_name())), - Cell::from(tool.call_count.to_string()), - Cell::from(tool.error_count.to_string()).style(error_style), - Cell::from(apr_str).style(apr_style), - Cell::from(avg_str), - Cell::from(range_str), - Cell::from(last_str), - Cell::from(freq_bar).style(Style::default().fg(Color::Magenta)), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Min(14), - Constraint::Length(6), - Constraint::Length(4), - Constraint::Length(5), - Constraint::Length(7), - Constraint::Length(12), - Constraint::Length(5), - Constraint::Length(10), - ], - ) - .header(header) - .block( - Block::default() - .borders(Borders::ALL) - .title(" MCP Tools ") - .border_style(Style::default().fg(Color::Magenta)), - ); - - f.render_widget(table, area); + f.render_stateful_widget(table, area, &mut state); } -fn draw_footer(f: &mut Frame, area: Rect) { - let footer = Line::from(vec![Span::styled( - " [q]uit [s]ort [p]ause [d]etail [t]ime [r] project [a]gent", - Style::default().fg(Color::DarkGray), - )]); +fn draw_footer(f: &mut Frame, app: &App, area: Rect) { + let focus_hint = match app.effective_focus() { + FocusPanel::Tools => "Tools", + FocusPanel::Live => "Live", + }; + let footer = Line::from(vec![ + Span::styled( + " [q]uit [s]ort [p]ause [d]etail [t]ime [r] project [a]gent [Tab] focus ", + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("· focus: {}", focus_hint), + Style::default().fg(Color::Cyan), + ), + ]); let paragraph = Paragraph::new(footer); f.render_widget(paragraph, area); From 1d688f29132ba613cfa0f62fcd5b292e181a0bc2 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 14:45:24 +0200 Subject: [PATCH 07/10] fix: tui bugs --- src/scraper/mod.rs | 6 ++++++ src/tui/ui.rs | 13 +++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/scraper/mod.rs b/src/scraper/mod.rs index 0b75dd2..a715306 100644 --- a/src/scraper/mod.rs +++ b/src/scraper/mod.rs @@ -104,7 +104,13 @@ pub struct LiveSession { /// Cumulative input tokens for the session (read from transcript). pub input_tokens: u64, pub output_tokens: u64, + /// Cumulative cache replay across all turns. Not displayed today + /// because every turn replays the full cached context — summing + /// produces inflated multi-million numbers — but kept on the struct + /// so a future "Cache: X / Y" breakdown can surface it. + #[allow(dead_code)] pub cache_read_tokens: u64, + #[allow(dead_code)] pub cache_creation_tokens: u64, /// RSS of the owning process in MB. pub mem_mb: u64, diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 42e1bd5..511a80d 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -464,12 +464,13 @@ fn draw_live_sessions( Some(_) => Color::Green, None => Color::DarkGray, }; - let tokens = humanize_u64( - session.input_tokens - + session.output_tokens - + session.cache_read_tokens - + session.cache_creation_tokens, - ); + // Show input + output only. Including cache_read produces inflated + // numbers (every turn replays the full cached context — a 50-turn + // session can easily report 10M+ even though actual model work was + // <1M tokens). Billing-wise, cache reads cost ~10% of input, so + // omitting them gives a more honest "how much work has this + // session done" number. + let tokens = humanize_u64(session.input_tokens + session.output_tokens); let mem_str = format!("{} MB", session.mem_mb); // Inline subagent summary so we don't need a second row per session. let mut task = if session.current_task.is_empty() { From 8e65b69a3c78ffc6121d1d11c8c7dd915bab9ee3 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sat, 16 May 2026 14:49:36 +0200 Subject: [PATCH 08/10] fix: CI --- src/config/mod.rs | 10 +++++++--- src/main.rs | 6 +++--- src/project.rs | 10 +++------- src/providers/copilot_chat.rs | 3 +-- src/providers/mod.rs | 6 ++++-- src/scraper/claude_sessions.rs | 23 ++++++++++------------- src/scraper/mod.rs | 1 - src/scraper/process.rs | 9 +++++---- src/storage/mod.rs | 16 ++++++---------- src/tui/app.rs | 23 +++++++++++++++-------- src/tui/ui.rs | 22 ++++++++++++++++++---- tests/storage_test.rs | 5 ++++- 12 files changed, 76 insertions(+), 58 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 6deb226..148917b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -176,8 +176,8 @@ pub fn ensure_otel_configured() -> Result<()> { /// `settings.json`. Idempotent — running twice is a no-op once the hook is /// already pointed at our script. Returns `Ok(true)` if any change was made. pub fn install_statusline_hook() -> Result { - let home = dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + let home = + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; install_statusline_hook_in(&home.join(".claude")) } @@ -324,7 +324,11 @@ mod tests { "enableTelemetry": true, "env": { "FOO": "bar" } }); - fs::write(&settings_path, serde_json::to_string_pretty(&original).unwrap()).unwrap(); + fs::write( + &settings_path, + serde_json::to_string_pretty(&original).unwrap(), + ) + .unwrap(); install_statusline_hook_in(&claude_dir).unwrap(); diff --git a/src/main.rs b/src/main.rs index 1a2b529..5caa11a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,9 +107,9 @@ fn run_setup(provider_name: &str) -> Result<()> { // since OTLP is the primary path. if provider_id == "claude_code" { match crate::config::install_statusline_hook() { - Ok(true) => println!( - " Installed agenttop StatusLine hook (rate-limit capture)" - ), + Ok(true) => { + println!(" Installed agenttop StatusLine hook (rate-limit capture)") + } Ok(false) => {} Err(e) => eprintln!(" Could not install StatusLine hook: {}", e), } diff --git a/src/project.rs b/src/project.rs index ec401ca..81e60d9 100644 --- a/src/project.rs +++ b/src/project.rs @@ -210,14 +210,10 @@ mod tests { let project_dir = tmp.path().join("-Users-test-myproject"); fs::create_dir_all(&project_dir).unwrap(); - create_test_sessions_index( - &project_dir, - &[("session-123", "/Users/test/myproject")], - ); + create_test_sessions_index(&project_dir, &[("session-123", "/Users/test/myproject")]); - let index = - ProjectResolver::parse_sessions_index(&project_dir.join("sessions-index.json")) - .unwrap(); + let index = ProjectResolver::parse_sessions_index(&project_dir.join("sessions-index.json")) + .unwrap(); assert_eq!(index.entries.len(), 1); assert_eq!(index.entries[0].session_id, "session-123"); assert_eq!(index.entries[0].project_path, "/Users/test/myproject"); diff --git a/src/providers/copilot_chat.rs b/src/providers/copilot_chat.rs index 0ae0aac..efdb816 100644 --- a/src/providers/copilot_chat.rs +++ b/src/providers/copilot_chat.rs @@ -67,8 +67,7 @@ impl Provider for CopilotChatProvider { let mut settings: serde_json::Value = serde_json::from_str(&content) .context("Failed to parse VSCode settings.json (may contain trailing commas; edit manually if so)")?; - let needs_update = settings - .get("github.copilot.chat.otel.enabled") + let needs_update = settings.get("github.copilot.chat.otel.enabled") != Some(&serde_json::Value::Bool(true)) || settings .get("github.copilot.chat.otel.otlpEndpoint") diff --git a/src/providers/mod.rs b/src/providers/mod.rs index ea0562d..1d716ff 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -11,8 +11,8 @@ pub mod claude_code; pub mod cline; pub mod copilot_chat; pub mod gemini_cli; -pub mod opencode; pub mod openai_codex; +pub mod opencode; pub mod qwen_code; use anyhow::Result; @@ -206,7 +206,9 @@ mod tests { Some("cline") ); assert_eq!( - registry.find_by_service_name("copilot-chat").map(|p| p.id()), + registry + .find_by_service_name("copilot-chat") + .map(|p| p.id()), Some("copilot_chat") ); assert_eq!( diff --git a/src/scraper/claude_sessions.rs b/src/scraper/claude_sessions.rs index 83ecf3a..7b4683b 100644 --- a/src/scraper/claude_sessions.rs +++ b/src/scraper/claude_sessions.rs @@ -105,10 +105,7 @@ pub fn scan( // transcript's model id usually doesn't say which one (the 1M variant // is selected via API beta header, not encoded in the name). If we // observe usage > 200k, the session must be in 1M mode — auto-bump. - let mut context_window = summary - .last_model - .as_deref() - .and_then(context_window_for); + let mut context_window = summary.last_model.as_deref().and_then(context_window_for); if let Some(window) = context_window && summary.latest_context_tokens > window && summary @@ -254,7 +251,9 @@ pub(crate) fn parse_transcript( let message = value.get("message"); // Model name (latest wins). - if let Some(model) = message.and_then(|m| m.get("model")).and_then(|v| v.as_str()) + if let Some(model) = message + .and_then(|m| m.get("model")) + .and_then(|v| v.as_str()) { summary.last_model = Some(model.to_string()); } @@ -325,8 +324,7 @@ pub(crate) fn parse_transcript( { for block in blocks { if block.get("type").and_then(|v| v.as_str()) == Some("tool_result") - && let Some(id) = - block.get("tool_use_id").and_then(|v| v.as_str()) + && let Some(id) = block.get("tool_use_id").and_then(|v| v.as_str()) { pending_tool_ids.remove(id); } @@ -455,11 +453,7 @@ mod tests { let dir = tempdir().unwrap(); let path = dir.path().join("t.jsonl"); let mut f = fs::File::create(&path).unwrap(); - writeln!( - f, - r#"{{"type":"user","message":{{"content":"hi"}}}}"# - ) - .unwrap(); + writeln!(f, r#"{{"type":"user","message":{{"content":"hi"}}}}"#).unwrap(); writeln!( f, r#"{{"type":"assistant","message":{{"model":"claude-opus-4-5","usage":{{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":200,"cache_creation_input_tokens":10}},"content":[{{"type":"text","text":"ok"}}]}}}}"# @@ -483,7 +477,10 @@ mod tests { assert_eq!(s.last_model.as_deref(), Some("claude-opus-4-5")); assert_eq!(s.current_task, "Edit src/main.rs"); assert_eq!(s.last_line_kind, LastLineKind::AssistantWithPendingTool); - assert_eq!(offsets.get(&path).copied(), Some(fs::metadata(&path).unwrap().len())); + assert_eq!( + offsets.get(&path).copied(), + Some(fs::metadata(&path).unwrap().len()) + ); } #[test] diff --git a/src/scraper/mod.rs b/src/scraper/mod.rs index a715306..9fdb832 100644 --- a/src/scraper/mod.rs +++ b/src/scraper/mod.rs @@ -322,4 +322,3 @@ impl Scraper { orphans } } - diff --git a/src/scraper/process.rs b/src/scraper/process.rs index 87399c8..91e31a0 100644 --- a/src/scraper/process.rs +++ b/src/scraper/process.rs @@ -81,9 +81,7 @@ impl ProcessScanner { .collect::>() .join(" "); let name = proc.name().to_string_lossy().into_owned(); - let cwd = proc - .cwd() - .map(|p| p.to_string_lossy().into_owned()); + let cwd = proc.cwd().map(|p| p.to_string_lossy().into_owned()); self.cache.insert( pid_u32, @@ -96,7 +94,10 @@ impl ProcessScanner { cwd, }, ); - self.children_by_ppid.entry(ppid_u32).or_default().push(pid_u32); + self.children_by_ppid + .entry(ppid_u32) + .or_default() + .push(pid_u32); } } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index b88e7ca..8b9975a 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -352,10 +352,7 @@ impl StorageHandle { rx.recv()? } - pub fn get_distinct_service_names( - &self, - since: Option>, - ) -> Result> { + pub fn get_distinct_service_names(&self, since: Option>) -> Result> { let (tx, rx) = mpsc::channel(); self.sender .send(StorageCommand::GetDistinctServiceNames { since, tx })?; @@ -365,11 +362,7 @@ impl StorageHandle { /// Tokens-per-second over the most recent `window_secs`, bucketed into /// `points` equal-width slots. Returned Vec is always length `points` /// (zero-padded at the front if there's no data). - pub fn get_token_rate_series( - &self, - window_secs: u64, - points: usize, - ) -> Result> { + pub fn get_token_rate_series(&self, window_secs: u64, points: usize) -> Result> { let (tx, rx) = mpsc::channel(); self.sender.send(StorageCommand::GetTokenRateSeries { window_secs, @@ -1097,7 +1090,10 @@ impl Storage { buckets[idx] += count; } - Ok(buckets.into_iter().map(|c| c as f64 / bucket_secs).collect()) + Ok(buckets + .into_iter() + .map(|c| c as f64 / bucket_secs) + .collect()) } /// Get distinct projects detected from file paths in telemetry diff --git a/src/tui/app.rs b/src/tui/app.rs index 931324d..80cabc7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -188,15 +188,20 @@ impl App { .map(|p| p.name.clone()) .unwrap_or_else(|| { // Fallback: use first 8 chars of session ID - format!("session:{}", &session.session_id[..8.min(session.session_id.len())]) + format!( + "session:{}", + &session.session_id[..8.min(session.session_id.len())] + ) }); - let entry = project_aggregates.entry(project_name.clone()).or_insert(ProjectInfo { - name: project_name, - event_count: 0, - first_seen: session.first_seen, - last_seen: session.last_seen, - }); + let entry = project_aggregates + .entry(project_name.clone()) + .or_insert(ProjectInfo { + name: project_name, + event_count: 0, + first_seen: session.first_seen, + last_seen: session.last_seen, + }); entry.event_count += session.event_count; @@ -417,7 +422,9 @@ impl App { /// there are no live sessions. pub fn toggle_focus(&mut self) { self.focus = match self.focus { - FocusPanel::Tools if !self.scraper_snapshot.live_sessions.is_empty() => FocusPanel::Live, + FocusPanel::Tools if !self.scraper_snapshot.live_sessions.is_empty() => { + FocusPanel::Live + } _ => FocusPanel::Tools, }; } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 511a80d..8b44ab7 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -174,10 +174,16 @@ fn format_host_strip(host: &HostMetrics) -> Vec> { let mem_color = pct_color(host.mem_pct); vec![ Span::styled("CPU ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{:.0}%", host.cpu_pct), Style::default().fg(cpu_color)), + Span::styled( + format!("{:.0}%", host.cpu_pct), + Style::default().fg(cpu_color), + ), Span::raw(" "), Span::styled("MEM ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{:.0}%", host.mem_pct), Style::default().fg(mem_color)), + Span::styled( + format!("{:.0}%", host.mem_pct), + Style::default().fg(mem_color), + ), Span::raw(" "), Span::styled("LOAD ", Style::default().fg(Color::DarkGray)), Span::styled( @@ -502,7 +508,11 @@ fn draw_live_sessions( } // Bright border when focused so the user knows which panel j/k navigates. - let border_color = if focused { Color::Green } else { Color::DarkGray }; + let border_color = if focused { + Color::Green + } else { + Color::DarkGray + }; let title = if focused { " Live sessions [j/k navigate · Tab → Tools] " } else { @@ -909,7 +919,11 @@ fn draw_tools_table(f: &mut Frame, app: &App, area: Rect) { .collect(); let focused = app.effective_focus() == FocusPanel::Tools; - let border_color = if focused { Color::Cyan } else { Color::DarkGray }; + let border_color = if focused { + Color::Cyan + } else { + Color::DarkGray + }; let title = if focused { format!( " Tools ({}) [s sort · d detail · Tab → Live] ", diff --git a/tests/storage_test.rs b/tests/storage_test.rs index 2c44fdd..dfdaf18 100644 --- a/tests/storage_test.rs +++ b/tests/storage_test.rs @@ -566,7 +566,10 @@ fn test_token_metrics_with_time_filter_returns_recent_data() { let since = Utc::now() - Duration::seconds(60); let metrics = storage.get_token_metrics(Some(since)).unwrap(); - assert_eq!(metrics.input_tokens, 999, "time-filtered query lost the row"); + assert_eq!( + metrics.input_tokens, 999, + "time-filtered query lost the row" + ); } #[test] From 8baa2c9bd0eb3d7f0f29723967dbf233f36ecc28 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sun, 17 May 2026 20:32:47 +0300 Subject: [PATCH 09/10] fix: clippy --- src/scraper/claude_sessions.rs | 2 +- src/tui/app.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scraper/claude_sessions.rs b/src/scraper/claude_sessions.rs index 7b4683b..f31ae1e 100644 --- a/src/scraper/claude_sessions.rs +++ b/src/scraper/claude_sessions.rs @@ -99,7 +99,7 @@ pub fn scan( } } // Sort children by RSS desc so the biggest is first. - children.sort_by(|a, b| b.mem_kb.cmp(&a.mem_kb)); + children.sort_by_key(|c| std::cmp::Reverse(c.mem_kb)); // Window detection: opus has both 200k and 1M variants, but the // transcript's model id usually doesn't say which one (the 1M variant diff --git a/src/tui/app.rs b/src/tui/app.rs index 80cabc7..c406df0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -226,7 +226,7 @@ impl App { // Convert to sorted vector (by event_count descending) let mut projects: Vec = project_aggregates.into_values().collect(); - projects.sort_by(|a, b| b.event_count.cmp(&a.event_count)); + projects.sort_by_key(|p| std::cmp::Reverse(p.event_count)); self.detected_projects = projects; self.compaction_stats = self From 749d3486b0818f1af25824a6d429fe78715103a7 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Mon, 18 May 2026 13:03:05 +0300 Subject: [PATCH 10/10] fix: CI --- tests/app_state_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/app_state_test.rs b/tests/app_state_test.rs index 5fe15b2..7640aff 100644 --- a/tests/app_state_test.rs +++ b/tests/app_state_test.rs @@ -89,7 +89,7 @@ fn test_sort_by_calls_desc() { }, ]; - tools.sort_by(|a, b| b.call_count.cmp(&a.call_count)); + tools.sort_by_key(|t| std::cmp::Reverse(t.call_count)); assert_eq!(tools[0].tool_name, "Write"); assert_eq!(tools[1].tool_name, "Bash"); @@ -126,7 +126,7 @@ fn test_sort_by_calls_asc() { }, ]; - tools.sort_by(|a, b| a.call_count.cmp(&b.call_count)); + tools.sort_by_key(|t| t.call_count); assert_eq!(tools[0].tool_name, "Read"); assert_eq!(tools[1].tool_name, "Bash");