diff --git a/README.md b/README.md index 3191e10..8006cc7 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,102 @@ -# abtop +# abtop — Factory Droid edition **Like [btop](https://github.com/aristocratos/btop), but for your AI coding agents.** -See every Claude Code, Codex CLI, and OpenCode session at a glance — token usage, context window %, rate limits, child processes, open ports, and more. -Claude Code, Codex CLI, and OpenCode sessions are discovered from local process/file state, so multiple active profiles are supported across macOS, Linux, and Windows. +Monitor Claude Code, Codex CLI, OpenCode, and **Factory Droid** sessions at a glance — token usage, context window %, rate limits, child processes, open ports, and more. All read-only, from local files and processes. No API keys, no auth. -![demo](https://raw.githubusercontent.com/graykode/abtop/main/assets/demo.gif) +> **Fork notice.** This is a fork of [graykode/abtop](https://github.com/graykode/abtop). Everything upstream is preserved; the additions live under [What's different](#whats-different). -## Why +![demo](https://raw.githubusercontent.com/graykode/abtop/main/assets/demo.gif) -- Running 3+ agents across projects? See them all in one screen. -- Hitting rate limits? Watch your quota in real-time. -- Agent spawned a server and forgot to kill it? Orphan port detection. -- Context window filling up? Per-session % bars with warnings. +## Features -All read-only. No API keys. No auth. +- Single-screen monitoring for **Claude Code**, **Codex CLI**, **OpenCode**, and **Factory Droid**. +- **Factory Droid**: live sessions + worker subagents, custom-model catalog, missions, and config validation — read from `~/.factory`. +- **Accurate token tracking for Factory Droid**: usage is parsed from `sessions/**/.settings.json` → `tokenUsage`; worker tokens are attributed via the parent's `childInclusiveTokenUsageBySessionId`. +- Real-time rate limits, context window %, per-session token rate, orphan port detection. +- 12 built-in themes (4 colorblind-friendly), EN/ZH UI, runtime config overlay. -## Install +## What's different -### macOS / Linux +Main change: a **Factory Droid collector** (`src/collector/factory.rs`) plus two new panels. -```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/graykode/abtop/releases/latest/download/abtop-installer.sh | sh -``` +| Area | Upstream | This fork | +|---|---|---| +| Factory Droid | — | ✅ sessions, models, missions, config validation | +| Token usage (Factory Droid) | — | ✅ from `*.settings.json` → `tokenUsage` | +| Worker subagent tokens | — | ✅ from `childInclusiveTokenUsageBySessionId` | +| Models / missions panels | — | ✅ toggle with `8` / `9` | +| JSON snapshot | — | `factory` block (models, missions, issues) | -### Cargo +**Token fix** (commit `637dcb1`): Factory Droid does not write token usage into session `.jsonl` logs — it lives in `sessions/**/.settings.json`. The collector now scans and parses those files, which populates the Tokens column, the tokens panel, footer totals, and the token rate. -```bash -cargo install abtop -``` +## Install / Build -### Windows +The fork ships as source. Build it yourself: -Native support — no WSL required. Uses `sysinfo` for process info and `netstat -ano` for listening ports. +```bash +# Build from source (Windows / macOS / Linux — native, no WSL required) +cargo build --release -```powershell -powershell -c "irm https://github.com/graykode/abtop/releases/latest/download/abtop-installer.ps1 | iex" +# Install from the local checkout +cargo install --path . ``` -Or `cargo install abtop` from any terminal with Git in PATH. Claude Code config is resolved automatically from `%USERPROFILE%\.claude`. - -### Other - -Pre-built binaries for all platforms are available on the [GitHub Releases](https://github.com/graykode/abtop/releases) page. +Pre-built binaries: upstream [releases](https://github.com/graykode/abtop/releases). ## Usage ```bash abtop # Launch TUI abtop --once # Print snapshot and exit -abtop --json # Print one JSON snapshot and exit (for scripts/tools) +abtop --json # One JSON snapshot and exit (for scripts/tools) +abtop --status-json # Compact status JSON without local paths/prompts abtop --setup # Install rate limit collection hook abtop --theme dracula # Launch with a specific theme ``` -Recommended terminal size: **120x40** or larger. Minimum 80x24 — panels hide gracefully when small. - -### tmux +Recommended terminal size: **120x40**. Minimum 80x24 — panels degrade gracefully. -abtop works standalone, but running inside tmux unlocks session jumping — press `Enter` to switch directly to the pane running that agent. +Factory Droid models/missions panels are off by default. Toggle at runtime with `8` / `9`, or enable in `~/.config/abtop/config.toml`: -```bash -tmux new -s work -# pane 0: abtop -# pane 1: claude (project A) -# pane 2: claude (project B) -# → Enter on a session in abtop jumps to its pane +```toml +show_models = true +show_missions = true ``` -## Supported Agents - -| Feature | Claude Code | Codex CLI | OpenCode | -| ----------------- | :---------: | :-------: | :------: | -| Session Discovery | ✅ | ✅ | ✅ | -| Token Tracking | ✅ | ✅ | ✅ | -| Context Window % | ✅ | ✅ | ❌ | -| Status Detection | ✅ | ✅ | ✅ | -| Current Task | ✅ | ✅ | ❌ | -| Rate Limit | ✅ | ✅ | ❌ | -| Git Status | ✅ | ✅ | ✅ | -| Children / Ports | ✅ | ✅ | ✅ | -| Subagents | ✅ | ❌ | ❌ | -| Memory Status | ✅ | ❌ | ❌ | - -OpenCode support reads the local SQLite database at `~/.local/share/opencode/opencode.db` and requires `sqlite3` in `PATH`. +`hidden_agents = ["factory"]` disables the Factory Droid collector entirely. -## Themes - -12 built-in themes, including 4 colorblind-friendly options (`high-contrast`, `protanopia`, `deuteranopia`, `tritanopia`). Press `t` to cycle at runtime, or launch with `--theme `. Your choice is saved to `~/.config/abtop/config.toml`. - -| btop (default) | dracula | catppuccin | -|:-:|:-:|:-:| -| ![btop](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/btop.png) | ![dracula](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/dracula.png) | ![catppuccin](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/catppuccin.png) | - -| tokyo-night | gruvbox | nord | -|:-:|:-:|:-:| -| ![tokyo-night](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/tokyo-night.png) | ![gruvbox](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/gruvbox.png) | ![nord](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/nord.png) | - -Colorblind-friendly themes: - -| high-contrast | protanopia | -|:-:|:-:| -| ![high-contrast](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/high-contrast.png) | ![protanopia](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/protanopia.png) | - -| deuteranopia | tritanopia | -|:-:|:-:| -| ![deuteranopia](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/deuteranopia.png) | ![tritanopia](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/tritanopia.png) | - -Light themes (`light` — Solarized cream, `white` — GitHub-style pure white) for bright terminals: +## Supported Agents -| light | white | -|:-:|:-:| -| ![light](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/light.png) | ![white](https://raw.githubusercontent.com/graykode/abtop/main/assets/themes/white.png) | +| Feature | Claude Code | Codex CLI | OpenCode | Factory Droid | +| ----------------- | :---------: | :-------: | :------: | :-----------: | +| Session Discovery | ✅ | ✅ | ✅ | ✅ | +| Token Tracking | ✅ | ✅ | ✅ | ✅ | +| Context Window % | ✅ | ✅ | ❌ | ❌ | +| Status Detection | ✅ | ✅ | ✅ | ✅ | +| Current Task | ✅ | ✅ | ❌ | ✅ | +| Rate Limit | ✅ | ✅ | ❌ | ❌ | +| Git Status | ✅ | ✅ | ✅ | ❌ | +| Children / Ports | ✅ | ✅ | ✅ | ❌ | +| Subagents | ✅ | ❌ | ❌ | ✅ | +| Memory Status | ✅ | ❌ | ❌ | ❌ | + +Factory Droid support reads `~/.factory`: live sessions from `sessions-index.json` (orchestrator plus worker subagents), token usage from `sessions/**/.settings.json`, the custom-model catalog from `settings.json` / `factory-settings.json`, missions from `missions//`, and a config validator that flags duplicate ids, dangling default model references, and stale state files. API keys are never read. ## Configuration -`~/.config/abtop/config.toml` supports: +`~/.config/abtop/config.toml`: ```toml theme = "btop" -# Hide specific agent CLIs from the TUI (case-insensitive). -# Useful if you only use one agent and want a cleaner view. hidden_agents = ["codex"] -# Additional Claude Code profile roots to scan. -# abtop also auto-discovers ~/.claude and ~/.claude-* roots that contain -# both sessions/ and projects/. +show_models = true +show_missions = true claude_config_dirs = ["~/.claude-personal", "~/.claude-work-team"] -# UI language. Omit or leave empty to auto-detect from LANG. language = "zh" ``` -### Supported Languages - -| Code | Language | -| ---- | ------------------- | -| `en` | English (default) | -| `zh` | Simplified Chinese | - -When `language` is unset, abtop auto-detects from `LANG` — any value starting with `zh` switches to Simplified Chinese, otherwise English. +UI language: `en` (default) or `zh`; auto-detected from `LANG` when unset. ## Key Bindings @@ -148,25 +107,23 @@ When `language` is unset, abtop auto-detects from `LANG` — any value starting | `x` | Kill selected session | | `X` | Kill all orphan ports | | `t` | Cycle theme | -| `1`–`5` | Toggle panel visibility | +| `1`–`9` | Toggle panel visibility | | `Esc` | Open/close config page | | `q` | Quit | | `r` | Force refresh | ## Library / JSON snapshot -abtop is also a library crate, so local tools can reuse its data-collection -layer in-process — no re-scanning, no subprocesses — and serialize the same -state the TUI renders. +abtop is also a library crate, so local tools can reuse its data-collection layer in-process and serialize the same state the TUI renders: ```bash -abtop --json # one-shot JSON snapshot for scripts +abtop --json # one-shot JSON snapshot for scripts +abtop --status-json # compact status summary; omits local paths, prompts, session ids ``` -For long-running consumers, build an `App`, refresh it with -`App::tick_no_summaries()` (which never spawns `claude --print`, so it doesn't -touch your Claude quota), and call `App::to_snapshot(interval_ms)` to get a -JSON-serializable [`Snapshot`]: +The **JSON snapshot includes** `chat_messages`, `summary`, working directories, tool-call previews, token counts, and a `factory` block — matching the TUI view. + +For long-running consumers, build an `App`, refresh with `App::tick_no_summaries()` (never spawns `claude --print`), and call `App::to_snapshot(interval_ms)`: ```rust,no_run use abtop::app::App; @@ -180,19 +137,9 @@ app.tick_no_summaries(); let json = serde_json::to_string(&app.to_snapshot(2_000)).unwrap(); ``` -`App` is not `Send` (it owns the collectors), so keep it on one thread and pass -the serialized JSON elsewhere. [abtop-web-ui](https://github.com/XKHoshizora/abtop-web-ui) -is a reference consumer: a local-first web dashboard built on exactly this API. - ## Privacy -abtop reads local files and local process/open-file metadata only. No API keys, no auth. In the TUI and `--once` output, tool names and file paths are shown, but file contents and prompt text are never displayed. Session summaries are generated via `claude --print`, which makes its own API call — this is the only indirect network usage. - -The JSON snapshot includes richer local dashboard data, including `summary`, `chat_messages`, working directories, config roots, tool-call previews, child process commands, token counts, and port metadata. Chat text is bounded and redacted by the collectors, but it is still derived from local transcripts and may contain sensitive project context. Treat JSON snapshots as local/private data and avoid writing them to shared logs or exposing them on a network without your own access controls. - -## Acknowledgements - -Huge thanks to [@tbouquet](https://github.com/tbouquet) for driving much of abtop's recent shape — themes, config overlay and panel toggles, session filtering, subagent tree view, the context window gauge with compaction detection, plus a steady stream of fixes and security hardening along the way. +abtop reads local files and local process/open-file metadata only. No API keys, no auth. Tool names and file paths are shown, but file contents and prompt text are never displayed. The full `--json` snapshot includes local dashboard data (`chat_messages`, `summary`, working directories, tool-call previews, token counts) plus a `factory` block — treat it as private and don't expose it without your own access controls. `--status-json` emits only aggregate health/quota fields and **omits local paths**. ## License diff --git a/src/app.rs b/src/app.rs index 23783a8..06d3610 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::collector::factory::{FactoryConfigIssue, FactoryMission, FactoryModel}; use crate::collector::{read_rate_limits, McpServer, MultiCollector}; use crate::host_info::{AgentAggregate, HostMetrics, HostSampler}; use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus}; @@ -14,6 +15,20 @@ const MAX_SUMMARY_JOBS: usize = 3; /// Max summary attempts per session before giving up. const MAX_SUMMARY_RETRIES: u32 = 2; +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum RateLimitWindow { + FiveHour, + SevenDay, +} + +#[derive(Clone, Copy, Debug, Default)] +struct RateLimitSpendSample { + pct: f64, + observed_at: u64, + burn_pct_per_hour: Option, + eta_secs: Option, +} + /// Produce a terminal-safe fallback summary from a raw prompt. fn sanitize_fallback(prompt: &str, max_len: usize) -> String { prompt @@ -66,9 +81,11 @@ impl NarrowTab { pub enum NarrowSection { Sessions, Projects, + Missions, Context, Quota, Tokens, + Models, Ports, Mcp, } @@ -76,8 +93,8 @@ pub enum NarrowSection { impl NarrowSection { pub fn tab(self) -> NarrowTab { match self { - Self::Sessions | Self::Projects => NarrowTab::Work, - Self::Context | Self::Quota | Self::Tokens => NarrowTab::Usage, + Self::Sessions | Self::Projects | Self::Missions => NarrowTab::Work, + Self::Context | Self::Quota | Self::Tokens | Self::Models => NarrowTab::Usage, Self::Ports | Self::Mcp => NarrowTab::System, } } @@ -93,6 +110,8 @@ pub struct App { pub rate_limits: Vec, /// Per-session previous token totals, keyed by (agent_cli, session_id). prev_tokens: HashMap<(String, String), u64>, + /// Last observed quota percentages, keyed by (source, window), for burn-rate estimates. + rate_limit_spend_samples: HashMap<(String, RateLimitWindow), RateLimitSpendSample>, /// Rate limit poll counter (read every 5 ticks = 10s) rate_limit_counter: u32, collector: MultiCollector, @@ -120,6 +139,17 @@ pub struct App { pub show_ports: bool, pub show_sessions: bool, pub show_mcp: bool, + pub show_models: bool, + pub show_missions: bool, + /// Factory Droid custom-model catalog (populated on slow ticks when the + /// droid workspace is present). + pub factory_models: Vec, + /// Factory Droid mission list (populated on slow ticks). + pub factory_missions: Vec, + /// Factory Droid config validation findings. + pub factory_issues: Vec, + /// True when a Factory Droid app process was detected on the last tick. + pub factory_app_running: bool, pub narrow_tab: NarrowTab, pub active_narrow_section: Option, pub maximized_narrow_section: Option, @@ -178,6 +208,7 @@ impl App { token_rates: VecDeque::with_capacity(GRAPH_HISTORY_LEN), rate_limits: Vec::new(), prev_tokens: HashMap::new(), + rate_limit_spend_samples: HashMap::new(), rate_limit_counter: 5, collector, summaries, @@ -196,6 +227,12 @@ impl App { show_ports: panels.ports, show_sessions: panels.sessions, show_mcp: panels.mcp, + show_models: panels.models, + show_missions: panels.missions, + factory_models: Vec::new(), + factory_missions: Vec::new(), + factory_issues: Vec::new(), + factory_app_running: false, narrow_tab: NarrowTab::Work, active_narrow_section: Some(NarrowSection::Sessions), maximized_narrow_section: None, @@ -240,6 +277,8 @@ impl App { 5 => self.show_ports = !self.show_ports, 6 => self.show_sessions = !self.show_sessions, 7 => self.show_mcp = !self.show_mcp, + 8 => self.show_models = !self.show_models, + 9 => self.show_missions = !self.show_missions, _ => return, } self.persist_panel_visibility(); @@ -269,6 +308,8 @@ impl App { ports: self.show_ports, sessions: self.show_sessions, mcp: self.show_mcp, + models: self.show_models, + missions: self.show_missions, }; if let Err(e) = crate::config::save_panel_visibility(&panels) { self.set_status(format!("panels save failed: {}", e)); @@ -287,7 +328,7 @@ impl App { } pub fn config_item_count(&self) -> usize { - 8 // theme + 7 panel toggles + 10 // theme + 9 panel toggles } pub fn config_select_next(&mut self) { @@ -313,6 +354,8 @@ impl App { 5 => self.show_ports = !self.show_ports, 6 => self.show_sessions = !self.show_sessions, 7 => self.show_mcp = !self.show_mcp, + 8 => self.show_models = !self.show_models, + 9 => self.show_missions = !self.show_missions, _ => return, } self.persist_panel_visibility(); @@ -321,8 +364,10 @@ impl App { pub fn narrow_tab_visible(&self, tab: NarrowTab) -> bool { match tab { - NarrowTab::Work => self.show_sessions || self.show_projects, - NarrowTab::Usage => self.show_context || self.show_quota || self.show_tokens, + NarrowTab::Work => self.show_sessions || self.show_projects || self.show_missions, + NarrowTab::Usage => { + self.show_context || self.show_quota || self.show_tokens || self.show_models + } NarrowTab::System => self.show_ports || self.show_mcp, } } @@ -384,9 +429,11 @@ impl App { match section { NarrowSection::Sessions => self.show_sessions, NarrowSection::Projects => self.show_projects, + NarrowSection::Missions => self.show_missions, NarrowSection::Context => self.show_context, NarrowSection::Quota => self.show_quota, NarrowSection::Tokens => self.show_tokens, + NarrowSection::Models => self.show_models, NarrowSection::Ports => self.show_ports, NarrowSection::Mcp => self.show_mcp, } @@ -394,11 +441,16 @@ impl App { pub fn visible_narrow_sections(&self, tab: NarrowTab) -> Vec { let sections: &[NarrowSection] = match tab { - NarrowTab::Work => &[NarrowSection::Sessions, NarrowSection::Projects], + NarrowTab::Work => &[ + NarrowSection::Sessions, + NarrowSection::Projects, + NarrowSection::Missions, + ], NarrowTab::Usage => &[ NarrowSection::Context, NarrowSection::Quota, NarrowSection::Tokens, + NarrowSection::Models, ], NarrowTab::System => &[NarrowSection::Ports, NarrowSection::Mcp], }; @@ -509,6 +561,10 @@ impl App { self.sessions = self.collector.collect(); self.orphan_ports = self.collector.orphan_ports.clone(); self.mcp_servers = self.collector.mcp_servers.clone(); + self.factory_models = self.collector.factory_models().to_vec(); + self.factory_missions = self.collector.factory_missions().to_vec(); + self.factory_issues = self.collector.factory_issues().to_vec(); + self.factory_app_running = self.collector.factory_app_running(); self.host_metrics = self.host_sampler.sample(); self.agent_aggregate = AgentAggregate::from_sessions(&self.sessions); if self.selected >= self.sessions.len() && !self.sessions.is_empty() { @@ -541,6 +597,11 @@ impl App { self.rate_limits = read_rate_limits(&extra_dirs); // Merge live rate limits from agent collectors (e.g. Codex JSONL parsing) self.rate_limits.extend(self.collector.agent_rate_limits()); + annotate_rate_limit_spend( + &mut self.rate_limits, + &mut self.rate_limit_spend_samples, + now_secs(), + ); } else { self.rate_limit_counter += 1; } @@ -1062,6 +1123,85 @@ fn save_summary_cache(summaries: &HashMap) { } } +fn annotate_rate_limit_spend( + rate_limits: &mut [RateLimitInfo], + samples: &mut HashMap<(String, RateLimitWindow), RateLimitSpendSample>, + now: u64, +) { + for rl in rate_limits { + let (burn, eta) = update_rate_limit_spend_sample( + &rl.source, + RateLimitWindow::FiveHour, + rl.five_hour_pct, + rl.updated_at, + now, + samples, + ); + rl.five_hour_burn_pct_per_hour = burn; + rl.five_hour_eta_secs = eta; + + let (burn, eta) = update_rate_limit_spend_sample( + &rl.source, + RateLimitWindow::SevenDay, + rl.seven_day_pct, + rl.updated_at, + now, + samples, + ); + rl.seven_day_burn_pct_per_hour = burn; + rl.seven_day_eta_secs = eta; + } +} + +fn update_rate_limit_spend_sample( + source: &str, + window: RateLimitWindow, + pct: Option, + updated_at: Option, + now: u64, + samples: &mut HashMap<(String, RateLimitWindow), RateLimitSpendSample>, +) -> (Option, Option) { + let key = (source.to_ascii_lowercase(), window); + let Some(pct) = pct.map(|value| value.clamp(0.0, 100.0)) else { + samples.remove(&key); + return (None, None); + }; + let observed_at = updated_at.unwrap_or(now); + + let mut current = RateLimitSpendSample { + pct, + observed_at, + ..Default::default() + }; + + if let Some(prev) = samples.get(&key).copied() { + if observed_at == prev.observed_at { + current.burn_pct_per_hour = prev.burn_pct_per_hour; + current.eta_secs = prev.eta_secs; + } else if observed_at > prev.observed_at && pct > prev.pct { + let elapsed_secs = observed_at - prev.observed_at; + let burn_pct_per_hour = (pct - prev.pct) * 3600.0 / elapsed_secs as f64; + let remaining_pct = (100.0 - pct).max(0.0); + current.burn_pct_per_hour = Some(burn_pct_per_hour); + current.eta_secs = if burn_pct_per_hour > 0.0 { + Some((remaining_pct * 3600.0 / burn_pct_per_hour).ceil() as u64) + } else { + None + }; + } + } + + samples.insert(key, current); + (current.burn_pct_per_hour, current.eta_secs) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + /// Threshold above which a rate-limited bucket is surfaced as RateLimited /// in the session list. 90% leaves enough headroom to catch near-saturation /// before the account actually blocks. @@ -1154,9 +1294,52 @@ mod tests { seven_day_pct: None, seven_day_resets_at: None, updated_at: None, + ..Default::default() } } + #[test] + fn rate_limit_spend_estimates_burn_and_eta() { + let mut samples = HashMap::new(); + let mut limits = vec![RateLimitInfo { + source: "codex".to_string(), + five_hour_pct: Some(10.0), + five_hour_resets_at: Some(20_000), + updated_at: Some(1_000), + ..Default::default() + }]; + + annotate_rate_limit_spend(&mut limits, &mut samples, 1_000); + assert_eq!(limits[0].five_hour_burn_pct_per_hour, None); + assert_eq!(limits[0].five_hour_eta_secs, None); + + limits[0].five_hour_pct = Some(15.0); + limits[0].updated_at = Some(1_600); + annotate_rate_limit_spend(&mut limits, &mut samples, 1_600); + + assert_eq!(limits[0].five_hour_burn_pct_per_hour, Some(30.0)); + assert_eq!(limits[0].five_hour_eta_secs, Some(10_200)); + } + + #[test] + fn rate_limit_spend_clears_on_window_reset() { + let mut samples = HashMap::new(); + let mut limits = vec![RateLimitInfo { + source: "codex".to_string(), + five_hour_pct: Some(80.0), + updated_at: Some(1_000), + ..Default::default() + }]; + + annotate_rate_limit_spend(&mut limits, &mut samples, 1_000); + limits[0].five_hour_pct = Some(70.0); + limits[0].updated_at = Some(1_600); + annotate_rate_limit_spend(&mut limits, &mut samples, 1_600); + + assert_eq!(limits[0].five_hour_burn_pct_per_hour, None); + assert_eq!(limits[0].five_hour_eta_secs, None); + } + #[test] fn test_rate_limited_promotion_is_per_agent_cli() { // Claude is saturated, Codex is not. Only the Claude session should diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 3c7b4c0..83f15c0 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1243,7 +1243,9 @@ fn parse_codex_jsonl(path: &Path) -> Option { info.seven_day_resets_at = resets; } } - result.rate_limit = Some(info); + if info.five_hour_pct.is_some() || info.seven_day_pct.is_some() { + result.rate_limit = Some(info); + } } } Some("agent_message") => { @@ -1846,6 +1848,21 @@ mod tests { assert_eq!(rl.seven_day_pct, Some(14.0)); } + #[test] + fn test_parse_codex_ignores_empty_rate_limits() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"output_tokens":1},"last_token_usage":{"input_tokens":1,"output_tokens":1}},"rate_limits":{"limit_id":"codex"}}}"#, + ], + ); + + let result = parse_codex_jsonl(file.path()).unwrap(); + assert!(result.rate_limit.is_none()); + } + #[test] fn test_parse_codex_rate_limits_ignores_model_specific_limits() { let mut file = tempfile::NamedTempFile::new().unwrap(); diff --git a/src/collector/factory.rs b/src/collector/factory.rs new file mode 100644 index 0000000..5bdddd1 --- /dev/null +++ b/src/collector/factory.rs @@ -0,0 +1,1445 @@ +//! Collector for Factory Droid (the `~/.factory` workspace): live sessions, +//! the custom-model catalog, missions, and config validation. +//! +//! Factory Droid is a local desktop agent. It maintains `~/.factory/`: +//! - `sessions-index.json` — every session's last-update mtime, cwd, title, +//! and orchestrator→worker relationships. The collector treats an index +//! entry as live while its mtime is recent, or while any of its workers is +//! (a mission orchestrator idles between worker turns, so a parent is kept +//! alive by a freshly-updated worker). Liveness does not depend on guessing +//! the desktop app's process name. +//! - `sessions/**/.settings.json` — per-session token usage (`tokenUsage`) +//! and the parent's view of each worker's usage +//! (`childInclusiveTokenUsageBySessionId`). +//! - `settings.json` / `factory-settings.json` — the custom model catalog. +//! - `missions//` — per-mission state, model settings, and working dir. +//! +//! Secrets are never surfaced: the model catalog keeps `base_url` but drops +//! `apiKey`. + +use crate::model::{AgentSession, SessionStatus, SubAgent}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Index entries updated more recently than this are surfaced as live. +const LIVE_WINDOW_MS: u64 = 10 * 60 * 1000; +/// An index entry updated more recently than this is "thinking". +const THINKING_MS: u64 = 15 * 1000; +/// Updated more recently than this → "executing". +const EXECUTING_MS: u64 = 120 * 1000; +/// Cap on reported config issues. +const MAX_ISSUES: usize = 50; + +/// Case-insensitive tokens used to detect a running Factory Droid app. +const DROID_NAME_TOKENS: &[&str] = &["droid", "factory"]; + +/// A model from the Factory Droid catalog (apiKey intentionally omitted). +#[derive(Debug, Clone, Serialize)] +pub struct FactoryModel { + /// Canonical model id, e.g. `custom:claude-opus-5-0`. + pub id: String, + /// Provider model name, e.g. `claude-opus-5`. + pub model: String, + pub display_name: String, + pub provider: String, + pub base_url: String, + pub max_context_limit: u64, + pub max_output_tokens: u64, + pub no_image_support: bool, + /// Sort index within the source file. + pub index: i64, + /// Source catalog: "droid" (settings.json) or "vibemode" (factory-settings.json). + pub source: &'static str, + /// True when referenced by a default / orchestrator / subagent setting. + pub is_default: bool, +} + +/// A Factory Droid mission. +#[derive(Debug, Clone, Serialize)] +pub struct FactoryMission { + /// Stable mission id, e.g. `mis_57ffdada`. + pub mission_id: String, + /// Directory name under `missions/`. + pub dir: String, + /// Lifecycle state reported by `state.json` (paused, planning, running, …). + pub state: String, + /// First heading from `mission.md` (falls back to the mission_accepted title). + pub title: String, + pub cwd: String, + pub created_at_ms: u64, + pub updated_at_ms: u64, + /// `workerModel` from `model-settings.json`, if present. + pub worker_model: String, +} + +/// A config validation finding. +#[derive(Debug, Clone, Serialize)] +pub struct FactoryConfigIssue { + /// "high" | "medium" | "low". + pub severity: &'static str, + /// File the issue was found in (relative to `~/.factory`). + pub file: String, + pub message: String, +} + +/// Token usage parsed from a session's `.settings.json`. +#[derive(Debug, Default, Clone, Copy)] +struct SessionTokenUsage { + input: u64, + output: u64, + cache_read: u64, + cache_create: u64, +} + +impl SessionTokenUsage { + fn total(self) -> u64 { + self.input + self.output + self.cache_read + self.cache_create + } +} + +/// Last call token usage from a session's settings (`lastCallTokenUsage`). +#[derive(Debug, Default, Clone, Copy)] +struct LastCallUsage { + input: u64, + cache_read: u64, +} + +/// Deserialized `sessions-index.json`. +#[derive(Debug, Deserialize)] +struct IndexRoot { + #[serde(default)] + entries: Vec, +} + +#[derive(Debug, Deserialize)] +struct IndexEntry { + #[serde(default, rename = "sessionId")] + session_id: String, + #[serde(default, rename = "hostId")] + #[allow(dead_code)] + host_id: String, + /// Last-update epoch-millis, stored as a float. + #[serde(default)] + mtime: f64, + #[serde(default)] + title: String, + #[serde(default)] + cwd: String, + #[serde(default, rename = "messagesCount")] + messages_count: u64, + #[serde(default, rename = "callingSessionId")] + calling_session_id: Option, + #[serde(default)] + tags: Vec, +} + +#[derive(Debug, Deserialize)] +struct Tag { + #[serde(default)] + name: String, + #[serde(default)] + metadata: Option, +} + +impl IndexEntry { + fn mtime_ms(&self) -> u64 { + self.mtime.max(0.0) as u64 + } + + /// Mission id referenced by a `mission-session` tag, if any. + fn mission_id(&self) -> Option<&str> { + for tag in &self.tags { + if tag.name != "mission-session" { + continue; + } + if let Some(meta) = &tag.metadata { + if let Some(id) = meta.get("missionId").and_then(serde_json::Value::as_str) { + if !id.is_empty() { + return Some(id); + } + } + } + } + None + } +} + +/// Collects Factory Droid sessions and metadata. +pub struct FactoryCollector { + root: PathBuf, + active: bool, + cached_index: Vec, + cached_models: Vec, + cached_missions: Vec, + cached_issues: Vec, + /// Own token usage by session id (from `sessions/**/.settings.json`). + cached_tokens: HashMap, + /// Inclusive token usage for worker sessions as recorded by their parent + /// (`childInclusiveTokenUsageBySessionId`). + cached_child_tokens: HashMap, + /// Last call token usage by session id (from `lastCallTokenUsage` in settings). + cached_last_call_usage: HashMap, + /// Filesystem mtime of each `.settings.json`, used as the + /// freshest liveness signal (the index's `mtime` lags behind). + cached_settings_mtimes: HashMap, + /// PIDs of detected droid processes on the last tick. + last_droid_pids: Vec, +} + +impl FactoryCollector { + pub fn new() -> Self { + let root = dirs::home_dir().map(|h| h.join(".factory")); + let active = root.as_ref().is_some_and(|r| r.is_dir()); + Self { + root: root.unwrap_or_default(), + active, + cached_index: Vec::new(), + cached_models: Vec::new(), + cached_missions: Vec::new(), + cached_issues: Vec::new(), + cached_tokens: HashMap::new(), + cached_child_tokens: HashMap::new(), + cached_last_call_usage: HashMap::new(), + cached_settings_mtimes: HashMap::new(), + last_droid_pids: Vec::new(), + } + } + + /// True when the droid desktop app was detected on the last tick. + pub fn app_running(&self) -> bool { + !self.last_droid_pids.is_empty() + } + + pub fn models(&self) -> &[FactoryModel] { + &self.cached_models + } + + pub fn missions(&self) -> &[FactoryMission] { + &self.cached_missions + } + + pub fn issues(&self) -> &[FactoryConfigIssue] { + &self.cached_issues + } + + fn collect_impl(&mut self, shared: &super::SharedProcessData) -> Vec { + if !self.active { + return Vec::new(); + } + self.last_droid_pids = Self::find_droid_pids(&shared.process_info); + if shared.slow_tick { + self.cached_index = read_index(&self.root); + self.cached_models = read_models(&self.root); + self.cached_missions = read_missions(&self.root); + self.cached_issues = validate_config(&self.root); + let files = scan_settings_files(&self.root); + self.cached_settings_mtimes = files + .iter() + .map(|(id, (_, mtime))| (id.clone(), *mtime)) + .collect(); + let (tokens, child_tokens, last_call) = + read_session_tokens(&files, &self.settings_wanted_ids()); + self.cached_tokens = tokens; + self.cached_child_tokens = child_tokens; + self.cached_last_call_usage = last_call; + } + self.build_sessions() + } + + /// Best-effort detection of a running Factory Droid desktop app. + fn find_droid_pids(process_info: &HashMap) -> Vec { + process_info + .iter() + .filter(|(_, info)| { + let lower = info.command.to_ascii_lowercase(); + DROID_NAME_TOKENS.iter().any(|tok| lower.contains(tok)) + }) + .map(|(pid, _)| *pid) + .collect() + } + + /// Freshest liveness timestamp for an entry: the index `mtime` or the + /// settings file's filesystem mtime, whichever is newer. The index lags + /// behind real activity, so the settings file is authoritative when newer. + fn live_mtime(&self, entry: &IndexEntry) -> u64 { + let settings_mtime = self + .cached_settings_mtimes + .get(&entry.session_id) + .copied() + .unwrap_or(0); + entry.mtime_ms().max(settings_mtime) + } + + /// Session ids whose `.settings.json` content should be read for token + /// data: live sessions plus the parents of live workers (so subagent + /// `childInclusiveTokenUsageBySessionId` is available) and the workers of + /// live parents. Stale, never-indexed sessions are skipped, keeping the + /// slow tick fast — their liveness still comes from + /// [`Self::live_mtime`], which only needs the cheap mtime scan. + fn settings_wanted_ids(&self) -> std::collections::HashSet { + let now = now_ms(); + let live = |e: &IndexEntry| now.saturating_sub(self.live_mtime(e)) < LIVE_WINDOW_MS; + let mut wanted: std::collections::HashSet = std::collections::HashSet::new(); + for e in &self.cached_index { + if live(e) { + wanted.insert(e.session_id.clone()); + } + } + // Include parents of live workers and workers of live parents so token + // breakdowns render correctly for a live mission orchestrator. + for e in &self.cached_index { + let parent_live = e + .calling_session_id + .as_deref() + .is_some_and(|p| wanted.contains(p)); + if parent_live || wanted.contains(&e.session_id) { + wanted.insert(e.session_id.clone()); + if let Some(p) = &e.calling_session_id { + wanted.insert(p.clone()); + } + } + } + wanted + } + + fn build_sessions(&self) -> Vec { + let now = now_ms(); + let mission_models: HashMap<&str, &str> = self + .cached_missions + .iter() + .filter_map(|m| { + if m.worker_model.is_empty() { + None + } else { + Some((m.mission_id.as_str(), m.worker_model.as_str())) + } + }) + .collect(); + let default_model = self + .cached_models + .iter() + .find(|m| m.is_default) + .map(|m| m.model.clone()) + .unwrap_or_default(); + + let mut parents: Vec<&IndexEntry> = self + .cached_index + .iter() + .filter(|e| e.calling_session_id.is_none()) + .collect(); + parents.sort_by_key(|a| std::cmp::Reverse(self.live_mtime(a))); + let workers: Vec<&IndexEntry> = self + .cached_index + .iter() + .filter(|e| e.calling_session_id.is_some()) + .collect(); + + let config_root = super::abbrev_path(&self.root); + let mut sessions = Vec::new(); + + for parent in parents { + let mut subagents = Vec::new(); + let mut newest_worker_mtime = 0u64; + for worker in workers + .iter() + .filter(|w| w.calling_session_id.as_deref() == Some(parent.session_id.as_str())) + { + let worker_age = now.saturating_sub(self.live_mtime(worker)); + newest_worker_mtime = newest_worker_mtime.max(self.live_mtime(worker)); + if worker_age >= LIVE_WINDOW_MS { + continue; + } + subagents.push(SubAgent { + name: subagent_name(worker), + status: if worker_age < EXECUTING_MS { + "working".to_string() + } else { + "idle".to_string() + }, + tokens: self + .cached_child_tokens + .get(&worker.session_id) + .map_or(0, |u| u.total()), + }); + } + + // A parent is live if its own mtime is recent OR any of its workers + // are (a mission orchestrator idles between worker turns). + let parent_age = now.saturating_sub(self.live_mtime(parent)); + let live_mtime = self.live_mtime(parent).max(newest_worker_mtime); + let age = now.saturating_sub(live_mtime); + if parent_age >= LIVE_WINDOW_MS && age >= LIVE_WINDOW_MS { + continue; + } + + let model = parent + .mission_id() + .and_then(|id| mission_models.get(id)) + .map(|m| (*m).to_string()) + .unwrap_or_else(|| default_model.clone()); + + // Find max_context_limit for this model from the catalog. + let context_window = self + .cached_models + .iter() + .find(|m| m.id == model || m.model == model) + .map(|m| m.max_context_limit) + .unwrap_or(0); + + // Current context usage from last call (input + cache_read, like Claude). + let last_call = self + .cached_last_call_usage + .get(&parent.session_id) + .copied() + .unwrap_or_default(); + let current_context = last_call.input.saturating_add(last_call.cache_read); + let context_percent = if context_window > 0 { + (current_context as f64 / context_window as f64 * 100.0).min(100.0) + } else { + 0.0 + }; + + let tokens = self + .cached_tokens + .get(&parent.session_id) + .copied() + .unwrap_or_default(); + + sessions.push(AgentSession { + agent_cli: "factory", + pid: 0, + session_id: parent.session_id.clone(), + cwd: parent.cwd.clone(), + project_name: base_name(&parent.cwd), + started_at: self.live_mtime(parent), + status: live_status(age), + model, + effort: String::new(), + context_percent, + total_input_tokens: tokens.input, + total_output_tokens: tokens.output, + total_cache_read: tokens.cache_read, + total_cache_create: tokens.cache_create, + turn_count: parent.messages_count as u32, + current_tasks: current_tasks(parent, age), + mem_mb: 0, + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: Vec::new(), + context_history: Vec::new(), + compaction_count: 0, + context_window, + subagents, + mem_file_count: 0, + mem_line_count: 0, + children: Vec::new(), + initial_prompt: parent.title.clone(), + first_assistant_text: String::new(), + chat_messages: Vec::new(), + tool_calls: Vec::new(), + pending_since_ms: 0, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: config_root.clone(), + }); + } + + sessions + } +} + +impl Default for FactoryCollector { + fn default() -> Self { + Self::new() + } +} + +impl super::AgentCollector for FactoryCollector { + fn collect(&mut self, shared: &super::SharedProcessData) -> Vec { + self.collect_impl(shared) + } + + fn discovered_config_dirs(&self) -> Vec { + if self.active { + vec![self.root.clone()] + } else { + Vec::new() + } + } +} + +fn live_status(age_ms: u64) -> SessionStatus { + if age_ms < THINKING_MS { + SessionStatus::Thinking + } else if age_ms < EXECUTING_MS { + SessionStatus::Executing + } else { + SessionStatus::Waiting + } +} + +fn current_tasks(e: &IndexEntry, age_ms: u64) -> Vec { + let has_tag = |name: &str| e.tags.iter().any(|t| t.name == name); + if has_tag("mission-worker") { + vec!["mission worker".to_string()] + } else if has_tag("exec") || age_ms < EXECUTING_MS { + vec!["executing".to_string()] + } else { + vec!["waiting for input".to_string()] + } +} + +fn subagent_name(w: &IndexEntry) -> String { + let title = w.title.trim(); + if title.is_empty() || title.eq_ignore_ascii_case("New Session") { + "worker".to_string() + } else { + truncate(title, 120) + } +} + +fn read_index(root: &Path) -> Vec { + let path = root.join("sessions-index.json"); + if is_symlink(&path) || !path.exists() { + return Vec::new(); + } + let Ok(text) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::(&text) else { + return Vec::new(); + }; + parsed.entries +} + +/// Collect `.settings.json` mtimes across `sessions/` without +/// reading file contents. This is the cheap liveness scan; the index's +/// `mtime` lags behind real activity. +/// Recursively scan `sessions/` for `.settings.json` files, +/// returning each file's path and filesystem mtime in a single directory +/// walk. `DirEntry::file_type()` is used for dir/symlink classification so +/// no extra per-entry `stat` syscall is issued; the map is shared by the +/// mtime scan and the token read to avoid walking the tree twice. +fn scan_settings_files(root: &Path) -> HashMap { + let mut files = HashMap::new(); + let mut stack = vec![root.join("sessions")]; + while let Some(dir) = stack.pop() { + let Ok(read_dir) = fs::read_dir(&dir) else { + continue; + }; + for entry in read_dir.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if file_type.is_dir() { + stack.push(path); + continue; + } + if file_type.is_symlink() { + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(session_id) = name.strip_suffix(".settings.json") else { + continue; + }; + if session_id.is_empty() { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + let Ok(modified) = meta.modified() else { + continue; + }; + let Ok(ms) = modified.duration_since(std::time::UNIX_EPOCH) else { + continue; + }; + files.insert(session_id.to_string(), (path, ms.as_millis() as u64)); + } + } + files +} + +/// Parse token usage from settings files listed in `files`, reading only the +/// entries whose session id is in `wanted`. Returns `(own usage by session id, +/// child inclusive usage by child session id, last call usage by session id)`. +/// The index is the authoritative session list, and workers are always +/// included so live parents keep their subagent token breakdowns. +fn read_session_tokens( + files: &HashMap, + wanted: &std::collections::HashSet, +) -> ( + HashMap, + HashMap, + HashMap, +) { + let mut own = HashMap::new(); + let mut children = HashMap::new(); + let mut last_call = HashMap::new(); + for (session_id, (path, _)) in files { + if !wanted.contains(session_id) { + continue; + } + let v = read_json(path); + if v.is_null() { + continue; + } + if let Some(usage) = parse_token_usage(v.get("tokenUsage")) { + own.insert(session_id.clone(), usage); + } + if let Some(lc) = parse_last_call_usage(v.get("lastCallTokenUsage")) { + last_call.insert(session_id.clone(), lc); + } + if let Some(child_usage) = v + .get("childInclusiveTokenUsageBySessionId") + .and_then(serde_json::Value::as_object) + { + for (child_id, child_value) in child_usage { + if let Some(usage) = parse_token_usage(Some(child_value)) { + children.insert(child_id.clone(), usage); + } + } + } + } + (own, children, last_call) +} + +/// Parse `lastCallTokenUsage` from a session's settings. +fn parse_last_call_usage(v: Option<&serde_json::Value>) -> Option { + let obj = v?.as_object()?; + let get = |key: &str| obj.get(key).and_then(serde_json::Value::as_u64).unwrap_or(0); + Some(LastCallUsage { + input: get("inputTokens"), + cache_read: get("cacheReadTokens"), + }) +} + +/// Parse a Factory Droid token usage object, tolerating missing/unknown fields. +fn parse_token_usage(v: Option<&serde_json::Value>) -> Option { + let obj = v?.as_object()?; + let get = |key: &str| obj.get(key).and_then(serde_json::Value::as_u64).unwrap_or(0); + Some(SessionTokenUsage { + input: get("inputTokens"), + output: get("outputTokens"), + cache_read: get("cacheReadTokens"), + cache_create: get("cacheCreationTokens"), + }) +} + +/// Collect default-referenced model ids (session default, orchestrator, and +/// subagent/mission settings) across both catalog files. +fn collect_default_ids(v: &serde_json::Value, ids: &mut HashSet) { + let mut insert = |id: &str| { + if id.starts_with("custom:") { + ids.insert(id.to_string()); + } + }; + if let Some(sec) = v + .get("sessionDefaultSettings") + .and_then(serde_json::Value::as_object) + { + if let Some(id) = sec.get("model").and_then(serde_json::Value::as_str) { + insert(id); + } + } + if let Some(id) = v + .get("missionOrchestratorModel") + .and_then(serde_json::Value::as_str) + { + insert(id); + } + if let Some(general) = v.get("general").and_then(serde_json::Value::as_object) { + for (_, section) in general { + if let Some(section) = section.as_object() { + for (_, value) in section { + if let Some(id) = value.as_str() { + insert(id); + } + } + } + } + } +} + +fn read_models(root: &Path) -> Vec { + let mut default_ids = HashSet::new(); + for name in ["settings.json", "factory-settings.json"] { + if let Ok(text) = fs::read_to_string(root.join(name)) { + if let Ok(v) = serde_json::from_str::(&text) { + collect_default_ids(&v, &mut default_ids); + } + } + } + + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::new(); + for (name, source) in [ + ("settings.json", "droid"), + ("factory-settings.json", "vibemode"), + ] { + let Ok(text) = fs::read_to_string(root.join(name)) else { + continue; + }; + let Ok(v) = serde_json::from_str::(&text) else { + continue; + }; + let Some(models) = v.get("customModels").and_then(serde_json::Value::as_array) else { + continue; + }; + for m in models { + let id = m + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if id.is_empty() || !seen.insert(id.to_string()) { + continue; + } + out.push(FactoryModel { + id: id.to_string(), + model: m + .get("model") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + display_name: m + .get("displayName") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + provider: m + .get("provider") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + base_url: m + .get("baseUrl") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(), + max_context_limit: m + .get("maxContextLimit") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + max_output_tokens: m + .get("maxOutputTokens") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + no_image_support: m + .get("noImageSupport") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + index: m + .get("index") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0), + source, + is_default: default_ids.contains(id), + }); + } + } + out.sort_by(|a, b| { + a.source + .cmp(b.source) + .then(a.index.cmp(&b.index)) + .then(a.id.cmp(&b.id)) + }); + out +} + +fn read_json(path: &Path) -> serde_json::Value { + fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or(serde_json::Value::Null) +} + +fn read_mission_title(dir: &Path) -> String { + if let Ok(text) = fs::read_to_string(dir.join("mission.md")) { + if let Some(first) = text.lines().next() { + let title = first.trim_start_matches('#').trim(); + if !title.is_empty() { + return truncate(title, 256); + } + } + } + if let Ok(text) = fs::read_to_string(dir.join("progress_log.jsonl")) { + if let Some(first) = text.lines().next() { + if let Ok(v) = serde_json::from_str::(first) { + let title = v + .get("title") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !title.is_empty() { + return truncate(title, 256); + } + } + } + } + "mission".to_string() +} + +fn read_missions(root: &Path) -> Vec { + let mut out = Vec::new(); + let missions_dir = root.join("missions"); + let Ok(read_dir) = fs::read_dir(&missions_dir) else { + return out; + }; + let mut dirs: Vec = read_dir + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + + for dir in dirs { + let dir_name = dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let state = read_json(&dir.join("state.json")); + let mission_id = state + .get("missionId") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + let state_str = state + .get("state") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + let cwd = state + .get("workingDirectory") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + let created_at_ms = state + .get("createdAt") + .and_then(serde_json::Value::as_str) + .and_then(parse_iso_ms) + .unwrap_or(0); + let updated_at_ms = state + .get("updatedAt") + .and_then(serde_json::Value::as_str) + .and_then(parse_iso_ms) + .unwrap_or(0); + let worker_model = read_json(&dir.join("model-settings.json")) + .get("workerModel") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_string(); + + out.push(FactoryMission { + mission_id, + dir: dir_name, + state: state_str, + title: read_mission_title(&dir), + cwd, + created_at_ms, + updated_at_ms, + worker_model, + }); + } + out +} + +/// Parse an RFC3339 timestamp (as written by Factory Droid) into epoch-millis. +fn parse_iso_ms(s: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|dt| dt.timestamp_millis().max(0) as u64) +} + +fn issue( + severity: &'static str, + file: impl Into, + message: impl Into, +) -> FactoryConfigIssue { + FactoryConfigIssue { + severity, + file: file.into(), + message: message.into(), + } +} + +/// Validate a `customModels`-style JSON body for structural problems and +/// dangling default-model references. +fn validate_models_file(v: &serde_json::Value, file: &str, issues: &mut Vec) { + let Some(models) = v.get("customModels").and_then(serde_json::Value::as_array) else { + return; + }; + let mut ids: HashMap<&str, usize> = HashMap::new(); + let mut indices: HashSet = HashSet::new(); + let mut seen_indices: HashMap = HashMap::new(); + + for (i, m) in models.iter().enumerate() { + let id = m + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if id.is_empty() { + issues.push(issue("low", file, format!("customModels[{i}] missing id"))); + } else if let Some(prev) = ids.insert(id, i) { + issues.push(issue( + "medium", + file, + format!("duplicate model id '{id}' at indices {prev} and {i}"), + )); + } + if m.get("model") + .and_then(serde_json::Value::as_str) + .is_none_or(str::is_empty) + { + issues.push(issue( + "low", + file, + format!("customModels[{i}] missing model name"), + )); + } + if m.get("baseUrl") + .and_then(serde_json::Value::as_str) + .is_none_or(str::is_empty) + { + issues.push(issue( + "low", + file, + format!("customModels[{i}] missing baseUrl"), + )); + } + if let Some(idx) = m.get("index").and_then(serde_json::Value::as_i64) { + if !indices.insert(idx) { + if let Some(prev) = seen_indices.get(&idx) { + issues.push(issue( + "low", + file, + format!("duplicate model index {idx} at indices {prev} and {i}"), + )); + } + } + seen_indices.insert(idx, i); + } + } + + let known: HashSet<&str> = ids.keys().copied().collect(); + let mut refs = Vec::new(); + if let Some(id) = v + .get("sessionDefaultSettings") + .and_then(|s| s.get("model")) + .and_then(serde_json::Value::as_str) + { + refs.push(id); + } + if let Some(id) = v + .get("missionOrchestratorModel") + .and_then(serde_json::Value::as_str) + { + refs.push(id); + } + if let Some(general) = v.get("general").and_then(serde_json::Value::as_object) { + for (_, section) in general { + if let Some(section) = section.as_object() { + for (_, value) in section { + if let Some(id) = value.as_str() { + if id.starts_with("custom:") { + refs.push(id); + } + } + } + } + } + } + for id in refs { + if id.starts_with("custom:") && !known.contains(id) { + issues.push(issue( + "medium", + file, + format!("default model '{id}' is not in customModels"), + )); + } + } +} + +/// Structural validation of the whole `~/.factory` config surface. +fn validate_config(root: &Path) -> Vec { + let mut issues = Vec::new(); + + for name in ["settings.json", "factory-settings.json"] { + match fs::read_to_string(root.join(name)) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(v) => validate_models_file(&v, name, &mut issues), + Err(e) => issues.push(issue("high", name, format!("invalid JSON: {e}"))), + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + issues.push(issue("low", name, "missing")); + } + Err(e) => issues.push(issue("low", name, format!("unreadable: {e}"))), + } + } + + match fs::read_to_string(root.join("sessions-index.json")) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(v) => { + if let Some(entries) = v.get("entries").and_then(serde_json::Value::as_array) { + let empty_cwd = entries + .iter() + .filter(|e| { + e.get("cwd") + .and_then(serde_json::Value::as_str) + .is_none_or(str::is_empty) + }) + .count(); + if empty_cwd > 0 { + issues.push(issue( + "low", + "sessions-index.json", + format!("{empty_cwd} entries without cwd"), + )); + } + } + } + Err(e) => issues.push(issue( + "high", + "sessions-index.json", + format!("invalid JSON: {e}"), + )), + }, + Err(_) => issues.push(issue("low", "sessions-index.json", "missing")), + } + + if let Ok(read_dir) = fs::read_dir(root.join("missions")) { + let mut dirs: Vec = read_dir + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + dirs.sort(); + for dir in dirs { + let dir_name = dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let state_path = dir.join("state.json"); + if let Ok(text) = fs::read_to_string(&state_path) { + if serde_json::from_str::(&text).is_err() { + issues.push(issue( + "medium", + format!("missions/{dir_name}"), + "invalid state.json", + )); + } + } + if !dir.join("working_directory.txt").exists() { + issues.push(issue( + "low", + format!("missions/{dir_name}"), + "missing working_directory.txt", + )); + } + } + } + + for name in ["task-invocations.json", "background-processes.json"] { + let path = root.join(name); + if !path.exists() { + continue; + } + if let Ok(text) = fs::read_to_string(&path) { + if serde_json::from_str::(&text).is_err() { + issues.push(issue("low", name, "invalid JSON")); + } + } + } + + issues.truncate(MAX_ISSUES); + issues +} + +/// Check if a path is a symlink (fail-closed: returns true on error). +fn is_symlink(path: &Path) -> bool { + fs::symlink_metadata(path) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(true) +} + +fn truncate(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + s.to_string() + } else { + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() + } +} + +fn base_name(p: &str) -> String { + p.rsplit(['/', '\\']) + .find(|s| !s.is_empty()) + .unwrap_or("?") + .to_string() +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn sample_index() -> Vec { + let text = r#"{"version":2,"entries":[ + {"sessionId":"parent-1","hostId":"h1","mtime":,"title":"Orchestrator","cwd":"C:\\project","messagesCount":5}, + {"sessionId":"worker-1","hostId":"h1","mtime":,"title":"Worker: fix x","cwd":"C:\\project","messagesCount":2,"callingSessionId":"parent-1","tags":[{"name":"mission-worker"},{"name":"mission-session","metadata":{"role":"worker","missionId":"mis_1"}}]}, + {"sessionId":"old-1","hostId":"h1","mtime":1000,"title":"Stale","cwd":"C:\\old","messagesCount":1} + ]}"#; + let now = now_ms(); + let json = text.replace("", &format!("{}", now - 5000)); + let root: IndexRoot = serde_json::from_str(&json).unwrap(); + root.entries + } + + fn collector_with(root: PathBuf) -> FactoryCollector { + FactoryCollector { + root, + active: true, + cached_index: Vec::new(), + cached_models: Vec::new(), + cached_missions: Vec::new(), + cached_issues: Vec::new(), + cached_tokens: HashMap::new(), + cached_child_tokens: HashMap::new(), + cached_last_call_usage: HashMap::new(), + cached_settings_mtimes: HashMap::new(), + last_droid_pids: Vec::new(), + } + } + + #[test] + fn build_sessions_keeps_live_parents_and_attaches_workers() { + let root = dirs::home_dir().unwrap().join(".factory"); + let mut collector = collector_with(root); + collector.cached_index = sample_index(); + let sessions = collector.build_sessions(); + + assert_eq!(sessions.len(), 1, "stale parent dropped"); + let s = &sessions[0]; + assert_eq!(s.agent_cli, "factory"); + assert_eq!(s.session_id, "parent-1"); + assert_eq!(s.project_name, "project"); + assert_eq!(s.subagents.len(), 1); + assert_eq!(s.subagents[0].name, "Worker: fix x"); + assert_eq!(s.config_root, "~/.factory"); + } + + #[test] + fn build_sessions_keeps_parent_with_live_worker_but_stale_own_mtime() { + let root = dirs::home_dir().unwrap().join(".factory"); + let now = now_ms(); + let text = r#"{ + "entries": [ + {"sessionId":"parent-1","hostId":"h1","mtime":,"title":"Orchestrator","cwd":"C:\\project","messagesCount":5}, + {"sessionId":"worker-1","hostId":"h1","mtime":,"title":"Worker: fix x","cwd":"C:\\project","messagesCount":2,"callingSessionId":"parent-1"}, + {"sessionId":"worker-stale","hostId":"h1","mtime":,"title":"Worker: stale","cwd":"C:\\project","messagesCount":2,"callingSessionId":"parent-1"}, + {"sessionId":"solo-1","hostId":"h1","mtime":,"title":"Solo stale","cwd":"C:\\old","messagesCount":1} + ] + }"#; + let json = text + .replace("", &format!("{}", now - 20 * 60 * 1000)) + .replace("", &format!("{}", now - 5000)); + let mut collector = collector_with(root); + collector.cached_index = serde_json::from_str::(&json).unwrap().entries; + + let sessions = collector.build_sessions(); + assert_eq!(sessions.len(), 1, "only parent kept via live worker"); + let s = &sessions[0]; + assert_eq!(s.session_id, "parent-1"); + assert_eq!(s.subagents.len(), 1, "stale worker not attached"); + assert_eq!(s.subagents[0].name, "Worker: fix x"); + } + + #[test] + fn build_sessions_keeps_session_with_fresh_settings_file_but_stale_index_mtime() { + let root = dirs::home_dir().unwrap().join(".factory"); + let now = now_ms(); + let text = r#"{ + "entries": [ + {"sessionId":"solo-1","hostId":"h1","mtime":,"title":"Solo chat","cwd":"C:\\project","messagesCount":5}, + {"sessionId":"dead-1","hostId":"h1","mtime":,"title":"Old","cwd":"C:\\old","messagesCount":1} + ] + }"#; + let json = text.replace("", &format!("{}", now - 30 * 60 * 1000)); + let mut collector = collector_with(root); + collector.cached_index = serde_json::from_str::(&json).unwrap().entries; + // Only solo-1 has a fresh settings file; dead-1 has none. + collector + .cached_settings_mtimes + .insert("solo-1".to_string(), now - 30 * 1000); + + let sessions = collector.build_sessions(); + assert_eq!(sessions.len(), 1, "solo-1 kept by fresh settings file"); + assert_eq!(sessions[0].session_id, "solo-1"); + assert_eq!(sessions[0].status, SessionStatus::Executing); + } + + #[test] + fn build_sessions_populates_token_usage_and_subagent_tokens() { + let root = dirs::home_dir().unwrap().join(".factory"); + let mut collector = collector_with(root); + collector.cached_index = sample_index(); + collector.cached_tokens.insert( + "parent-1".to_string(), + SessionTokenUsage { + input: 1000, + output: 100, + cache_read: 9000, + cache_create: 0, + }, + ); + collector.cached_child_tokens.insert( + "worker-1".to_string(), + SessionTokenUsage { + input: 50, + output: 5, + cache_read: 400, + cache_create: 0, + }, + ); + + let sessions = collector.build_sessions(); + let s = &sessions[0]; + assert_eq!(s.total_input_tokens, 1000); + assert_eq!(s.total_output_tokens, 100); + assert_eq!(s.total_cache_read, 9000); + assert_eq!(s.total_cache_create, 0); + assert_eq!(s.total_tokens(), 10100); + assert_eq!(s.subagents[0].tokens, 455); + } + + #[test] + fn read_session_tokens_parses_own_and_child_usage() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let sessions = root.join("sessions").join("-C-project-Miros"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write( + sessions.join("parent-1.settings.json"), + r#"{ + "model": "custom:x", + "tokenUsage": { + "inputTokens": 100, + "outputTokens": 20, + "cacheCreationTokens": 0, + "cacheReadTokens": 300, + "thinkingTokens": 5 + }, + "childInclusiveTokenUsageBySessionId": { + "worker-1": { + "inputTokens": 10, + "outputTokens": 2, + "cacheCreationTokens": 0, + "cacheReadTokens": 30 + } + } + }"#, + ) + .unwrap(); + std::fs::write( + sessions.join("worker-1.settings.json"), + r#"{"tokenUsage": {"inputTokens": 10, "outputTokens": 2}}"#, + ) + .unwrap(); + // Non-JSON and non-settings files are ignored. + std::fs::write(sessions.join("notes.txt"), "not json").unwrap(); + std::fs::write(sessions.join("misc.settings.json.bak"), "{").unwrap(); + + let files = scan_settings_files(root); + let (own, children, _last_call) = read_session_tokens( + &files, + &["parent-1".to_string(), "worker-1".to_string()] + .into_iter() + .collect(), + ); + assert_eq!(own.get("parent-1").unwrap().total(), 420); + assert_eq!(own.get("worker-1").unwrap().total(), 12); + assert_eq!(children.get("worker-1").unwrap().total(), 42); + assert!(!children.contains_key("parent-1")); + assert_eq!(own.len(), 2, "notes.txt and .bak ignored"); + } + + #[test] + fn read_session_tokens_only_reads_wanted_ids() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let sessions = root.join("sessions").join("-C-project-Miros"); + std::fs::create_dir_all(&sessions).unwrap(); + std::fs::write( + sessions.join("wanted-1.settings.json"), + r#"{"tokenUsage": {"inputTokens": 100, "outputTokens": 20}}"#, + ) + .unwrap(); + std::fs::write( + sessions.join("skipped-1.settings.json"), + r#"{"tokenUsage": {"inputTokens": 999, "outputTokens": 999}}"#, + ) + .unwrap(); + + let files = scan_settings_files(root); + let (own, _children, _last_call) = read_session_tokens( + &files, + &["wanted-1".to_string()].into_iter().collect(), + ); + assert_eq!(own.len(), 1, "skipped-1 not read"); + assert_eq!(own.get("wanted-1").unwrap().total(), 120); + } + + #[test] + fn parse_token_usage_tolerates_partial_objects() { + let v: serde_json::Value = serde_json::from_str(r#"{"inputTokens": 7}"#).unwrap(); + let usage = parse_token_usage(Some(&v)).unwrap(); + assert_eq!(usage.input, 7); + assert_eq!(usage.output, 0); + assert!(parse_token_usage(Some(&serde_json::Value::Null)).is_none()); + assert!(parse_token_usage(None).is_none()); + } + + #[test] + fn status_windows_map_age_to_thinking_executing_waiting() { + assert_eq!(live_status(5_000), SessionStatus::Thinking); + assert_eq!(live_status(60_000), SessionStatus::Executing); + assert_eq!(live_status(5 * 60_000), SessionStatus::Waiting); + } + + #[test] + fn base_name_handles_windows_and_posix_paths() { + assert_eq!(base_name("C:\\project"), "project"); + assert_eq!(base_name("C:\\project\\sub"), "sub"); + assert_eq!(base_name("/home/u/proj"), "proj"); + assert_eq!(base_name("C:\\"), "C:"); + assert_eq!(base_name(""), "?"); + } + + #[test] + fn parse_iso_ms_accepts_rfc3339_zulu() { + assert_eq!( + parse_iso_ms("2026-06-24T00:31:45.533Z"), + Some(1_782_261_105_533) + ); + } + + #[test] + fn read_models_dedupes_by_id_and_marks_defaults() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let mut settings = std::fs::File::create(root.join("settings.json")).unwrap(); + write!( + settings, + r#"{{ + "sessionDefaultSettings": {{ "model": "custom:a-1" }}, + "customModels": [ + {{ "id": "custom:a-1", "model": "a", "index": 0, "baseUrl": "http://127.0.0.1:3000/v1", "provider": "generic" }}, + {{ "id": "custom:b-2", "model": "b", "index": 1, "baseUrl": "http://127.0.0.1:3000/v1", "provider": "openai", "maxContextLimit": 1048576 }} + ] + }}"# + ) + .unwrap(); + let mut vibemode = std::fs::File::create(root.join("factory-settings.json")).unwrap(); + write!( + vibemode, + r#"{{ "customModels": [ + {{ "id": "custom:a-1", "model": "a-vibe", "index": 0, "baseUrl": "https://r-api.vibemod.pro/v1" }}, + {{ "id": "custom:c-3", "model": "c", "index": 1, "baseUrl": "https://r-api.vibemod.pro/v1" }} + ] }}"# + ) + .unwrap(); + + let models = read_models(root); + assert_eq!(models.len(), 3, "duplicate id custom:a-1 deduped"); + let a = models.iter().find(|m| m.id == "custom:a-1").unwrap(); + assert!(a.is_default); + assert_eq!(a.source, "droid"); + let c = models.iter().find(|m| m.id == "custom:c-3").unwrap(); + assert_eq!(c.source, "vibemode"); + assert!(!c.is_default); + } + + #[test] + fn validate_config_flags_dup_id_and_dangling_default() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let mut settings = std::fs::File::create(root.join("settings.json")).unwrap(); + write!( + settings, + r#"{{ "sessionDefaultSettings": {{ "model": "custom:ghost" }}, + "customModels": [ + {{ "id": "custom:a", "model": "a", "index": 0, "baseUrl": "http://x" }}, + {{ "id": "custom:a", "model": "a2", "index": 1, "baseUrl": "http://x" }} + ] }}"# + ) + .unwrap(); + + let issues = validate_config(root); + assert!( + issues + .iter() + .any(|i| i.message.contains("duplicate model id")), + "issues: {issues:?}" + ); + assert!( + issues.iter().any(|i| i.message.contains("custom:ghost")), + "issues: {issues:?}" + ); + } + + #[test] + fn validate_config_reports_broken_settings_json() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("settings.json"), r#"{"customModels": ["#).unwrap(); + let issues = validate_config(root); + assert!( + issues + .iter() + .any(|i| i.severity == "high" && i.file == "settings.json"), + "issues: {issues:?}" + ); + } + + #[test] + fn find_droid_pids_matches_by_command_token() { + use super::super::process::ProcInfo; + let mut info = HashMap::new(); + info.insert( + 10, + ProcInfo { + pid: 10, + ppid: 1, + rss_kb: 1000, + cpu_pct: 0.0, + command: "C:\\Program Files\\Factory Droid\\Droid.exe".to_string(), + }, + ); + info.insert( + 20, + ProcInfo { + pid: 20, + ppid: 1, + rss_kb: 1000, + cpu_pct: 0.0, + command: "node server.js".to_string(), + }, + ); + let pids = FactoryCollector::find_droid_pids(&info); + assert_eq!(pids, vec![10]); + } +} diff --git a/src/collector/mod.rs b/src/collector/mod.rs index 69b0e81..6f7f513 100644 --- a/src/collector/mod.rs +++ b/src/collector/mod.rs @@ -1,5 +1,6 @@ pub mod claude; pub mod codex; +pub mod factory; pub mod mcp; pub mod opencode; pub mod process; @@ -7,6 +8,7 @@ pub mod rate_limit; pub use claude::ClaudeCollector; pub use codex::CodexCollector; +pub use factory::FactoryCollector; pub use mcp::McpServer; pub use opencode::OpenCodeCollector; pub use rate_limit::read_rate_limits; @@ -270,6 +272,8 @@ impl Drop for DesktopRolloutScanner { pub struct MultiCollector { collectors: Vec>, codex_enabled: bool, + factory_enabled: bool, + factory: factory::FactoryCollector, tick_count: u32, cached_ports: HashMap>, /// PID set snapshot from last port scan — invalidate cache when PIDs change. @@ -320,9 +324,12 @@ impl MultiCollector { collectors.push(Box::new(OpenCodeCollector::new())); } let codex_enabled = !is_hidden("codex"); + let factory_enabled = !is_hidden("factory"); Self { collectors, codex_enabled, + factory_enabled, + factory: factory::FactoryCollector::new(), tick_count: SLOW_POLL_INTERVAL, // trigger on first tick cached_ports: HashMap::new(), cached_port_pids: Vec::new(), @@ -347,6 +354,26 @@ impl MultiCollector { .collect() } + /// Factory Droid model catalog from the last slow tick. + pub fn factory_models(&self) -> &[factory::FactoryModel] { + self.factory.models() + } + + /// Factory Droid missions from the last slow tick. + pub fn factory_missions(&self) -> &[factory::FactoryMission] { + self.factory.missions() + } + + /// Factory Droid config validation findings from the last slow tick. + pub fn factory_issues(&self) -> &[factory::FactoryConfigIssue] { + self.factory.issues() + } + + /// True when a Factory Droid app process was detected on the last tick. + pub fn factory_app_running(&self) -> bool { + self.factory.app_running() + } + /// Return all config directories discovered across all collectors. pub fn all_config_dirs(&self) -> Vec { self.collectors @@ -396,6 +423,9 @@ impl MultiCollector { } let mut all = Vec::new(); + if self.factory_enabled { + all.extend(self.factory.collect(&shared)); + } for collector in &mut self.collectors { all.extend(collector.collect(&shared)); } @@ -514,14 +544,26 @@ mod tests { assert_eq!(mc.collectors.len(), 3); } + #[test] + fn with_hidden_factory_disables_factory_collector() { + let mc = MultiCollector::with_hidden(&["factory".to_string()]); + assert!(!mc.factory_enabled); + let mc = MultiCollector::with_hidden(&["FACTORY".to_string()]); + assert!(!mc.factory_enabled); + let mc = MultiCollector::with_hidden(&[]); + assert!(mc.factory_enabled); + } + #[test] fn with_hidden_all_agents_yields_empty() { let mc = MultiCollector::with_hidden(&[ "claude".to_string(), "codex".to_string(), "opencode".to_string(), + "factory".to_string(), ]); assert!(mc.collectors.is_empty()); + assert!(!mc.factory_enabled); } #[test] diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 7ef1523..08ca9a5 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -68,6 +68,14 @@ impl OpenCodeCollector { }) .collect(); + // Resolve process cwds once per tick (per PID), not once per + // (session × PID). On Windows lsof is absent, so cwd lookups are + // pure process spawns that always fail — skip them entirely. + let pid_cwds: HashMap = opencode_pids + .iter() + .filter_map(|&pid| get_process_cwd(pid).map(|cwd| (pid, cwd))) + .collect(); + // Refresh DB rows on slow ticks only; reuse cache on fast ticks so // we don't fork sqlite3 every 2s. if shared.slow_tick { @@ -81,8 +89,12 @@ impl OpenCodeCollector { let mut claimed_pids = HashSet::new(); for ds in &self.cached_db_sessions { - let matched_pid = - Self::match_pid_to_session_once(&pid_commands, &ds.directory, &mut claimed_pids); + let matched_pid = Self::match_pid_to_session_once( + &pid_commands, + &pid_cwds, + &ds.directory, + &mut claimed_pids, + ); // Drop sessions whose process isn't running. (Done sessions are // filtered out by MultiCollector::collect anyway, so emitting // a Done row here would be dead code.) @@ -222,22 +234,33 @@ impl OpenCodeCollector { /// opencode process" here, because that would mark every DB row as /// alive whenever a single opencode is running in an unrelated dir. #[cfg(test)] - fn match_pid_to_session(pid_commands: &HashMap, session_dir: &str) -> Option { - Self::match_pid_to_session_excluding(pid_commands, session_dir, &HashSet::new()) + fn match_pid_to_session( + pid_commands: &HashMap, + session_dir: &str, + ) -> Option { + Self::match_pid_to_session_excluding( + pid_commands, + &HashMap::new(), + session_dir, + &HashSet::new(), + ) } fn match_pid_to_session_once( pid_commands: &HashMap, + pid_cwds: &HashMap, session_dir: &str, claimed_pids: &mut HashSet, ) -> Option { - let pid = Self::match_pid_to_session_excluding(pid_commands, session_dir, claimed_pids)?; + let pid = + Self::match_pid_to_session_excluding(pid_commands, pid_cwds, session_dir, claimed_pids)?; claimed_pids.insert(pid); Some(pid) } fn match_pid_to_session_excluding( pid_commands: &HashMap, + pid_cwds: &HashMap, session_dir: &str, claimed_pids: &HashSet, ) -> Option { @@ -251,10 +274,8 @@ impl OpenCodeCollector { if claimed_pids.contains(&pid) { continue; } - if let Some(cwd) = get_process_cwd(pid) { - if cwd == session_dir { - return Some(pid); - } + if pid_cwds.get(&pid).is_some_and(|cwd| cwd == session_dir) { + return Some(pid); } if cmd.contains(session_dir) { return Some(pid); @@ -287,18 +308,18 @@ impl OpenCodeCollector { SELECT s.id, s.title, s.directory, s.version, s.time_created, s.time_updated, COALESCE(p.name, '') as project_name, - COUNT(m.id) as turn_count, - COALESCE(SUM(json_extract(m.data, '$.tokens.input')), 0) as total_input, - COALESCE(SUM(json_extract(m.data, '$.tokens.output')), 0) as total_output, - COALESCE(SUM(json_extract(m.data, '$.tokens.cache.read')), 0) as total_cache_read, - COALESCE(SUM(json_extract(m.data, '$.tokens.cache.write')), 0) as total_cache_write -FROM session s + (SELECT COUNT(*) FROM message m + WHERE m.session_id = s.id + AND json_extract(m.data, '$.role') = 'assistant') as turn_count, + COALESCE(s.tokens_input, 0) as total_input, + COALESCE(s.tokens_output, 0) as total_output, + COALESCE(s.tokens_cache_read, 0) as total_cache_read, + COALESCE(s.tokens_cache_write, 0) as total_cache_write +FROM (SELECT id, title, directory, version, time_created, time_updated, project_id, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_write + FROM session ORDER BY time_updated DESC LIMIT {}) s LEFT JOIN project p ON s.project_id = p.id -LEFT JOIN message m ON m.session_id = s.id - AND json_extract(m.data, '$.role') = 'assistant' -GROUP BY s.id -ORDER BY s.time_updated DESC -LIMIT {};"#, +ORDER BY s.time_updated DESC;"#, MAX_SESSIONS ); @@ -314,9 +335,8 @@ SELECT FROM message m2 WHERE m2.session_id = s.id AND json_extract(m2.data, '$.role') = 'assistant' ORDER BY m2.time_created DESC LIMIT 1), '') as provider -FROM session s -ORDER BY s.time_updated DESC -LIMIT {};"#, +FROM (SELECT id, time_updated FROM session ORDER BY time_updated DESC LIMIT {}) s +ORDER BY s.time_updated DESC;"#, MAX_SESSIONS ); @@ -425,7 +445,8 @@ fn truncate_field(s: &mut String, max_bytes: usize) { } /// Get the current working directory of a process. -/// Uses /proc on Linux, lsof on macOS/other Unix. +/// Uses /proc on Linux, lsof on macOS/other Unix. Windows has no lsof, +/// so cwd lookup returns `None` rather than spawning a failing process. #[cfg(target_os = "linux")] fn get_process_cwd(pid: u32) -> Option { std::fs::read_link(format!("/proc/{}/cwd", pid)) @@ -433,7 +454,12 @@ fn get_process_cwd(pid: u32) -> Option { .map(|p| p.to_string_lossy().into_owned()) } -#[cfg(not(target_os = "linux"))] +#[cfg(all(windows, not(target_os = "linux")))] +fn get_process_cwd(_pid: u32) -> Option { + None +} + +#[cfg(all(not(windows), not(target_os = "linux")))] fn get_process_cwd(pid: u32) -> Option { // -a ANDs the selection terms; without it, lsof ORs `-p ` with // `-d cwd` and returns cwd entries for unrelated processes too. @@ -564,6 +590,7 @@ mod tests { assert_eq!( OpenCodeCollector::match_pid_to_session_once( &pid_commands, + &HashMap::new(), "/home/u/proj-a", &mut claimed_pids, ), @@ -572,6 +599,7 @@ mod tests { assert_eq!( OpenCodeCollector::match_pid_to_session_once( &pid_commands, + &HashMap::new(), "/home/u/proj-a", &mut claimed_pids, ), diff --git a/src/collector/rate_limit.rs b/src/collector/rate_limit.rs index c177fda..b17ee9a 100644 --- a/src/collector/rate_limit.rs +++ b/src/collector/rate_limit.rs @@ -126,5 +126,6 @@ fn read_rate_file(path: &Path, default_source: &str) -> Option { seven_day_pct: file.seven_day.as_ref().map(|w| w.used_percentage), seven_day_resets_at: file.seven_day.as_ref().map(|w| w.resets_at), updated_at: file.updated_at, + ..Default::default() }) } diff --git a/src/config.rs b/src/config.rs index 4cd5532..dfb6018 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub struct PanelVisibility { pub ports: bool, pub sessions: bool, pub mcp: bool, + pub models: bool, + pub missions: bool, } impl Default for PanelVisibility { @@ -21,6 +23,8 @@ impl Default for PanelVisibility { ports: true, sessions: true, mcp: true, + models: false, + missions: false, } } } @@ -104,6 +108,8 @@ fn parse_config_body(content: &str) -> AppConfig { "show_ports" => config.panels.ports = parse_bool(val).unwrap_or(true), "show_sessions" => config.panels.sessions = parse_bool(val).unwrap_or(true), "show_mcp" => config.panels.mcp = parse_bool(val).unwrap_or(true), + "show_models" => config.panels.models = parse_bool(val).unwrap_or(true), + "show_missions" => config.panels.missions = parse_bool(val).unwrap_or(true), _ => {} } } @@ -167,6 +173,8 @@ pub fn save_panel_visibility(panels: &PanelVisibility) -> Result<(), String> { ("show_ports", panels.ports.to_string()), ("show_sessions", panels.sessions.to_string()), ("show_mcp", panels.mcp.to_string()), + ("show_models", panels.models.to_string()), + ("show_missions", panels.missions.to_string()), ]) } @@ -262,6 +270,17 @@ mod tests { assert_eq!(cfg.claude_config_dirs, vec![home.join(".claude-personal")]); } + #[test] + fn parse_config_body_loads_factory_panel_keys() { + let cfg = parse_config_body("show_models = false\nshow_missions = true\n"); + assert!(!cfg.panels.models); + assert!(cfg.panels.missions); + // Unset keys keep their defaults. + let cfg = parse_config_body(""); + assert!(!cfg.panels.models); + assert!(!cfg.panels.missions); + } + fn theme_update(name: &str) -> Vec<(&'static str, String)> { vec![("theme", format!("\"{}\"", name))] } diff --git a/src/demo.rs b/src/demo.rs index 06df788..a4611ca 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -1,4 +1,5 @@ use crate::app::App; +use crate::collector::factory::{FactoryConfigIssue, FactoryMission, FactoryModel}; use crate::model::{ AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, OrphanPort, RateLimitInfo, SessionStatus, SubAgent, ToolCall, @@ -509,6 +510,68 @@ pub fn populate_demo(app: &mut App) { config_root: "~/.local/share/opencode".into(), file_accesses: vec![], }, + AgentSession { + agent_cli: "factory", + pid: 9800, + session_id: "ses_f6a7b8c9-def0-1234-5678-666666666666".into(), + cwd: "/Users/demo/factory-droid".into(), + project_name: "factory-droid".into(), + started_at: now - 3 * 60 * 1000, // 3m ago + status: SessionStatus::Executing, + model: "custom:claude-opus-5-0".into(), + effort: String::new(), + context_percent: 61.0, + total_input_tokens: 18_400, + total_output_tokens: 6_200, + total_cache_read: 240_000, + total_cache_create: 31_000, + turn_count: 17, + current_tasks: vec!["Mission: draft migration plan".into()], + mem_mb: 210, + version: "1.2.3".into(), + git_branch: "".into(), + git_added: 0, + git_modified: 0, + token_history: vec![ + 4000, 7000, 11000, 9000, 13000, 10000, 15000, 12000, 17000, 14000, 19000, 16000, + ], + context_history: vec![], + compaction_count: 0, + context_window: 200_000, + subagents: vec![ + SubAgent { + name: "draft migration plan".into(), + status: "done".into(), + tokens: 4_100, + }, + SubAgent { + name: "verify breaking changes".into(), + status: "working".into(), + tokens: 2_300, + }, + ], + mem_file_count: 1, + mem_line_count: 4, + children: vec![], + + first_assistant_text: String::new(), + chat_messages: vec![ + ChatMessage { + role: ChatRole::User, + text: "Plan the migration to the new storage backend".into(), + }, + ChatMessage { + role: ChatRole::Assistant, + text: "Scoped the migration; worker is verifying breaking changes now.".into(), + }, + ], + initial_prompt: "Plan the migration to the new storage backend".into(), + tool_calls: vec![], + pending_since_ms: 0, + thinking_since_ms: 0, + config_root: "~/.factory".into(), + file_accesses: vec![], + }, ]; // --- Summaries (pre-populated, no LLM calls) --- @@ -532,6 +595,10 @@ pub fn populate_demo(app: &mut App) { "ses_e5f6a7b8-9abc-def0-1234-555555555555".into(), "Terraform multi-region refactor".into(), ); + app.summaries.insert( + "ses_f6a7b8c9-def0-1234-5678-666666666666".into(), + "Storage migration plan".into(), + ); // --- Rate limits --- app.rate_limits = vec![ @@ -542,6 +609,7 @@ pub fn populate_demo(app: &mut App) { seven_day_pct: Some(12.0), seven_day_resets_at: Some(now_secs() + 5 * 24 * 3600), updated_at: Some(now_secs() - 10), + ..Default::default() }, RateLimitInfo { source: "codex".into(), @@ -550,6 +618,7 @@ pub fn populate_demo(app: &mut App) { seven_day_pct: Some(14.0), seven_day_resets_at: Some(now_secs() + 6 * 24 * 3600), updated_at: Some(now_secs() - 5), + ..Default::default() }, ]; @@ -580,4 +649,69 @@ pub fn populate_demo(app: &mut App) { load1: 1.8, }); app.agent_aggregate = crate::host_info::AgentAggregate::from_sessions(&app.sessions); + + // --- Factory Droid catalog, missions, and config validation --- + app.factory_app_running = true; + app.factory_models = vec![ + FactoryModel { + id: "custom:claude-opus-5-0".into(), + model: "claude-opus-5".into(), + display_name: "Claude Opus 5".into(), + provider: "anthropic".into(), + base_url: "https://api.anthropic.com".into(), + max_context_limit: 200_000, + max_output_tokens: 32_000, + no_image_support: false, + index: 0, + source: "droid", + is_default: true, + }, + FactoryModel { + id: "custom:qwen3-coder".into(), + model: "qwen3-coder:14b".into(), + display_name: "Qwen3 Coder".into(), + provider: "ollama".into(), + base_url: "http://localhost:11434".into(), + max_context_limit: 32_000, + max_output_tokens: 8_192, + no_image_support: true, + index: 1, + source: "vibemode", + is_default: false, + }, + ]; + app.factory_missions = vec![ + FactoryMission { + mission_id: "mis_57ffdada".into(), + dir: "mis_57ffdada".into(), + state: "running".into(), + title: "Storage migration plan".into(), + cwd: "/Users/demo/factory-droid".into(), + created_at_ms: now - 3 * 60 * 1000, + updated_at_ms: now - 15_000, + worker_model: "custom:claude-opus-5-0".into(), + }, + FactoryMission { + mission_id: "mis_77aa0001".into(), + dir: "mis_77aa0001".into(), + state: "paused".into(), + title: "UI polish pass".into(), + cwd: "/Users/demo/factory-droid".into(), + created_at_ms: now - 26 * 60 * 1000, + updated_at_ms: now - 18 * 60 * 1000, + worker_model: "custom:qwen3-coder".into(), + }, + ]; + app.factory_issues = vec![ + FactoryConfigIssue { + severity: "medium", + file: "settings.json".into(), + message: "model default points to unknown id".into(), + }, + FactoryConfigIssue { + severity: "low", + file: "missions/mis_77aa0001/state.json".into(), + message: "stale heartbeat".into(), + }, + ]; } diff --git a/src/lib.rs b/src/lib.rs index ab42504..438d1b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -165,6 +165,28 @@ pub fn run() -> io::Result<()> { } } + // --status-json flag: print a compact, privacy-preserving status summary + // for widgets/mobile clients. Unlike --json, it omits cwd, prompts, chat, + // session ids, tool args, child commands, and project names. + if std::env::args().any(|a| a == "--status-json") { + let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); + if demo_mode { + demo::populate_demo(&mut app); + } else { + app.tick_no_summaries(); + } + match serde_json::to_string_pretty(&app.to_status_summary(2000)) { + Ok(json) => { + println!("{}", json); + return Ok(()); + } + Err(e) => { + eprintln!("failed to serialize status summary: {}", e); + std::process::exit(1); + } + } + } + // --once flag: print snapshot and exit if std::env::args().any(|a| a == "--once") { let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); @@ -252,7 +274,7 @@ fn run_app( KeyCode::Char('T') => app.tree_view = !app.tree_view, KeyCode::Char('l') => app.toggle_timeline(), KeyCode::Char('f') => app.toggle_file_audit(), - KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char(c @ '1'..='9') => app.toggle_panel(c as u8 - b'0'), KeyCode::Char('M') => app.toggle_mcp_session_suppression(), KeyCode::Char('t') => app.cycle_theme(), _ => {} @@ -297,7 +319,7 @@ fn run_app( KeyCode::Char('t') => app.cycle_theme(), KeyCode::Char('T') => app.tree_view = !app.tree_view, KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_timeline(), - KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char(c @ '1'..='9') => app.toggle_panel(c as u8 - b'0'), KeyCode::Char('M') => app.toggle_mcp_session_suppression(), KeyCode::Char('c') => app.toggle_config(), KeyCode::Char('v') => app.toggle_view_menu(), diff --git a/src/locale.rs b/src/locale.rs index 84e8de5..ef0ea10 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -47,6 +47,7 @@ static LOCALE_EN: LazyLock> = LazyLock::ne // Agent labels m.insert("agent.claude", "*CC"); m.insert("agent.codex", ">CD"); + m.insert("agent.factory", "@FC"); // Tool labels m.insert("tool.bash", "Bash"); @@ -127,7 +128,7 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("help.toggle_file_audit", "toggle file audit"); m.insert( "help.toggle_panels", - "toggle panels (context/quota/tokens/projects/ports/sessions/mcp)", + "toggle panels (context/quota/tokens/projects/ports/sessions/mcp/models/missions)", ); m.insert( "help.mcp_suppress", @@ -166,6 +167,8 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("view.sessions_panel", "sessions panel"); m.insert("view.mcp_servers_panel", "mcp servers panel"); m.insert("view.mcp_session_hide", "mcp session hide"); + m.insert("view.models_panel", "models panel"); + m.insert("view.missions_panel", "missions panel"); m.insert("view.cycle_theme", "cycle theme"); m.insert("view.key_toggle", "key = toggle · Esc = close "); @@ -202,8 +205,12 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("quota.no_data", "no data"); m.insert("quota.abtop_setup", "abtop --setup"); m.insert("quota.run_codex", "run codex once"); + m.insert("quota.usage_unknown", "usage unknown"); + m.insert("quota.codex_wait", "after next reply"); + m.insert("quota.claude_wait", "statusline hook"); m.insert("quota.total", "total"); m.insert("quota.in", "in"); + m.insert("quota.cap", "cap"); // Projects panel m.insert("projects.no_git", "no git"); @@ -226,6 +233,20 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("mcp.default", "default"); m.insert("mcp.suppress_off", "suppress: off (M)"); + // Models panel + m.insert("models.no_models", "no custom models"); + m.insert("models.running", "running"); + m.insert("models.stopped", "stopped"); + m.insert("models.default", "default"); + m.insert("models.provider", "provider"); + m.insert("models.context", "ctx"); + m.insert("models.source", "src"); + m.insert("models.output", "out"); + + // Missions panel + m.insert("missions.no_missions", "no missions"); + m.insert("missions.model", "model"); + // Config panel m.insert("config.title", " Config "); m.insert("config.theme", "Theme"); @@ -240,6 +261,8 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("config.ports_panel", "Ports panel (5)"); m.insert("config.sessions_panel", "Sessions panel (6)"); m.insert("config.mcp_panel", "MCP servers (7)"); + m.insert("config.models_panel", "Models panel (8)"); + m.insert("config.missions_panel", "Missions panel (9)"); // Terminal size too small m.insert("term.too_small", "Terminal size too small:"); @@ -295,6 +318,7 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne // Agent labels m.insert("agent.claude", "*CC"); m.insert("agent.codex", ">CD"); + m.insert("agent.factory", "@FC"); // Tool labels m.insert("tool.bash", "终端"); @@ -375,7 +399,7 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("help.toggle_file_audit", "切换文件审计"); m.insert( "help.toggle_panels", - "切换面板 (上下文/配额/词元/项目/端口/会话/MCP)", + "切换面板 (上下文/配额/词元/项目/端口/会话/MCP/模型/任务)", ); m.insert("help.mcp_suppress", "切换会话面板中的 MCP 服务器隐藏"); m.insert("help.this_help", "显示帮助"); @@ -411,6 +435,8 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("view.sessions_panel", "会话面板"); m.insert("view.mcp_servers_panel", "MCP 服务器面板"); m.insert("view.mcp_session_hide", "隐藏 MCP 会话"); + m.insert("view.models_panel", "模型面板"); + m.insert("view.missions_panel", "任务面板"); m.insert("view.cycle_theme", "切换主题"); m.insert("view.key_toggle", "按键切换 · Esc 关闭 "); @@ -447,8 +473,12 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("quota.no_data", "无数据"); m.insert("quota.abtop_setup", "abtop --setup"); m.insert("quota.run_codex", "运行一次 codex"); + m.insert("quota.usage_unknown", "用量未知"); + m.insert("quota.codex_wait", "下次回复后"); + m.insert("quota.claude_wait", "状态栏钩子"); m.insert("quota.total", "总计"); m.insert("quota.in", "还有"); + m.insert("quota.cap", "耗尽"); // Projects panel m.insert("projects.no_git", "非 Git"); @@ -471,6 +501,20 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("mcp.default", "默认"); m.insert("mcp.suppress_off", "隐藏: 关闭 (M)"); + // Models panel + m.insert("models.no_models", "无自定义模型"); + m.insert("models.running", "运行中"); + m.insert("models.stopped", "已停止"); + m.insert("models.default", "默认"); + m.insert("models.provider", "提供方"); + m.insert("models.context", "上下文"); + m.insert("models.source", "来源"); + m.insert("models.output", "输出"); + + // Missions panel + m.insert("missions.no_missions", "无任务"); + m.insert("missions.model", "模型"); + // Config panel m.insert("config.title", " 配置 "); m.insert("config.theme", "主题"); @@ -485,6 +529,8 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("config.ports_panel", "端口面板 (5)"); m.insert("config.sessions_panel", "会话面板 (6)"); m.insert("config.mcp_panel", "MCP 服务器 (7)"); + m.insert("config.models_panel", "模型面板 (8)"); + m.insert("config.missions_panel", "任务面板 (9)"); // Terminal size too small m.insert("term.too_small", "终端尺寸过小:"); diff --git a/src/model/session.rs b/src/model/session.rs index 2bc5f24..d6d965c 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -41,10 +41,18 @@ pub struct RateLimitInfo { pub five_hour_pct: Option, /// 5-hour window reset timestamp (epoch seconds) pub five_hour_resets_at: Option, + /// Observed 5-hour usage burn rate, in percentage points per hour. + pub five_hour_burn_pct_per_hour: Option, + /// Estimated seconds until the 5-hour window reaches 100% at current burn. + pub five_hour_eta_secs: Option, /// 7-day window usage percentage (0-100) pub seven_day_pct: Option, /// 7-day window reset timestamp (epoch seconds) pub seven_day_resets_at: Option, + /// Observed 7-day usage burn rate, in percentage points per hour. + pub seven_day_burn_pct_per_hour: Option, + /// Estimated seconds until the 7-day window reaches 100% at current burn. + pub seven_day_eta_secs: Option, /// When this data was last updated pub updated_at: Option, } diff --git a/src/snapshot.rs b/src/snapshot.rs index 3ce950d..97cdf60 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -48,6 +48,115 @@ pub struct Snapshot { pub orphan_ports: Vec, /// Detected MCP servers (currently `codex mcp-server`). pub mcp_servers: Vec, + /// Factory Droid state: custom-model catalog, missions, config validation. + pub factory: FactorySnapshot, +} + +/// Factory Droid state surfaced to JSON consumers. `apiKey` is intentionally +/// never present; the catalog keeps only the provider `base_url`. +#[derive(Debug, Clone, Serialize)] +pub struct FactorySnapshot { + /// True when a running Factory Droid desktop app was detected. + pub app_running: bool, + /// Custom-model catalog (merged from settings + factory-settings). + pub models: Vec, + /// Missions with lifecycle state and worker model. + pub missions: Vec, + /// Config validation findings, sorted by severity. + pub issues: Vec, +} + +/// A single catalog model, without secrets. +#[derive(Debug, Clone, Serialize)] +pub struct FactoryModelView { + pub id: String, + pub model: String, + pub display_name: String, + pub provider: String, + pub base_url: String, + pub max_context_limit: u64, + pub max_output_tokens: u64, + pub no_image_support: bool, + pub source: &'static str, + pub is_default: bool, +} + +/// A single mission. +#[derive(Debug, Clone, Serialize)] +pub struct FactoryMissionView { + pub mission_id: String, + pub dir: String, + pub state: String, + pub title: String, + pub cwd: String, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub worker_model: String, +} + +/// A config validation finding. +#[derive(Debug, Clone, Serialize)] +pub struct FactoryConfigIssueView { + pub severity: &'static str, + pub file: String, + pub message: String, +} + +/// Compact status payload for widgets, notifications, and mobile clients. +/// +/// Unlike [`Snapshot`], this intentionally omits cwd, prompts, chat text, +/// session identifiers, tool arguments, child commands, and project names. +#[derive(Debug, Clone, Serialize)] +pub struct StatusSummary { + /// Unix-epoch milliseconds when this summary was built. + pub generated_at_ms: u64, + /// Collector tick interval in milliseconds. + pub interval_ms: u64, + /// Most recent per-tick active-token delta across all sessions. + pub token_rate: f64, + /// Total live sessions across all supported agent CLIs. + pub sessions_total: usize, + /// Sessions currently doing work. + pub sessions_active: usize, + /// Per-agent aggregate status, without per-session identifiers. + pub agents: Vec, + /// Account quota windows, when an agent has reported them. + pub quota: Vec, +} + +/// Aggregated status for one agent CLI. +#[derive(Debug, Clone, Serialize)] +pub struct AgentStatusSummary { + pub agent_cli: &'static str, + pub sessions: usize, + pub active: usize, + pub waiting: usize, + pub rate_limited: usize, + pub total_tokens: u64, + pub active_tokens: u64, + pub avg_context_pct: f64, + pub max_context_pct: f64, + pub max_turn_count: u32, +} + +/// Compact quota state for one agent source. +#[derive(Debug, Clone, Serialize)] +pub struct QuotaStatusSummary { + pub source: String, + pub five_hour: Option, + pub seven_day: Option, +} + +/// Compact quota state for one reset window. +#[derive(Debug, Clone, Serialize)] +pub struct QuotaWindowStatus { + pub used_pct: f64, + pub remaining_pct: f64, + pub burn_pct_per_hour: Option, + pub eta_secs: Option, + pub resets_at: Option, + pub cap_before_reset: bool, + pub level: &'static str, } /// One chat line from the transcript tail (detail view only). @@ -283,8 +392,177 @@ impl App { rate_limits: self.rate_limits.clone(), orphan_ports: self.orphan_ports.clone(), mcp_servers, + factory: FactorySnapshot { + app_running: self.factory_app_running, + models: self + .factory_models + .iter() + .map(|m| FactoryModelView { + id: m.id.clone(), + model: m.model.clone(), + display_name: m.display_name.clone(), + provider: m.provider.clone(), + base_url: m.base_url.clone(), + max_context_limit: m.max_context_limit, + max_output_tokens: m.max_output_tokens, + no_image_support: m.no_image_support, + source: m.source, + is_default: m.is_default, + }) + .collect(), + missions: self + .factory_missions + .iter() + .map(|m| FactoryMissionView { + mission_id: m.mission_id.clone(), + dir: m.dir.clone(), + state: m.state.clone(), + title: m.title.clone(), + cwd: m.cwd.clone(), + created_at_ms: m.created_at_ms, + updated_at_ms: m.updated_at_ms, + worker_model: m.worker_model.clone(), + }) + .collect(), + issues: self + .factory_issues + .iter() + .map(|i| FactoryConfigIssueView { + severity: i.severity, + file: i.file.clone(), + message: i.message.clone(), + }) + .collect(), + }, } } + + /// Build a compact, privacy-preserving JSON summary for external clients. + /// + /// This is intended for dashboards/widgets that need health and quota state + /// without local paths, prompts, chat text, tool arguments, or session IDs. + pub fn to_status_summary(&self, interval_ms: u64) -> StatusSummary { + let now = SystemTime::now(); + let generated_at_ms = epoch_ms(now).unwrap_or(0); + let now_secs = generated_at_ms / 1000; + + let mut agents = Vec::new(); + for agent_cli in ["claude", "codex", "opencode", "factory"] { + let matching: Vec<_> = self + .sessions + .iter() + .filter(|s| s.agent_cli.eq_ignore_ascii_case(agent_cli)) + .collect(); + if matching.is_empty() { + continue; + } + + let sessions = matching.len(); + let active = matching.iter().filter(|s| s.status.is_active()).count(); + let waiting = matching + .iter() + .filter(|s| matches!(s.status, SessionStatus::Waiting)) + .count(); + let rate_limited = matching + .iter() + .filter(|s| matches!(s.status, SessionStatus::RateLimited)) + .count(); + let total_tokens = matching.iter().map(|s| s.total_tokens()).sum(); + let active_tokens = matching.iter().map(|s| s.active_tokens()).sum(); + let max_context_pct = matching + .iter() + .map(|s| s.context_percent) + .fold(0.0, f64::max); + let context_sum: f64 = matching.iter().map(|s| s.context_percent).sum(); + let avg_context_pct = if sessions > 0 { + context_sum / sessions as f64 + } else { + 0.0 + }; + let max_turn_count = matching.iter().map(|s| s.turn_count).max().unwrap_or(0); + + agents.push(AgentStatusSummary { + agent_cli, + sessions, + active, + waiting, + rate_limited, + total_tokens, + active_tokens, + avg_context_pct, + max_context_pct, + max_turn_count, + }); + } + + let quota = self + .rate_limits + .iter() + .map(|rl| QuotaStatusSummary { + source: rl.source.clone(), + five_hour: quota_window_status( + rl.five_hour_pct, + rl.five_hour_resets_at, + rl.five_hour_burn_pct_per_hour, + rl.five_hour_eta_secs, + now_secs, + ), + seven_day: quota_window_status( + rl.seven_day_pct, + rl.seven_day_resets_at, + rl.seven_day_burn_pct_per_hour, + rl.seven_day_eta_secs, + now_secs, + ), + }) + .collect(); + + StatusSummary { + generated_at_ms, + interval_ms, + token_rate: self.token_rates.back().copied().unwrap_or(0.0), + sessions_total: self.sessions.len(), + sessions_active: self + .sessions + .iter() + .filter(|s| s.status.is_active()) + .count(), + agents, + quota, + } + } +} + +fn quota_window_status( + used_pct: Option, + resets_at: Option, + burn_pct_per_hour: Option, + eta_secs: Option, + now_secs: u64, +) -> Option { + let used_pct = used_pct?; + let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0); + let cap_before_reset = match (eta_secs, resets_at.and_then(|ts| ts.checked_sub(now_secs))) { + (Some(eta), Some(reset_secs)) => eta < reset_secs, + _ => false, + }; + let level = if used_pct >= 95.0 || remaining_pct <= 5.0 || cap_before_reset { + "danger" + } else if used_pct >= 75.0 || eta_secs.is_some_and(|eta| eta <= 3600) { + "warning" + } else { + "ok" + }; + + Some(QuotaWindowStatus { + used_pct, + remaining_pct, + burn_pct_per_hour, + eta_secs, + resets_at, + cap_before_reset, + level, + }) } #[cfg(test)] @@ -381,12 +659,65 @@ mod tests { assert!(parsed["sessions"].is_array()); } + #[test] + fn snapshot_includes_factory_catalog_without_secrets() { + let snap = demo_app().to_snapshot(2_000); + assert!(snap.factory.app_running); + assert!(!snap.factory.models.is_empty()); + assert!(!snap.factory.missions.is_empty()); + // The demo seeds a config issue; payload never carries apiKey fields. + let json = serde_json::to_string(&snap).expect("snapshot serializes"); + assert!(json.contains("\"factory\"")); + assert!(json.contains("\"app_running\"")); + assert!(!json.contains("apiKey")); + assert!(!json.contains("api_key")); + } + + #[test] + fn status_summary_aggregates_without_session_details() { + let summary = demo_app().to_status_summary(2_000); + + assert_eq!(summary.interval_ms, 2_000); + assert!(summary.generated_at_ms > 0); + assert!(summary.sessions_total > 0); + assert!(!summary.agents.is_empty()); + assert!(summary + .agents + .iter() + .any(|agent| agent.agent_cli == "codex" && agent.sessions > 0)); + } + + #[test] + fn status_summary_json_omits_private_session_fields() { + let json = + serde_json::to_string(&demo_app().to_status_summary(2_000)).expect("status serializes"); + + for forbidden in [ + "cwd", + "session_id", + "project_name", + "summary", + "current_task", + "chat_messages", + "tool_calls", + "children", + "config_root", + ] { + assert!( + !json.contains(forbidden), + "status summary leaked `{forbidden}` in {json}" + ); + } + } + #[test] fn readme_documents_json_snapshot_privacy_surface() { let readme = include_str!("../README.md"); assert!(readme.contains("--json")); + assert!(readme.contains("--status-json")); assert!(readme.contains("JSON snapshot includes")); assert!(readme.contains("chat_messages")); assert!(readme.contains("summary")); + assert!(readme.contains("omits local paths")); } } diff --git a/src/ui/config.rs b/src/ui/config.rs index 3085ae0..4d33aa4 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -11,7 +11,7 @@ pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) { let area = f.area(); let popup_w = 50u16.min(area.width.saturating_sub(4)); - let popup_h = 15u16.min(area.height.saturating_sub(4)); + let popup_h = 17u16.min(area.height.saturating_sub(4)); let x = (area.width.saturating_sub(popup_w)) / 2; let y = (area.height.saturating_sub(popup_h)) / 2; let popup = Rect::new(x, y, popup_w, popup_h); @@ -75,6 +75,14 @@ pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) { t("config.mcp_panel"), toggle_str(&on_str, &off_str, app.show_mcp), ), + ( + t("config.models_panel"), + toggle_str(&on_str, &off_str, app.show_models), + ), + ( + t("config.missions_panel"), + toggle_str(&on_str, &off_str, app.show_missions), + ), ]; let mut lines = Vec::new(); diff --git a/src/ui/help.rs b/src/ui/help.rs index e4e5b12..889286e 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -25,7 +25,7 @@ fn get_entries() -> Vec<(String, String)> { (" t / T".to_string(), t("help.cycle_theme")), (" l".to_string(), t("help.toggle_timeline")), (" f".to_string(), t("help.toggle_file_audit")), - (" 1-7".to_string(), t("help.toggle_panels")), + (" 1-9".to_string(), t("help.toggle_panels")), (" M".to_string(), t("help.mcp_suppress")), (t("help.help"), String::new()), (" ?".to_string(), t("help.this_help")), diff --git a/src/ui/missions.rs b/src/ui/missions.rs new file mode 100644 index 0000000..52192ce --- /dev/null +++ b/src/ui/missions.rs @@ -0,0 +1,64 @@ +use crate::app::App; +use crate::locale::t; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::{btop_block_active, truncate_str}; + +pub(crate) fn draw_missions_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_missions_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_missions_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { + let mut lines = Vec::new(); + let no_missions = t("missions.no_missions"); + let model_label = t("missions.model"); + + if app.factory_missions.is_empty() { + lines.push(Line::from(Span::styled( + format!(" {}", no_missions), + Style::default().fg(theme.inactive_fg), + ))); + } else { + for mission in &app.factory_missions { + lines.push(Line::from(vec![ + Span::styled( + format!(" {}", truncate_str(&mission.title, 22)), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", mission.state), + Style::default().fg(theme.proc_misc), + ), + ])); + let mut detail = Vec::new(); + if !mission.worker_model.is_empty() { + detail.push(Span::styled( + format!(" {}:{}", model_label, mission.worker_model), + Style::default().fg(theme.main_fg), + )); + } + let dir = mission.dir.clone(); + detail.push(Span::styled( + format!(" {}", truncate_str(&dir, 12)), + Style::default().fg(theme.inactive_fg), + )); + lines.push(Line::from(detail)); + } + } + + let block = btop_block_active("missions", "⁹", theme.cpu_box, theme, active); + f.render_widget(Paragraph::new(lines).block(block), area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dc8f248..8ef774d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,8 @@ mod footer; mod header; mod help; mod mcp; +mod missions; +mod models; mod ports; mod projects; mod quota; @@ -397,6 +399,8 @@ pub fn draw(f: &mut Frame, app: &App) { NarrowSection::Projects => projects::draw_projects_panel(f, app, area, theme), NarrowSection::Ports => ports::draw_ports_panel(f, app, area, theme), NarrowSection::Mcp => mcp::draw_mcp_panel(f, app, area, theme), + NarrowSection::Models => models::draw_models_panel(f, app, area, theme), + NarrowSection::Missions => missions::draw_missions_panel(f, app, area, theme), NarrowSection::Sessions | NarrowSection::Context => {} } } @@ -430,6 +434,12 @@ fn desktop_layout(app: &App, area: Rect) -> DesktopLayout { if app.show_mcp { mid_sections.push(NarrowSection::Mcp); } + if app.show_models { + mid_sections.push(NarrowSection::Models); + } + if app.show_missions { + mid_sections.push(NarrowSection::Missions); + } let any_mid = !mid_sections.is_empty(); let mid_h_ideal: u16 = 8; @@ -669,6 +679,10 @@ fn draw_narrow_section( NarrowSection::Tokens => tokens::draw_tokens_panel_active(f, app, area, theme, active), NarrowSection::Ports => ports::draw_ports_panel_active(f, app, area, theme, active), NarrowSection::Mcp => mcp::draw_mcp_panel_active(f, app, area, theme, active), + NarrowSection::Models => models::draw_models_panel_active(f, app, area, theme, active), + NarrowSection::Missions => { + missions::draw_missions_panel_active(f, app, area, theme, active) + } } } @@ -1342,6 +1356,44 @@ mod tests { } } + #[test] + fn factory_panels_render_when_enabled() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + app.show_models = true; + app.show_missions = true; + + // Compact mode: models land in Usage, missions in Work. + let backend = TestBackend::new(69, 30); + let mut terminal = Terminal::new(backend).unwrap(); + app.set_narrow_tab(NarrowTab::Usage); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + assert!(text.contains("models"), "models panel title\n{text}"); + assert!( + text.contains("Qwen3 Coder"), + "custom model display name\n{text}" + ); + assert!(text.contains("running"), "droid app running status\n{text}"); + + app.set_narrow_tab(NarrowTab::Work); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + assert!(text.contains("missions"), "missions panel title\n{text}"); + assert!( + text.contains("Storage migration plan"), + "mission title\n{text}" + ); + + // Desktop mode: both panels render as mid panels. + let backend = TestBackend::new(120, 40); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + assert!(text.contains("models"), "desktop models panel\n{text}"); + assert!(text.contains("missions"), "desktop missions panel\n{text}"); + } + fn render_demo(width: u16, height: u16) -> String { let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); crate::demo::populate_demo(&mut app); diff --git a/src/ui/models.rs b/src/ui/models.rs new file mode 100644 index 0000000..e053747 --- /dev/null +++ b/src/ui/models.rs @@ -0,0 +1,128 @@ +use crate::app::App; +use crate::locale::t; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::{btop_block_active, grad_at, make_gradient, truncate_str}; + +pub(crate) fn draw_models_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_models_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_models_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { + let mut lines = Vec::new(); + let no_models = t("models.no_models"); + let running = t("models.running"); + let stopped = t("models.stopped"); + let default = t("models.default"); + let provider_label = t("models.provider"); + let ctx_label = t("models.context"); + let out_label = t("models.output"); + let source_label = t("models.source"); + + let status = if app.factory_app_running { + format!("● {}", running) + } else { + format!("○ {}", stopped) + }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(status, Style::default().fg(theme.main_fg)), + ])); + + if app.factory_models.is_empty() { + lines.push(Line::from(Span::styled( + format!(" {}", no_models), + Style::default().fg(theme.inactive_fg), + ))); + } else { + let grad = make_gradient( + theme.used_grad.start, + theme.used_grad.mid, + theme.used_grad.end, + ); + for model in &app.factory_models { + let name = if model.display_name.is_empty() { + model.model.clone() + } else { + model.display_name.clone() + }; + let mut spans = vec![Span::styled( + format!(" {}", truncate_str(&name, 18)), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + )]; + if model.is_default { + spans.push(Span::styled( + format!(" {}", default), + Style::default().fg(theme.proc_misc), + )); + } + lines.push(Line::from(spans)); + lines.push(Line::from(vec![ + Span::styled( + format!(" {}:{}", provider_label, model.provider), + Style::default().fg(theme.proc_misc), + ), + Span::styled( + format!(" {}:{}", ctx_label, model.max_context_limit), + Style::default().fg(theme.main_fg), + ), + Span::styled( + format!(" {}:{}", out_label, model.max_output_tokens), + Style::default().fg(grad_at(&grad, 60.0)), + ), + Span::styled( + format!(" {}:{}", source_label, model.source), + Style::default().fg(theme.inactive_fg), + ), + ])); + } + } + + if !app.factory_issues.is_empty() { + let high_color = grad_at( + &make_gradient( + theme.used_grad.start, + theme.used_grad.mid, + theme.used_grad.end, + ), + 100.0, + ); + lines.push(Line::from(Span::styled("", Style::default()))); + for issue in &app.factory_issues { + let color = match issue.severity { + "high" => high_color, + "medium" => theme.proc_misc, + _ => theme.inactive_fg, + }; + lines.push(Line::from(vec![ + Span::styled( + format!(" ⚠ {}", truncate_str(&issue.file, 12)), + Style::default().fg(color), + ), + Span::styled( + format!( + " {}", + truncate_str(&issue.message, area.width.saturating_sub(22) as usize) + ), + Style::default().fg(theme.main_fg), + ), + ])); + } + } + + let block = btop_block_active("models", "⁸", theme.mem_box, theme, active); + f.render_widget(Paragraph::new(lines).block(block), area); +} diff --git a/src/ui/quota.rs b/src/ui/quota.rs index 72b19e0..ca1fc68 100644 --- a/src/ui/quota.rs +++ b/src/ui/quota.rs @@ -45,14 +45,15 @@ pub(crate) fn draw_quota_panel_active( let ticks_per_min = 30usize; let tokens_per_min: f64 = rates.iter().rev().take(ticks_per_min).sum(); - // Split into side-by-side columns: one per known source (CLAUDE | CODEX). - // Columns are always rendered so the panel layout stays stable even when a - // source has no data yet. - let num_sources = SOURCES.len() as u16; + // Split into side-by-side columns for active sources. When a workspace is + // Codex-only, give Codex the full quota panel instead of spending half the + // space on an empty Claude column. + let sources = active_quota_sources(app); + let num_sources = sources.len() as u16; let col_w = inner.width / num_sources; let content_h = inner.height.saturating_sub(1); // reserve last row for totals - for (i, source) in SOURCES.iter().enumerate() { + for (i, source) in sources.iter().enumerate() { let col_x = inner.x + (i as u16) * col_w; let this_w = if i as u16 == num_sources - 1 { inner.width - (i as u16) * col_w @@ -108,12 +109,13 @@ fn draw_source_column( let bar_w = col_w_usize.saturating_sub(10).clamp(2, 8); let Some(rl) = rl else { - let hint = if source.eq_ignore_ascii_case("claude") { - t("quota.abtop_setup") + let hint = if source.eq_ignore_ascii_case("codex") { + t("quota.codex_wait") + } else if source.eq_ignore_ascii_case("claude") { + t("quota.claude_wait") } else { - t("quota.run_codex") + t("quota.no_data") }; - let no_data = t("quota.no_data"); let lines = vec![ Line::from(Span::styled( format!(" {}", source.to_uppercase()), @@ -122,11 +124,11 @@ fn draw_source_column( .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - format!(" — {}", no_data), + format!(" — {}", t("quota.usage_unknown")), Style::default().fg(theme.inactive_fg), )), Line::from(Span::styled( - format!(" {}", hint), + format!(" {}", hint), Style::default().fg(theme.graph_text), )), ]; @@ -167,10 +169,13 @@ fn draw_source_column( if let Some(used_pct) = rl.five_hour_pct { let remaining = (100.0 - used_pct).clamp(0.0, 100.0); - let reset = if show_reset { - rl.five_hour_resets_at - .map(format_reset_time) - .unwrap_or_default() + let detail = if show_reset { + format_quota_detail( + rl.five_hour_resets_at, + rl.five_hour_burn_pct_per_hour, + rl.five_hour_eta_secs, + now, + ) } else { String::new() }; @@ -190,20 +195,23 @@ fn draw_source_column( // when there's nothing meaningful to show (stale source or the // cached reset moment is past), render it blank. lines.push(Line::from(Span::styled( - if reset.is_empty() { + if detail.is_empty() { String::new() } else { - format!(" {}", reset) + format!(" {}", detail) }, Style::default().fg(theme.graph_text), ))); } if let Some(used_pct) = rl.seven_day_pct { let remaining = (100.0 - used_pct).clamp(0.0, 100.0); - let reset = if show_reset { - rl.seven_day_resets_at - .map(format_reset_time) - .unwrap_or_default() + let detail = if show_reset { + format_quota_detail( + rl.seven_day_resets_at, + rl.seven_day_burn_pct_per_hour, + rl.seven_day_eta_secs, + now, + ) } else { String::new() }; @@ -223,10 +231,10 @@ fn draw_source_column( // when there's nothing meaningful to show (stale source or the // cached reset moment is past), render it blank. lines.push(Line::from(Span::styled( - if reset.is_empty() { + if detail.is_empty() { String::new() } else { - format!(" {}", reset) + format!(" {}", detail) }, Style::default().fg(theme.graph_text), ))); @@ -235,18 +243,29 @@ fn draw_source_column( f.render_widget(Paragraph::new(lines), area); } -/// Format a reset timestamp as a human countdown labeled "in X" so the -/// row reads as a time-until-reset. Returns an empty string when the -/// reset is already in the past — the actual next reset depends on the -/// window length which the caller doesn't track, and showing a "now" -/// sentinel was misleading on stale sources where the window had -/// already rolled over multiple times. Callers skip the row when this -/// returns empty. -pub(crate) fn format_reset_time(reset_ts: u64) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); +fn active_quota_sources(app: &App) -> Vec<&'static str> { + let active: Vec<&'static str> = SOURCES + .iter() + .copied() + .filter(|source| { + app.sessions + .iter() + .any(|s| s.agent_cli.eq_ignore_ascii_case(source)) + || app + .rate_limits + .iter() + .any(|r| r.source.eq_ignore_ascii_case(source)) + }) + .collect(); + + if active.is_empty() { + SOURCES.to_vec() + } else { + active + } +} + +fn format_reset_time_at(reset_ts: u64, now: u64) -> String { if reset_ts <= now { return String::new(); } @@ -266,3 +285,144 @@ pub(crate) fn format_reset_time(reset_ts: u64) -> String { format!("{} {}{} {}{}", prefix, d, t("time.d"), h, t("time.h")) } } + +fn format_quota_detail( + reset_ts: Option, + burn_pct_per_hour: Option, + eta_secs: Option, + now: u64, +) -> String { + let reset = reset_ts + .map(|ts| format_reset_time_at(ts, now)) + .unwrap_or_default(); + let Some(burn) = burn_pct_per_hour.filter(|burn| *burn >= 0.05) else { + return reset; + }; + let burn = format_burn_rate(burn); + + if let (Some(eta), Some(reset_secs)) = (eta_secs, reset_ts.and_then(|ts| ts.checked_sub(now))) { + if eta < reset_secs { + return format!("{} {} {}", t("quota.cap"), format_duration_short(eta), burn); + } + } + + if reset.is_empty() { + burn + } else { + format!("{} {}", reset, burn) + } +} + +fn format_burn_rate(burn_pct_per_hour: f64) -> String { + if burn_pct_per_hour >= 10.0 { + format!("+{:.0}%/h", burn_pct_per_hour) + } else { + format!("+{:.1}%/h", burn_pct_per_hour) + } +} + +fn format_duration_short(secs: u64) -> String { + if secs < 60 { + format!("{}{}", secs, t("time.s")) + } else if secs < 3600 { + format!("{}{}", secs / 60, t("time.m")) + } else if secs < 86400 { + let h = secs / 3600; + let m = (secs % 3600) / 60; + if m == 0 { + format!("{}{}", h, t("time.h")) + } else { + format!("{}{} {}{}", h, t("time.h"), m, t("time.m")) + } + } else { + let d = secs / 86400; + let h = (secs % 86400) / 3600; + if h == 0 { + format!("{}{}", d, t("time.d")) + } else { + format!("{}{} {}{}", d, t("time.d"), h, t("time.h")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PanelVisibility; + use crate::model::{AgentSession, SessionStatus}; + + fn test_app() -> App { + App::new_with_config(Theme::default(), &[], PanelVisibility::default()) + } + + fn test_session(agent_cli: &'static str) -> AgentSession { + AgentSession { + agent_cli, + pid: 1, + session_id: String::new(), + cwd: String::new(), + project_name: String::new(), + started_at: 0, + status: SessionStatus::Waiting, + model: String::new(), + effort: String::new(), + context_percent: 0.0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read: 0, + total_cache_create: 0, + turn_count: 0, + current_tasks: Vec::new(), + mem_mb: 0, + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: Vec::new(), + context_history: Vec::new(), + compaction_count: 0, + context_window: 0, + subagents: Vec::new(), + mem_file_count: 0, + mem_line_count: 0, + children: Vec::new(), + initial_prompt: String::new(), + first_assistant_text: String::new(), + chat_messages: Vec::new(), + tool_calls: Vec::new(), + pending_since_ms: 0, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: String::new(), + } + } + + #[test] + fn quota_sources_focus_codex_only_sessions() { + let mut app = test_app(); + app.sessions.push(test_session("codex")); + + assert_eq!(active_quota_sources(&app), vec!["codex"]); + } + + #[test] + fn quota_sources_default_when_runtime_is_empty() { + let app = test_app(); + + assert_eq!(active_quota_sources(&app), vec!["claude", "codex"]); + } + + #[test] + fn quota_detail_warns_when_cap_arrives_before_reset() { + let detail = format_quota_detail(Some(10_000), Some(50.0), Some(3_600), 1_000); + + assert_eq!(detail, "cap 1h +50%/h"); + } + + #[test] + fn quota_detail_keeps_reset_when_reset_arrives_first() { + let detail = format_quota_detail(Some(4_600), Some(10.0), Some(7_200), 1_000); + + assert_eq!(detail, "in 1h 0m +10%/h"); + } +} diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index e7a745d..8085ccd 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -134,9 +134,10 @@ pub(crate) fn draw_sessions_panel_active( let marker = if selected { "►" } else { " " }; let (agent_label, agent_color) = match session.agent_cli { - "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta - "codex" => (">CD", Color::Rgb(122, 157, 255)), // #7A9DFF periwinkle - "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // #4ADE80 emerald + "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta + "codex" => (">CD", Color::Rgb(122, 157, 255)), // #7A9DFF periwinkle + "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // #4ADE80 emerald + "factory" => ("@FC", Color::Rgb(129, 140, 248)), // #818CF8 indigo other => { let fallback: String = other.chars().take(3).collect::().to_uppercase(); ( @@ -162,8 +163,8 @@ pub(crate) fn draw_sessions_panel_active( let is_done = matches!(session.status, crate::model::SessionStatus::Done); let row_style = if selected { Style::default() - .bg(theme.selected_bg) - .fg(theme.selected_fg) + .bg(theme.div_line) + .fg(theme.main_fg) .add_modifier(Modifier::BOLD) } else if is_done { Style::default().fg(theme.inactive_fg) @@ -255,8 +256,7 @@ pub(crate) fn draw_sessions_panel_active( rows.push(Row::new(cells).style(row_style).height(1)); // 2nd line: task text in Summary column - let summary_idx = - 3 + show_pid as usize + show_session_id as usize + show_config as usize; + let summary_idx = 3 + show_pid as usize + show_session_id as usize + show_config as usize; let total_cols = 6 + show_pid as usize + show_session_id as usize @@ -881,12 +881,23 @@ pub(crate) fn draw_sessions_panel_active( } else { format!(" · effort: {}", session.effort) }; + let avg_tokens = if session.turn_count > 0 { + format!( + " · avg {}/t", + fmt_tokens(session.total_tokens() / session.turn_count as u64) + ) + } else { + String::new() + }; footer_lines.push(Line::from(Span::styled( format!( - " {} · {} · {} turns{}", + " {} · {} · {} turns · active {} · total {}{}{}", session.version, session.elapsed_display(), session.turn_count, + fmt_tokens(session.active_tokens()), + fmt_tokens(session.total_tokens()), + avg_tokens, effort_part, ), Style::default().fg(theme.inactive_fg), diff --git a/src/ui/view_menu.rs b/src/ui/view_menu.rs index a8850ea..c3e7b95 100644 --- a/src/ui/view_menu.rs +++ b/src/ui/view_menu.rs @@ -76,6 +76,16 @@ pub(crate) fn items(app: &App) -> Vec { label: t("view.mcp_servers_panel").leak(), state: bool_state(app.show_mcp), }, + ViewItem { + key: '8', + label: t("view.models_panel").leak(), + state: bool_state(app.show_models), + }, + ViewItem { + key: '9', + label: t("view.missions_panel").leak(), + state: bool_state(app.show_missions), + }, ViewItem { key: 'M', label: t("view.mcp_session_hide").leak(),