From 2888c08704332e3c5e448bea7f74919f54706a56 Mon Sep 17 00:00:00 2001 From: RoyLin Date: Mon, 29 Jun 2026 16:58:41 +0800 Subject: [PATCH] release: v0.5.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TUI context & UX hardening + 书安OS login: - context: model-aware auto-compaction (threshold scaled to each model's real window, 128k default for undeclared models, color-coded ctx%, ContextCompacted notice). - tokens: ↓ counts OUTPUT (completion) tokens, not prompt+completion; CJK-aware live estimate. - fix: planning/parallel turns that end without text auto-synthesize a final answer in every mode (was ultracode-only); clear the subagent panel at turn end. - ui: route the whole TUI through the Tokyo Night palette (was raw ANSI). - OS login: persistent session + automatic token refresh; the OAuth callback survives browser preconnect/favicon; helpful guidance when `os` isn't configured; endpoint + token exported to the agent shell env (no per-call config read). - OS skill: a3s-os-capabilities drives the platform-wide progressive API (POST /api/v1/kernel/capabilities) — surfaces viewUrl, stays concise, narrows list→describe→execute. - deps: pin a3s-code-core (AI45Lab/Code) + a3s-tui (A3S-Lab/TUI) to git revs that carry the APIs this release needs. Also bundles in-progress `a3s box` / `a3s tools` / Claude+Codex accounts / top. --- .github/workflows/ci.yml | 1 + CLAUDE.md | 66 + Cargo.lock | 322 +- Cargo.toml | 12 +- README.md | 29 +- skills/a3s-os-capabilities.md | 124 + src/a3s_os.rs | 911 ++ src/box_cmd.rs | 566 ++ src/claude.rs | 103 + src/claude/code_cli.rs | 631 ++ src/claude/credentials.rs | 248 + src/claude/host_tools.rs | 632 ++ src/claude/model.rs | 41 + src/claude/protocol.rs | 295 + src/claude/raw_messages.rs | 435 + src/main.rs | 32 +- src/tools.rs | 132 + src/top/collect.rs | 277 + src/top/mod.rs | 15630 +++++++++++++++++++++++++++++--- src/top/view.rs | 113 + src/tui/config.rs | 3 + src/tui/mod.rs | 1744 +++- src/tui/panels/banner.rs | 7 +- src/tui/panels/btw.rs | 9 +- src/tui/panels/effort.rs | 16 +- src/tui/panels/files.rs | 7 +- src/tui/panels/git.rs | 72 +- src/tui/panels/help.rs | 9 +- src/tui/panels/ide.rs | 28 +- src/tui/panels/login.rs | 176 +- src/tui/panels/menu.rs | 5 +- src/tui/panels/model.rs | 299 +- src/tui/panels/plan.rs | 112 +- src/tui/panels/plugins.rs | 14 +- src/tui/panels/relay.rs | 51 +- src/tui/panels/theme.rs | 6 +- src/tui/panels/top.rs | 119 +- src/tui/render.rs | 177 +- src/tui/util.rs | 141 +- src/update.rs | 586 +- tests/box_command.rs | 116 + tests/box_command_soak.rs | 68 + tests/support/mod.rs | 136 + 43 files changed, 22694 insertions(+), 1807 deletions(-) create mode 100644 CLAUDE.md create mode 100644 skills/a3s-os-capabilities.md create mode 100644 src/a3s_os.rs create mode 100644 src/box_cmd.rs create mode 100644 src/claude.rs create mode 100644 src/claude/code_cli.rs create mode 100644 src/claude/credentials.rs create mode 100644 src/claude/host_tools.rs create mode 100644 src/claude/model.rs create mode 100644 src/claude/protocol.rs create mode 100644 src/claude/raw_messages.rs create mode 100644 src/tools.rs create mode 100644 src/top/collect.rs create mode 100644 src/top/view.rs create mode 100644 tests/box_command.rs create mode 100644 tests/box_command_soak.rs create mode 100644 tests/support/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f58f945..50af351 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: with: { components: "rustfmt, clippy" } - run: cargo fmt --all -- --check - run: cargo clippy --all-targets -- -D warnings + - run: cargo test --all-targets - run: cargo build --release # Validate the TUI compiles for Windows + macOS so all three desktop diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..98a18fe --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ + +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/Cargo.lock b/Cargo.lock index 2d700ae..9ebf357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "a3s" -version = "0.5.11" +version = "0.5.12" dependencies = [ "a3s-code-core", "a3s-tui", @@ -13,12 +13,21 @@ dependencies = [ "base64 0.22.1", "futures", "image", + "rand 0.8.6", + "reqwest 0.11.27", + "serde", "serde_json", + "sha2", "similar", "tokio", "tokio-util", ] +[[package]] +name = "a3s-acl" +version = "0.2.0" +source = "git+https://github.com/A3S-Lab/ACL.git?tag=v0.2.0#ccb8622b42847481c451197d4f9ce5623a350a25" + [[package]] name = "a3s-acl" version = "0.2.1" @@ -27,11 +36,10 @@ checksum = "35b83be97f61abdd33096446eae063f9d65d98e84c446560b59d3c744194287e" [[package]] name = "a3s-code-core" -version = "4.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7154923a9eb1ca6e8425feb92d8c7ddb58ae1616d1f751cee2e9c7f54a9e6ec0" +version = "4.1.0" +source = "git+https://github.com/AI45Lab/Code.git?rev=e1b65e8fd5dc3cf392d073173c37213669d0f782#e1b65e8fd5dc3cf392d073173c37213669d0f782" dependencies = [ - "a3s-acl", + "a3s-acl 0.2.0", "a3s-common", "a3s-lane", "a3s-memory", @@ -51,6 +59,7 @@ dependencies = [ "html2text", "ignore", "lopdf", + "notify", "pdf-extract", "pin-project-lite", "regex", @@ -133,16 +142,17 @@ dependencies = [ [[package]] name = "a3s-search" -version = "1.2.3" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b21472e0812fe14e8577e6dcee6eb0267fb50897dd8325d348e243b82639298" +checksum = "d6d8b7d11e484044e2311418d8cacfc417359e5b536e37408c0b4bda3d10c9d1" dependencies = [ - "a3s-acl", + "a3s-acl 0.2.1", "a3s-updater", "anyhow", "async-trait", "chromiumoxide", "clap", + "dom_smoothie", "futures", "reqwest 0.12.28", "scraper", @@ -161,7 +171,7 @@ dependencies = [ [[package]] name = "a3s-tui" version = "0.1.4" -source = "git+https://github.com/A3S-Lab/TUI.git?rev=9db984689ed76ffae272f5410fdc274f93257eaa#9db984689ed76ffae272f5410fdc274f93257eaa" +source = "git+https://github.com/A3S-Lab/TUI.git?rev=be0ed1864a9a9f27ecf9999a98e731e51e7fa569#be0ed1864a9a9f27ecf9999a98e731e51e7fa569" dependencies = [ "comrak", "crossterm", @@ -278,7 +288,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -289,7 +299,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -567,6 +577,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -992,13 +1017,26 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.6.1", "dtoa-short", "itoa", "phf 0.11.3", "smallvec", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros 0.7.0", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -1009,6 +1047,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -1057,6 +1105,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -1105,6 +1174,40 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser 0.37.0", + "foldhash", + "html5ever 0.39.0", + "nom 8.0.0", + "precomputed-hash", + "selectors 0.38.0", + "tendril 0.5.0", +] + +[[package]] +name = "dom_smoothie" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8b9b294aabb8010b37c49a07d6f82175152f4927855d534979a38737721875" +dependencies = [ + "dom_query", + "flagset", + "foldhash", + "gjson", + "html-escape", + "once_cell", + "phf 0.13.1", + "tendril 0.5.0", + "thiserror 2.0.18", + "unicode-segmentation", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1172,7 +1275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1242,6 +1345,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -1273,6 +1382,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -1466,6 +1584,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gjson" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43503cc176394dd30a6525f5f36e838339b8b5619be33ed9a7783841580a97b6" + [[package]] name = "glob" version = "0.3.3" @@ -1589,6 +1713,15 @@ dependencies = [ "phf 0.13.1", ] +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "html2text" version = "0.16.7" @@ -1623,6 +1756,16 @@ dependencies = [ "markup5ever 0.38.0", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", +] + [[package]] name = "http" version = "0.2.12" @@ -1781,7 +1924,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -1968,6 +2111,26 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inotify" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +dependencies = [ + "bitflags 2.13.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1997,6 +2160,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2076,7 +2259,7 @@ dependencies = [ "itoa", "log", "md-5", - "nom", + "nom 7.1.3", "rangemap", "rayon", "time", @@ -2120,6 +2303,17 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + [[package]] name = "markup5ever_rcdom" version = "0.38.0+unofficial" @@ -2228,13 +2422,49 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2636,7 +2866,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.41", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -2673,7 +2903,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -2982,6 +3212,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3005,7 +3244,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3107,12 +3346,12 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" dependencies = [ - "cssparser", + "cssparser 0.34.0", "ego-tree", "getopts", "html5ever 0.29.1", "precomputed-hash", - "selectors", + "selectors 0.26.0", "tendril 0.4.3", ] @@ -3133,8 +3372,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ "bitflags 2.13.0", - "cssparser", - "derive_more", + "cssparser 0.34.0", + "derive_more 0.99.20", "fxhash", "log", "new_debug_unreachable", @@ -3145,6 +3384,25 @@ dependencies = [ "smallvec", ] +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.0", + "cssparser 0.37.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" @@ -3390,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3581,7 +3839,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4007,6 +4265,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4055,6 +4319,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4298,7 +4568,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 58ef3f0..5914c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "a3s" -version = "0.5.11" +version = "0.5.12" edition = "2021" description = "a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI" license = "MIT" @@ -14,10 +14,11 @@ name = "a3s" path = "src/main.rs" [dependencies] -a3s-code-core = "4.2.8" -a3s-tui = { git = "https://github.com/A3S-Lab/TUI.git", rev = "9db984689ed76ffae272f5410fdc274f93257eaa" } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +a3s-code-core = { git = "https://github.com/AI45Lab/Code.git", rev = "e1b65e8fd5dc3cf392d073173c37213669d0f782" } +a3s-tui = { git = "https://github.com/A3S-Lab/TUI.git", rev = "be0ed1864a9a9f27ecf9999a98e731e51e7fa569" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "net"] } anyhow = "1" +serde = { version = "1", features = ["derive"] } serde_json = "1" similar = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] } @@ -26,6 +27,9 @@ async-trait = "0.1" tokio-util = "0.7" base64 = "0.22" futures = "0.3" +rand = "0.8" +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +sha2 = "0.10" [profile.release] opt-level = "z" diff --git a/README.md b/README.md index 92b30db..ffe6c36 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,38 @@ brew install A3S-Lab/tap/a3s Then run the tools you need. `a3s box ...` installs `a3s-box` on first use. +## Account Models + +In `a3s code`, `/model` lists configured `config.acl` models plus signed-in +account tabs. When Claude Code is logged in (`claude /login`), the Claude Code +tab can switch the current session to Claude models using the local Claude Code +OAuth credentials, including Claude Code's macOS Keychain entry. +`CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_AUTH_TOKEN` can also provide the account +token for non-standard environments. If Anthropic rejects the raw OAuth Messages +API bridge with a rate-limit or authentication error, a3s falls back to the +installed `claude` CLI in safe streaming mode; Claude Code's own tools stay +disabled while a3s host tools are requested through an adapter protocol and +still execute inside a3s-code. The adapter accepts Claude Code-style +`` output and tool names such as `Read` or `Bash`, normalizes +common argument aliases like `path` to a3s's `file_path`, and feeds tool results +back into the next Claude turn as structured history. + +## Testing + +```sh +cargo test --all-targets +cargo test --test box_command_soak -- --ignored +``` + +The ignored soak test repeats `a3s box` after a fake first-use install and +verifies later runs reuse the installed `a3s-box`. + ## Updating In the TUI, **`/update`** upgrades to the latest release and restarts into your session — Homebrew installs are refreshed + upgraded; standalone installs swap -the binary directly. +the binary directly. Both paths verify the installed binary reports the target +version before treating the update as successful. If you're on an **older build (≤ 0.5.4)** whose `/update` was broken, it can't upgrade itself, and `brew upgrade a3s` alone won't see the new version (Homebrew diff --git a/skills/a3s-os-capabilities.md b/skills/a3s-os-capabilities.md new file mode 100644 index 0000000..ab83bb4 --- /dev/null +++ b/skills/a3s-os-capabilities.md @@ -0,0 +1,124 @@ +--- +name: a3s-os-capabilities +description: "书安OS progressive API — the way to answer ANY question about the signed-in 书安OS platform: your platform account/identity, what the platform can do, and its data / resources / state (LLM/OCR, assets, packages, runtime, knowledge, observability, …). One action-dispatched endpoint, broad-to-narrow: list -> search -> describe -> execute. Use this — NOT the local shell (whoami/paths) — for anything about the OS platform. Available only when signed in." +kind: instruction +allowed-tools: "bash(*), read(*)" +--- + +# 书安OS progressive API (渐进式 API) + +Discover and call the 书安OS platform capabilities you are authorized for, +*progressively* (broad → narrow), through **one** endpoint. This is the whole +platform's capability surface — not just one domain: AI capabilities (LLM/OCR +config), assets, packages, registry, runtime, resources, knowledge bases, +observability, marketplace, and more. Security is one domain among these. + +Use this skill for **any** question about the OS platform — when the user says +"OS" they mean this signed-in platform, not the local operating system. That +includes your platform **account / identity** ("what's my OS account?"), what the +platform can do, and its data/state ("what LLM/OCR is configured", "OCR this +PDF", "list my assets", "search platform operations for X"). Do NOT answer these +from the local shell or filesystem (`whoami`, paths, env) — those describe this +machine, not the OS platform. Start with `list` to find the right module/op. + +Single endpoint (`action`-dispatched, always `POST` with a JSON body): + +``` +POST {{BASE_URL}}/api/v1/kernel/capabilities +``` + +Flow ("先广后窄" / broad-then-narrow) — like a CLI, expand on demand instead of +loading every manual into context: + +``` +list → search → describe → execute +(modules) (find op) (op schema) (run it) + git --help apropos rebase --help rebase -i +``` + +## Authentication + +The endpoint and Bearer token are **already in your shell environment** (exported +when you signed in) — use them directly; do NOT read `~/.a3s/os-auth.json` or any +config file on each call: + +- `$A3S_OS_BASE_URL` — the platform base URL (`{{BASE_URL}}`) +- `$A3S_OS_TOKEN` — the Bearer token + +Everything is permission-filtered by that token; you only ever see/run what the +signed-in user may access. + +## Request + +```json +{ "action": "list | search | describe | execute", + "module": " (describe / execute)", + "query": " (search)", + "operation": " (execute = the op to run; describe = return just that one op's full schema)", + "params": { } } +``` + +| action | needs | returns | +| --- | --- | --- | +| `list` | — | every module you can access (name, description, path, operationCount) | +| `search` | `query` | matching operations across modules | +| `describe` | `module` (+ optional `operation`) | the module's sub-modules + its operations; or, with `operation`, just that ONE operation's full input/output schema | +| `execute` | `module`, `operation`, `params` | the operation result (`data`), plus an optional `viewUrl` deep link and `ui` agent-ui directive | + +## Rules + +- **Stay narrow — never dump the whole catalog.** Walk it like a CLI, one rung at + a time, fetching only what the user's question needs: `list` (modules only) → + pick the relevant module → `describe` it to see its **sub-modules** and + operation counts (drill into a sub-module, don't enumerate every operation) → + `search`/`describe ` for the ONE operation you'll run → + `execute`. Show the user only the operation(s) that answer them, not every + interface. +- **Keep output tight — extract, don't dump.** Pipe every response through `jq` + to pull only the fields you need (e.g. `... | jq -r '.data.modules[].name'` for + a module list, `... | jq '.data | keys'` to peek a shape) so the result is a few + relevant lines, not a raw JSON blob. In your reply to the user, summarize in a + few lines — do NOT paste the whole response back. (The TUI already collapses + long tool output to ~5 lines, but a targeted `jq` result is what keeps both the + tool line and your answer short.) +- Never guess `module`, `operation`, field names, or enums. `list` / `search` / + `describe` first, then build `params` from the returned schema. `describe` with + an `operation` gives that op's exact schema — the rung right before `execute`. +- On an `execute` schema error, re-`describe` that operation and fix `params` + instead of inventing fields. +- Prefer read/`GET`-style operations for discovery; write operations (create / + update / delete) run with the user's real platform permissions — confirm intent + before mutating platform state. +- **Always surface `viewUrl`.** Many responses include a `viewUrl` — a deep link + to the console page for exactly what the user asked about. WHENEVER the response + contains one, extract it robustly (it may be top-level or nested, e.g. + `jq -r '.. | .viewUrl? // empty'` over the response) and present it to the user + as a clearly labeled, clickable link on its own line — e.g. + `🔗 在控制台查看: ` (write the label in the user's language). The TUI + renders bare URLs as clickable, so include the full URL. Never fabricate a + `viewUrl` that wasn't returned, and never drop one that was. +- The `ui` field (`protocol: "agent-ui"`) is a host-rendered remote component — + note that it exists if present, but don't try to render it yourself. + +## Examples + +```bash +# Endpoint + token come from the env exported at login — no config read needed. +API="$A3S_OS_BASE_URL/api/v1/kernel/capabilities" +post() { curl -s -X POST "$API" -H "Authorization: Bearer $A3S_OS_TOKEN" -H 'Content-Type: application/json' -d "$1"; } + +post '{"action":"list"}' # 1. what modules exist +post '{"action":"search","query":"ocr"}' # 2. find operations +post '{"action":"describe","module":"kernel","operation":"runOcr"}' # 3. exact schema +``` + +```json +// 4a. list the system's configured LLM/OCR capabilities (masked projection) +{ "action": "execute", "module": "kernel", "operation": "listAiCaps" } +``` + +```json +// 4b. run OCR through the platform's configured backend (you never see its URL/key) +{ "action": "execute", "module": "kernel", "operation": "runOcr", + "params": { "url": "https://…/spec.pdf", "mimeType": "application/pdf", "modelType": "document", "outputFormat": "markdown" } } +``` diff --git a/src/a3s_os.rs b/src/a3s_os.rs new file mode 100644 index 0000000..19cf295 --- /dev/null +++ b/src/a3s_os.rs @@ -0,0 +1,911 @@ +//! OS account login helpers for the TUI. + +use a3s_code_core::config::OsConfig; +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(180); +const HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const OAUTH_CLIENT_ID: &str = "a3s-code"; +const OAUTH_SCOPE: &str = "profile offline_access"; +const STORE_FILE: &str = "os-auth.json"; + +/// Built-in `a3s-os-capabilities` skill that drives OS's progressive API — +/// the platform-wide kernel `capabilities` endpoint (POST /api/v1/kernel/ +/// capabilities), spanning all domains, not just security. Materialized under +/// `~/.a3s/os-skills/` only when signed in; `{{BASE_URL}}` is replaced with the +/// configured OS address so the agent calls the right endpoint. +const CAPABILITY_SKILL: &str = include_str!("../skills/a3s-os-capabilities.md"); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct StoredOsSession { + pub address: String, + pub access_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_label: Option, + pub login_at_ms: u64, +} + +impl StoredOsSession { + pub(crate) fn display_label(&self) -> String { + self.account_label + .clone() + .unwrap_or_else(|| self.address.clone()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct OsAuthStore { + #[serde(default)] + sessions: Vec, +} + +#[derive(Debug, Deserialize)] +struct OAuthTokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + token_type: Option, + #[serde(default)] + expires_in: Option, +} + +#[derive(Debug, Deserialize)] +struct OAuthTokenError { + error: String, + #[serde(default)] + error_description: Option, +} + +pub(crate) async fn login_via_browser(config: OsConfig) -> Result { + validate_address(&config.address)?; + let state = random_url_token(32); + let code_verifier = pkce_verifier(); + let code_challenge = pkce_challenge(&code_verifier); + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .context("bind local OS login callback")?; + let port = listener.local_addr()?.port(); + let redirect_uri = format!("http://127.0.0.1:{port}/callback"); + let authorize_url = + build_authorization_url(&config.address, &redirect_uri, &state, &code_challenge); + open_browser(&authorize_url).with_context(|| { + format!("open browser failed; visit this URL manually to continue: {authorize_url}") + })?; + + let callback = tokio::time::timeout(CALLBACK_TIMEOUT, wait_for_callback(listener, &state)) + .await + .map_err(|_| anyhow!("timed out waiting for OS login callback"))??; + if let Some(error) = callback + .get("error") + .filter(|value| !value.trim().is_empty()) + { + let description = callback + .get("error_description") + .map(String::as_str) + .unwrap_or("OAuth2 authorization failed"); + anyhow::bail!("OS OAuth2 authorization failed: {error}: {description}"); + } + let code = callback + .get("code") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("OAuth2 callback did not include an authorization code"))?; + let token = + exchange_authorization_code(&config.address, code, &redirect_uri, &code_verifier).await?; + let session = session_from_token_response(&config.address, token); + save_session(&session)?; + Ok(session) +} + +pub(crate) fn login_with_token(config: &OsConfig, token: &str) -> Result { + validate_address(&config.address)?; + let token = token.trim(); + if token.is_empty() { + anyhow::bail!("token is empty"); + } + let session = StoredOsSession { + address: normalize_address(&config.address), + access_token: token.to_string(), + refresh_token: None, + token_type: Some("Bearer".to_string()), + expires_at_ms: None, + account_label: None, + login_at_ms: now_ms(), + }; + save_session(&session)?; + Ok(session) +} + +pub(crate) fn logout(config: &OsConfig) -> Result { + let path = auth_store_path()?; + remove_session_at(&path, &normalize_address(&config.address)) +} + +/// Env vars the agent's `bash` inherits so it can call the progressive API +/// without re-reading `~/.a3s/os-auth.json` on every turn (the shell can't keep +/// state between tool calls, so the address/token would otherwise be looked up +/// each time). `spawn_shell` runs `bash` with the cli's process env (no +/// `env_clear`), so setting them here makes `$A3S_OS_BASE_URL` / `$A3S_OS_TOKEN` +/// available to every command. +pub(crate) const OS_ENV_BASE_URL: &str = "A3S_OS_BASE_URL"; +pub(crate) const OS_ENV_TOKEN: &str = "A3S_OS_TOKEN"; + +/// Export the signed-in platform endpoint + token to the process env so the +/// agent's shell can use them directly. Called on login and on startup restore. +pub(crate) fn export_os_env(session: &StoredOsSession) { + std::env::set_var(OS_ENV_BASE_URL, &session.address); + std::env::set_var(OS_ENV_TOKEN, &session.access_token); +} + +/// Clear the exported platform env (called on /logout). +pub(crate) fn clear_os_env() { + std::env::remove_var(OS_ENV_BASE_URL); + std::env::remove_var(OS_ENV_TOKEN); +} + +/// The stored session for the configured OS address, if the user logged in +/// on a previous run. This is the load-back half of `save_session`: without it a +/// persisted login is never restored, so the user has to `/login` every launch. +/// Best-effort — any read/parse error is treated as "not signed in". +pub(crate) fn current_session(config: &OsConfig) -> Option { + let path = auth_store_path().ok()?; + current_session_at(&path, &normalize_address(&config.address)) +} + +fn current_session_at(path: &Path, address: &str) -> Option { + read_store(path) + .ok()? + .sessions + .into_iter() + .find(|s| s.address == address) +} + +fn os_skills_root() -> Result { + let home = std::env::var_os("HOME") + .ok_or_else(|| anyhow!("HOME is not set; cannot install the OS skill"))?; + Ok(Path::new(&home).join(".a3s").join("os-skills")) +} + +/// Materialize the built-in `a3s-os-capabilities` skill (templated with the OS +/// base URL) under `~/.a3s/os-skills/` and return that directory so the caller +/// can add it to the session's skill dirs. Call only when signed in. Best-effort +/// — returns `None` on any I/O error rather than failing the launch. +pub(crate) fn ensure_capability_skill_dir(config: &OsConfig) -> Option { + let root = os_skills_root().ok()?; + ensure_capability_skill_dir_at(&root, config).ok()?; + Some(root) +} + +fn ensure_capability_skill_dir_at(root: &Path, config: &OsConfig) -> Result<()> { + let skill_dir = root.join("a3s-os-capabilities"); + std::fs::create_dir_all(&skill_dir)?; + let body = CAPABILITY_SKILL.replace("{{BASE_URL}}", &normalize_address(&config.address)); + std::fs::write(skill_dir.join("SKILL.md"), body)?; + Ok(()) +} + +/// Remove the materialized OS skill dir (called on /logout). Best-effort. +pub(crate) fn remove_capability_skill_dir() { + if let Ok(root) = os_skills_root() { + let _ = std::fs::remove_dir_all(root); + } +} + +fn session_from_token_response( + configured_address: &str, + token: OAuthTokenResponse, +) -> StoredOsSession { + StoredOsSession { + address: normalize_address(configured_address), + access_token: token.access_token, + refresh_token: token.refresh_token, + token_type: token.token_type.or_else(|| Some("Bearer".to_string())), + expires_at_ms: token + .expires_in + .map(|seconds| now_ms().saturating_add(seconds.saturating_mul(1000))), + account_label: None, + login_at_ms: now_ms(), + } +} + +/// Outcome rendered on the localhost callback page after the OAuth redirect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoginOutcome { + Success, + NotApproved, + InvalidState, +} + +/// The Chinese, OS-branded page shown in the browser once the OAuth redirect +/// lands back on the local callback. Returns `(http_status, html_body)`. +fn login_callback_page(outcome: LoginOutcome) -> (&'static str, String) { + let (status, heading, detail) = match outcome { + LoginOutcome::Success => ( + "200 OK", + "OS 授权登录成功", + "您已成功登录,可以关闭此页面,返回 a3s code 继续操作。", + ), + LoginOutcome::NotApproved => ( + "400 Bad Request", + "OS 授权未通过", + "登录授权未被批准,可以关闭此页面,返回 a3s code。", + ), + LoginOutcome::InvalidState => ( + "400 Bad Request", + "登录状态无效", + "登录状态校验失败,请返回 a3s code 重新执行 /login。", + ), + }; + let body = format!( + "\ + \ + OS 登录\ + \ +
\ +

{heading}

\ +

{detail}

" + ); + (status, body) +} + +async fn wait_for_callback( + listener: TcpListener, + expected_state: &str, +) -> Result> { + // Browsers don't make exactly one request to the redirect URI: Chrome opens + // speculative *preconnect* sockets (no bytes sent) and fetches /favicon.ico, + // and any of those can land before the real ?code=...&state=... redirect. + // Accepting only once meant a preconnect/favicon consumed the single accept + // (empty read -> missing-state bail, or a 10s read-timeout error), the + // listener was dropped, and the real callback then hit a dead port — the + // "redirects back but can't be reached" bug. So loop: skip non-OAuth + // requests (replying so the socket closes cleanly) and keep the listener + // alive until the OAuth params actually arrive. The outer CALLBACK_TIMEOUT + // bounds the whole wait. + loop { + let (mut stream, _) = listener.accept().await?; + let mut buf = [0_u8; 8192]; + // A preconnect socket sends nothing: a per-connection read timeout (or a + // 0-byte read) must NOT kill the flow — just move on to the next socket. + let n = match tokio::time::timeout(Duration::from_secs(10), stream.read(&mut buf)).await { + Ok(Ok(n)) if n > 0 => n, + _ => continue, + }; + let request = String::from_utf8_lossy(&buf[..n]); + let target = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let query = target.split_once('?').map(|(_, query)| query).unwrap_or(""); + let params = parse_query(query); + + // Not the OAuth redirect (favicon, "/", a bare preconnect that did send a + // request line): acknowledge it and keep waiting for the real callback. + if !params.contains_key("state") + && !params.contains_key("code") + && !params.contains_key("error") + { + let _ = stream + .write_all(b"HTTP/1.1 204 No Content\r\nconnection: close\r\n\r\n") + .await; + continue; + } + + let state = params.get("state").cloned().unwrap_or_default(); + let error = params.get("error").filter(|value| !value.trim().is_empty()); + let outcome = if state == expected_state && error.is_none() { + LoginOutcome::Success + } else if state == expected_state { + LoginOutcome::NotApproved + } else { + LoginOutcome::InvalidState + }; + let (status, body) = login_callback_page(outcome); + let response = format!( + "HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + + if state != expected_state { + anyhow::bail!("login callback state mismatch"); + } + return Ok(params); + } +} + +async fn exchange_authorization_code( + address: &str, + code: &str, + redirect_uri: &str, + code_verifier: &str, +) -> Result { + let token_url = build_token_url(address); + let client = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .context("build OS OAuth2 HTTP client")?; + let response = client + .post(&token_url) + .header("accept", "application/json") + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", OAUTH_CLIENT_ID), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", code_verifier), + ]) + .send() + .await + .with_context(|| format!("send OAuth2 token request to {token_url}"))?; + let status = response.status(); + let body = response + .text() + .await + .context("read OAuth2 token response body")?; + + if !status.is_success() { + if let Ok(error) = serde_json::from_str::(&body) { + let description = error.error_description.unwrap_or_default(); + anyhow::bail!( + "OAuth2 token exchange failed at {token_url} (HTTP {}): {} {}", + status.as_u16(), + error.error, + description + ); + } + anyhow::bail!( + "OAuth2 token exchange failed at {token_url} (HTTP {}): {body}", + status.as_u16() + ); + } + + serde_json::from_str::(&body) + .with_context(|| format!("parse OAuth2 token response from {token_url}")) +} + +/// Refresh proactively this many ms before the access token expires, so an +/// in-flight progressive-API call never races an expiry. +const REFRESH_SKEW_MS: u64 = 120_000; // 2 minutes + +/// True if the session both *can* be refreshed (has a refresh token + known +/// expiry) and *should* be now (expiry within `REFRESH_SKEW_MS`, or already past). +pub(crate) fn needs_refresh(session: &StoredOsSession) -> bool { + session.refresh_token.is_some() + && session + .expires_at_ms + .is_some_and(|exp| now_ms().saturating_add(REFRESH_SKEW_MS) >= exp) +} + +/// Exchange the stored refresh token for a fresh access token and persist the +/// updated session. Preserves the existing refresh token / account label when the +/// server doesn't re-issue them (refresh-token rotation is optional in OAuth2). +/// Call only when [`needs_refresh`] is true. +pub(crate) async fn refresh_session(session: &StoredOsSession) -> Result { + let refresh_token = session + .refresh_token + .clone() + .ok_or_else(|| anyhow!("no refresh token to refresh with"))?; + let token = exchange_refresh_token(&session.address, &refresh_token).await?; + let mut next = session_from_token_response(&session.address, token); + if next.refresh_token.is_none() { + next.refresh_token = Some(refresh_token); + } + if next.account_label.is_none() { + next.account_label = session.account_label.clone(); + } + save_session(&next)?; + Ok(next) +} + +async fn exchange_refresh_token(address: &str, refresh_token: &str) -> Result { + let token_url = build_token_url(address); + let client = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .context("build OS OAuth2 HTTP client")?; + let response = client + .post(&token_url) + .header("accept", "application/json") + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", OAUTH_CLIENT_ID), + ("refresh_token", refresh_token), + ]) + .send() + .await + .with_context(|| format!("send OAuth2 refresh request to {token_url}"))?; + let status = response.status(); + let body = response + .text() + .await + .context("read OAuth2 refresh response body")?; + + if !status.is_success() { + if let Ok(error) = serde_json::from_str::(&body) { + let description = error.error_description.unwrap_or_default(); + anyhow::bail!( + "OAuth2 refresh failed at {token_url} (HTTP {}): {} {}", + status.as_u16(), + error.error, + description + ); + } + anyhow::bail!( + "OAuth2 refresh failed at {token_url} (HTTP {}): {body}", + status.as_u16() + ); + } + + serde_json::from_str::(&body) + .with_context(|| format!("parse OAuth2 refresh response from {token_url}")) +} + +fn build_authorization_url( + address: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> String { + let base = normalize_address(address); + let authorize = + if base.contains('?') || base.trim_end_matches('/').ends_with("/oauth/authorize") { + base + } else { + format!("{}/oauth/authorize", base.trim_end_matches('/')) + }; + let sep = if authorize.contains('?') { '&' } else { '?' }; + format!( + "{authorize}{sep}response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}", + percent_encode(OAUTH_CLIENT_ID), + percent_encode(redirect_uri), + percent_encode(state), + percent_encode(code_challenge), + percent_encode(OAUTH_SCOPE) + ) +} + +fn build_token_url(address: &str) -> String { + let base = normalize_address(address); + if base.ends_with("/api/v1") { + format!("{base}/oauth/token") + } else { + format!("{}/api/v1/oauth/token", base.trim_end_matches('/')) + } +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let status = std::process::Command::new("open").arg(url).status(); + #[cfg(target_os = "linux")] + let status = std::process::Command::new("xdg-open").arg(url).status(); + #[cfg(target_os = "windows")] + let status = std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .status(); + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let status: std::io::Result = Err(std::io::Error::new( + std::io::ErrorKind::Other, + "unsupported OS", + )); + + match status { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(anyhow!("browser opener exited with {status}")), + Err(error) => Err(error.into()), + } +} + +fn save_session(session: &StoredOsSession) -> Result<()> { + let path = auth_store_path()?; + save_session_at(&path, session) +} + +fn save_session_at(path: &Path, session: &StoredOsSession) -> Result<()> { + let mut store = read_store(path)?; + store + .sessions + .retain(|item| item.address != session.address); + store.sessions.push(session.clone()); + store.sessions.sort_by(|a, b| a.address.cmp(&b.address)); + write_store(path, &store) +} + +fn remove_session_at(path: &Path, address: &str) -> Result { + let mut store = read_store(path)?; + let before = store.sessions.len(); + store.sessions.retain(|item| item.address != address); + let removed = store.sessions.len() != before; + if store.sessions.is_empty() { + if path.exists() { + std::fs::remove_file(path)?; + } + return Ok(removed); + } + write_store(path, &store)?; + Ok(removed) +} + +fn read_store(path: &Path) -> Result { + if !path.exists() { + return Ok(OsAuthStore::default()); + } + let raw = std::fs::read_to_string(path) + .with_context(|| format!("read OS auth store {}", path.display()))?; + serde_json::from_str(&raw).with_context(|| format!("parse OS auth store {}", path.display())) +} + +fn write_store(path: &Path, store: &OsAuthStore) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let body = serde_json::to_string_pretty(store)?; + std::fs::write(path, body)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + Ok(()) +} + +fn auth_store_path() -> Result { + let home = std::env::var_os("HOME") + .ok_or_else(|| anyhow!("HOME is not set; cannot store OS login"))?; + Ok(Path::new(&home).join(".a3s").join(STORE_FILE)) +} + +fn validate_address(address: &str) -> Result<()> { + let address = address.trim(); + if address.starts_with("https://") || address.starts_with("http://") { + Ok(()) + } else { + anyhow::bail!("OS address must start with http:// or https://"); + } +} + +fn normalize_address(address: &str) -> String { + address.trim().trim_end_matches('/').to_string() +} + +fn parse_query(query: &str) -> BTreeMap { + query + .split('&') + .filter(|part| !part.is_empty()) + .filter_map(|part| { + let (key, value) = part.split_once('=').unwrap_or((part, "")); + Some((percent_decode(key)?, percent_decode(value)?)) + }) + .collect() +} + +fn percent_encode(value: &str) -> String { + let mut out = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn percent_decode(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + i += 3; + } + b'%' => return None, + byte => { + out.push(byte); + i += 1; + } + } + } + String::from_utf8(out).ok() +} + +fn pkce_verifier() -> String { + random_url_token(32) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn random_url_token(bytes_len: usize) -> String { + let mut bytes = vec![0_u8; bytes_len]; + OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authorization_url_uses_oauth2_code_flow_with_pkce() { + let url = build_authorization_url( + "https://os.example.test/", + "http://127.0.0.1:1234/callback", + "state 1", + "challenge-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP", + ); + + assert!(url.starts_with("https://os.example.test/oauth/authorize?")); + assert!(url.contains("response_type=code")); + assert!(url.contains("client_id=a3s-code")); + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback")); + assert!(url.contains("state=state%201")); + assert!(url.contains("code_challenge=challenge-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOP")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("scope=profile%20offline_access")); + } + + #[test] + fn token_url_targets_standard_oauth2_token_endpoint() { + assert_eq!( + build_token_url("https://os.example.test/"), + "https://os.example.test/api/v1/oauth/token" + ); + assert_eq!( + build_token_url("https://os.example.test/api/v1"), + "https://os.example.test/api/v1/oauth/token" + ); + } + + #[test] + fn builds_session_from_oauth2_token_response() { + let session = session_from_token_response( + "https://os.example.test", + OAuthTokenResponse { + access_token: "tok 1".to_string(), + refresh_token: Some("ref".to_string()), + token_type: Some("Bearer".to_string()), + expires_in: Some(60), + }, + ); + + assert_eq!(session.address, "https://os.example.test"); + assert_eq!(session.access_token, "tok 1"); + assert_eq!(session.refresh_token.as_deref(), Some("ref")); + assert!(session.expires_at_ms.is_some()); + } + + #[test] + fn needs_refresh_only_when_expiring_with_a_refresh_token() { + let base = StoredOsSession { + address: "https://os.example.test".to_string(), + access_token: "a".to_string(), + refresh_token: Some("r".to_string()), + token_type: None, + expires_at_ms: None, + account_label: None, + login_at_ms: 0, + }; + // Unknown expiry → can't tell it's expiring → don't refresh. + assert!(!needs_refresh(&base)); + // Far in the future → not yet. + assert!(!needs_refresh(&StoredOsSession { + expires_at_ms: Some(now_ms() + 3_600_000), + ..base.clone() + })); + // Inside the skew window (or already past) → refresh. + assert!(needs_refresh(&StoredOsSession { + expires_at_ms: Some(now_ms() + 10_000), + ..base.clone() + })); + // Expiring but no refresh token → nothing we can do. + assert!(!needs_refresh(&StoredOsSession { + refresh_token: None, + expires_at_ms: Some(now_ms() + 10_000), + ..base.clone() + })); + } + + #[test] + fn pkce_challenge_matches_rfc7636_example() { + let challenge = pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + #[test] + fn store_replaces_and_removes_sessions_by_address() { + let dir = tempfile_dir("a3s-os-auth-test"); + let path = dir.join(STORE_FILE); + let first = StoredOsSession { + address: "https://os.example.test".to_string(), + access_token: "one".to_string(), + refresh_token: None, + token_type: Some("Bearer".to_string()), + expires_at_ms: None, + account_label: None, + login_at_ms: 1, + }; + let second = StoredOsSession { + access_token: "two".to_string(), + login_at_ms: 2, + ..first.clone() + }; + + save_session_at(&path, &first).unwrap(); + save_session_at(&path, &second).unwrap(); + let store = read_store(&path).unwrap(); + assert_eq!(store.sessions.len(), 1); + assert_eq!(store.sessions[0].access_token, "two"); + + assert!(remove_session_at(&path, "https://os.example.test").unwrap()); + assert!(!path.exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn current_session_restores_persisted_login_and_clears_on_logout() { + let dir = tempfile_dir("a3s-os-auth-restore"); + let path = dir.join(STORE_FILE); + let addr = "https://os.example.test"; + + // Nothing stored yet → signed out. + assert!(current_session_at(&path, addr).is_none()); + + let session = StoredOsSession { + address: addr.to_string(), + access_token: "tok".to_string(), + refresh_token: None, + token_type: Some("Bearer".to_string()), + expires_at_ms: None, + account_label: Some("alice".to_string()), + login_at_ms: 1, + }; + save_session_at(&path, &session).unwrap(); + + // Persisted login is restored across "runs". + let restored = current_session_at(&path, addr).expect("login should be remembered"); + assert_eq!(restored.access_token, "tok"); + assert_eq!(restored.display_label(), "alice"); + + // A different address does not match. + assert!(current_session_at(&path, "https://other.example").is_none()); + + // /logout clears the remembered login. + assert!(remove_session_at(&path, addr).unwrap()); + assert!(current_session_at(&path, addr).is_none()); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn capability_skill_materializes_templated_and_is_discoverable() { + let dir = tempfile_dir("a3s-os-skill"); + let config = OsConfig { + address: "https://os.example.test/".to_string(), + }; + ensure_capability_skill_dir_at(&dir, &config).unwrap(); + + // The cli skill loader discovers it by name (this is "生效"). + let skills = crate::tui::skills::load_skills(std::slice::from_ref(&dir)); + assert!( + skills.iter().any(|(n, _)| n == "a3s-os-capabilities"), + "a3s-os-capabilities skill not discovered: {skills:?}" + ); + + // Base URL templated in; no placeholder left. + let md = std::fs::read_to_string(dir.join("a3s-os-capabilities/SKILL.md")).unwrap(); + assert!(md.contains("https://os.example.test")); + assert!(!md.contains("{{BASE_URL}}")); + + // Definitive "生效": the *core* skill loader (stricter than the cli's + // menu parser — validates kind + fail-secure allowed-tools + 10KiB body) + // accepts it. If this parsed to None the skill would silently not load. + let skill = a3s_code_core::skills::Skill::parse(&md) + .expect("core skill loader must accept the materialized SKILL.md"); + assert_eq!(skill.name, "a3s-os-capabilities"); + assert!( + skill.allowed_tools.is_some(), + "allowed-tools must parse (fail-secure) so the skill is usable" + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn login_callback_page_is_chinese_and_branded() { + for outcome in [ + LoginOutcome::Success, + LoginOutcome::NotApproved, + LoginOutcome::InvalidState, + ] { + let (_, body) = login_callback_page(outcome); + assert!(body.contains("OS"), "missing OS branding: {outcome:?}"); + assert!( + body.contains("登录") || body.contains("授权"), + "page should be Chinese: {outcome:?}" + ); + assert!(body.starts_with(""), "not an HTML page"); + assert!(body.contains("charset=\"utf-8\""), "missing utf-8 charset"); + } + let (status, body) = login_callback_page(LoginOutcome::Success); + assert_eq!(status, "200 OK"); + assert!(body.contains("授权登录成功")); + assert_eq!( + login_callback_page(LoginOutcome::InvalidState).0, + "400 Bad Request" + ); + } + + // Regression: a browser preconnect (empty socket) and a favicon request + // arriving BEFORE the real ?code=...&state=... redirect must not kill the + // callback — the listener has to survive them. This is the "redirects back + // but can't be reached" bug. + #[tokio::test] + async fn wait_for_callback_survives_preconnect_and_favicon() { + use tokio::io::AsyncWriteExt; + use tokio::net::TcpStream; + + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { wait_for_callback(listener, "state-xyz").await }); + + // 1) preconnect: open then immediately close, sending no bytes (EOF read). + TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + // 2) favicon: a real request line but no OAuth params. + let mut fav = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + fav.write_all(b"GET /favicon.ico HTTP/1.1\r\nhost: x\r\n\r\n") + .await + .unwrap(); + // 3) the real OAuth redirect. + let mut cb = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + cb.write_all(b"GET /callback?code=abc&state=state-xyz HTTP/1.1\r\nhost: x\r\n\r\n") + .await + .unwrap(); + + let params = task.await.unwrap().expect("callback should succeed"); + assert_eq!(params.get("code").map(String::as_str), Some("abc")); + assert_eq!(params.get("state").map(String::as_str), Some("state-xyz")); + } + + fn tempfile_dir(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("{name}-{}-{}", std::process::id(), now_ms())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + path + } +} diff --git a/src/box_cmd.rs b/src/box_cmd.rs new file mode 100644 index 0000000..30fdc5b --- /dev/null +++ b/src/box_cmd.rs @@ -0,0 +1,566 @@ +//! `a3s box` proxy command. +//! +//! Runs `a3s-box ...` when it is available. If it is missing, bootstrap the +//! Box runtime first so `a3s top` and `a3s box ...` share the same happy path. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +const BOX_BINARY: &str = "a3s-box"; +const PRIMARY_BOX_RELEASE_BASE: &str = "https://github.com/A3S-Lab/Box"; +const BOX_RELEASE_BASES: &[&str] = &[PRIMARY_BOX_RELEASE_BASE, "https://github.com/AI45Lab/Box"]; + +pub async fn run(args: Vec) -> anyhow::Result<()> { + let binary = ensure_a3s_box()?; + let status = Command::new(&binary).args(args).status().map_err(|err| { + anyhow::anyhow!( + "failed to run {} at {}: {err}", + BOX_BINARY, + binary.display() + ) + })?; + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) +} + +pub(crate) fn ensure_a3s_box() -> anyhow::Result { + if let Some(path) = find_existing_a3s_box() { + return Ok(path); + } + + eprintln!("a3s: {BOX_BINARY} is not installed; installing it now..."); + if let Some(path) = install_with_homebrew() { + return Ok(path); + } + + install_standalone() +} + +fn find_existing_a3s_box() -> Option { + preferred_box_paths() + .into_iter() + .find_map(|path| executable_path(&path)) + .or_else(|| find_on_path(BOX_BINARY)) + .or_else(|| { + fallback_box_paths() + .into_iter() + .find_map(|path| executable_path(&path)) + }) +} + +fn preferred_box_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(value) = std::env::var_os("A3S_BOX_INSTALL_DIR") { + paths.push(PathBuf::from(value).join(BOX_BINARY)); + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + paths.push(parent.join(BOX_BINARY)); + } + } + + paths +} + +fn fallback_box_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(home) = std::env::var_os("HOME") { + paths.push( + PathBuf::from(home) + .join(".local") + .join("bin") + .join(BOX_BINARY), + ); + } + + paths +} + +fn install_with_homebrew() -> Option { + find_on_path("brew")?; + + eprintln!("a3s: trying Homebrew formula a3s-lab/tap/a3s-box..."); + let installed = Command::new("brew") + .args(["install", "a3s-lab/tap/a3s-box"]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + + if !installed { + eprintln!("a3s: Homebrew install failed; falling back to direct download"); + return None; + } + + find_on_path(BOX_BINARY).or_else(|| { + let output = Command::new("brew") + .args(["--prefix", "a3s-box"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if prefix.is_empty() { + return None; + } + executable_path(&PathBuf::from(prefix).join("bin").join(BOX_BINARY)) + }) +} + +fn install_standalone() -> anyhow::Result { + let target = box_asset_target().ok_or_else(|| { + anyhow::anyhow!( + "automatic a3s-box install is not supported on {}-{}; install it manually from {PRIMARY_BOX_RELEASE_BASE}/releases/latest", + std::env::consts::OS, + std::env::consts::ARCH + ) + })?; + let (release_base, latest) = fetch_latest_box_release().ok_or_else(|| { + anyhow::anyhow!( + "could not reach {PRIMARY_BOX_RELEASE_BASE}/releases/latest to install a3s-box" + ) + })?; + let url = box_release_url(release_base, &latest, target); + let bin_dir = install_bin_dir()?; + let tmp = std::env::temp_dir().join(format!("a3s-box-install-{}", std::process::id())); + let tarball = tmp.join("a3s-box.tar.gz"); + + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp)?; + std::fs::create_dir_all(&bin_dir)?; + + eprintln!("a3s: downloading a3s-box {latest} for {target}..."); + let downloaded = Command::new("curl") + .args(["-fL", "--show-error", "--progress-bar", "-o"]) + .arg(&tarball) + .arg(&url) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if !downloaded { + return Err(anyhow::anyhow!("failed to download {url}")); + } + + eprintln!("a3s: extracting a3s-box {latest}..."); + let extracted = Command::new("tar") + .arg("xzf") + .arg(&tarball) + .arg("-C") + .arg(&tmp) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if !extracted { + return Err(anyhow::anyhow!("failed to extract {}", tarball.display())); + } + + let package_dir = extracted_box_package_dir(&tmp, &latest, target)?; + + eprintln!("a3s: installing a3s-box into {}...", bin_dir.display()); + for binary in [ + "a3s-box", + "a3s-box-shim", + "a3s-box-guest-init", + "a3s-box-cri", + ] { + let src = package_dir.join(binary); + if src.exists() { + install_executable(&src, &bin_dir.join(binary))?; + } + } + + let extracted_lib = package_dir.join("lib"); + if extracted_lib.is_dir() { + for lib_dir in standalone_lib_dirs(&bin_dir) { + std::fs::create_dir_all(&lib_dir)?; + copy_dir_contents(&extracted_lib, &lib_dir)?; + } + } + + let installed = bin_dir.join(BOX_BINARY); + if !installed.exists() { + return Err(anyhow::anyhow!( + "downloaded archive did not contain {BOX_BINARY}" + )); + } + + if !path_contains_dir(&bin_dir) { + eprintln!( + "a3s: installed {} to {}; add this directory to PATH for direct use", + BOX_BINARY, + bin_dir.display() + ); + } else { + eprintln!("a3s: installed {} to {}", BOX_BINARY, bin_dir.display()); + } + + let _ = std::fs::remove_dir_all(&tmp); + Ok(installed) +} + +fn extracted_box_package_dir(tmp: &Path, version: &str, target: &str) -> anyhow::Result { + let expected = tmp.join(format!("a3s-box-v{version}-{target}")); + if expected.join(BOX_BINARY).is_file() { + return Ok(expected); + } + + if tmp.join(BOX_BINARY).is_file() { + return Ok(tmp.to_path_buf()); + } + + for entry in std::fs::read_dir(tmp)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() && path.join(BOX_BINARY).is_file() { + return Ok(path); + } + } + + Err(anyhow::anyhow!( + "downloaded archive did not contain {BOX_BINARY}" + )) +} + +fn install_executable(src: &Path, dest: &Path) -> anyhow::Result<()> { + std::fs::copy(src, dest)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +fn copy_dir_contents(src: &Path, dest: &Path) -> anyhow::Result<()> { + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if src_path.is_dir() { + std::fs::create_dir_all(&dest_path)?; + copy_dir_contents(&src_path, &dest_path)?; + } else { + std::fs::copy(&src_path, &dest_path)?; + } + } + Ok(()) +} + +fn fetch_latest_box_release() -> Option<(&'static str, String)> { + BOX_RELEASE_BASES.iter().find_map(|release_base| { + fetch_latest_box_version(release_base).map(|version| (*release_base, version)) + }) +} + +fn fetch_latest_box_version(release_base: &str) -> Option { + let out = Command::new("curl") + .args([ + "-fsSL", + "-o", + "/dev/null", + "-w", + "%{url_effective}", + &format!("{release_base}/releases/latest"), + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + version_from_release_url(&String::from_utf8_lossy(&out.stdout)) +} + +fn version_from_release_url(url: &str) -> Option { + url.trim() + .rsplit_once("/tag/v") + .map(|(_, version)| version.trim().to_string()) + .filter(|version| !version.is_empty()) +} + +fn box_release_url(release_base: &str, version: &str, target: &str) -> String { + format!("{release_base}/releases/download/v{version}/a3s-box-v{version}-{target}.tar.gz") +} + +fn box_asset_target() -> Option<&'static str> { + Some(match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "macos-arm64", + ("linux", "aarch64") => "linux-arm64", + ("linux", "x86_64") => "linux-x86_64", + _ => return None, + }) +} + +fn install_bin_dir() -> anyhow::Result { + if let Some(value) = std::env::var_os("A3S_BOX_INSTALL_DIR") { + return Ok(PathBuf::from(value)); + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + if dir_is_writable(parent) { + return Ok(parent.to_path_buf()); + } + } + } + + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("HOME is not set; set A3S_BOX_INSTALL_DIR"))?; + Ok(home.join(".local").join("bin")) +} + +fn install_prefix_for_bin_dir(bin_dir: &Path) -> PathBuf { + if bin_dir.file_name().and_then(|name| name.to_str()) == Some("bin") { + bin_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| bin_dir.to_path_buf()) + } else { + bin_dir.to_path_buf() + } +} + +fn standalone_lib_dirs(bin_dir: &Path) -> Vec { + let runtime_lib_dir = bin_dir.join("lib"); + let prefix_lib_dir = install_prefix_for_bin_dir(bin_dir).join("lib"); + if prefix_lib_dir == runtime_lib_dir { + vec![runtime_lib_dir] + } else { + vec![runtime_lib_dir, prefix_lib_dir] + } +} + +fn dir_is_writable(dir: &Path) -> bool { + let probe = dir.join(format!(".a3s-box-install-check-{}", std::process::id())); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe) + { + Ok(_) => { + let _ = std::fs::remove_file(probe); + true + } + Err(_) => false, + } +} + +fn path_contains_dir(dir: &Path) -> bool { + std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).any(|path| path == dir)) + .unwrap_or(false) +} + +fn find_on_path(binary: &str) -> Option { + let path = Path::new(binary); + if path.components().count() > 1 { + return executable_path(path); + } + std::env::var_os("PATH").and_then(|paths| { + std::env::split_paths(&paths).find_map(|dir| executable_path(&dir.join(binary))) + }) +} + +fn executable_path(path: &Path) -> Option { + if !path.is_file() { + return None; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(path).ok()?.permissions().mode(); + (mode & 0o111 != 0).then_some(path.to_path_buf()) + } + #[cfg(not(unix))] + { + Some(path.to_path_buf()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_latest_release_redirect() { + assert_eq!( + version_from_release_url("https://github.com/A3S-Lab/Box/releases/tag/v2.5.2"), + Some("2.5.2".to_string()) + ); + assert_eq!( + version_from_release_url("https://github.com/A3S-Lab/Box/releases"), + None + ); + } + + #[test] + fn builds_box_release_url() { + assert_eq!( + box_release_url(PRIMARY_BOX_RELEASE_BASE, "2.5.2", "linux-x86_64"), + "https://github.com/A3S-Lab/Box/releases/download/v2.5.2/a3s-box-v2.5.2-linux-x86_64.tar.gz" + ); + } + + #[test] + fn target_is_known_on_supported_hosts() { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) + || cfg!(all(target_os = "linux", target_arch = "aarch64")) + || cfg!(all(target_os = "linux", target_arch = "x86_64")) + { + assert!(box_asset_target().is_some()); + } + } + + #[test] + fn finds_existing_box_in_configured_install_dir() { + let _guard = env_guard(); + let root = + std::env::temp_dir().join(format!("a3s-box-install-dir-test-{}", std::process::id())); + let bin = root.join(BOX_BINARY); + make_executable(&bin); + + let old_install_dir = std::env::var_os("A3S_BOX_INSTALL_DIR"); + let old_path = std::env::var_os("PATH").unwrap_or_default(); + std::env::set_var("A3S_BOX_INSTALL_DIR", &root); + std::env::set_var("PATH", ""); + + let found = find_existing_a3s_box(); + + restore_var("A3S_BOX_INSTALL_DIR", old_install_dir); + std::env::set_var("PATH", old_path); + let _ = std::fs::remove_dir_all(root); + + assert_eq!(found, Some(bin)); + } + + #[test] + fn configured_install_dir_wins_over_path() { + let _guard = env_guard(); + let root = std::env::temp_dir().join(format!( + "a3s-box-install-dir-priority-test-{}", + std::process::id() + )); + let configured = root.join("configured"); + let path_dir = root.join("path"); + let configured_bin = configured.join(BOX_BINARY); + let path_bin = path_dir.join(BOX_BINARY); + make_executable(&configured_bin); + make_executable(&path_bin); + + let old_install_dir = std::env::var_os("A3S_BOX_INSTALL_DIR"); + let old_path = std::env::var_os("PATH").unwrap_or_default(); + std::env::set_var("A3S_BOX_INSTALL_DIR", &configured); + std::env::set_var("PATH", &path_dir); + + let found = find_existing_a3s_box(); + + restore_var("A3S_BOX_INSTALL_DIR", old_install_dir); + std::env::set_var("PATH", old_path); + let _ = std::fs::remove_dir_all(root); + + assert_eq!(found, Some(configured_bin)); + } + + #[test] + fn install_prefix_for_bin_parent() { + assert_eq!( + install_prefix_for_bin_dir(Path::new("/tmp/a3s/bin")), + PathBuf::from("/tmp/a3s") + ); + assert_eq!( + install_prefix_for_bin_dir(Path::new("/tmp/a3s-tools")), + PathBuf::from("/tmp/a3s-tools") + ); + } + + #[test] + fn standalone_lib_dirs_cover_runtime_rpath_and_prefix_layout() { + assert_eq!( + standalone_lib_dirs(Path::new("/tmp/a3s/bin")), + vec![ + PathBuf::from("/tmp/a3s/bin/lib"), + PathBuf::from("/tmp/a3s/lib") + ] + ); + assert_eq!( + standalone_lib_dirs(Path::new("/tmp/a3s-tools")), + vec![PathBuf::from("/tmp/a3s-tools/lib")] + ); + } + + #[test] + fn finds_nested_box_release_package_dir() { + let root = + std::env::temp_dir().join(format!("a3s-box-package-dir-test-{}", std::process::id())); + let package = root.join("a3s-box-v2.5.2-linux-x86_64"); + make_executable(&package.join(BOX_BINARY)); + + let found = extracted_box_package_dir(&root, "2.5.2", "linux-x86_64").unwrap(); + + let _ = std::fs::remove_dir_all(root); + assert_eq!(found, package); + } + + #[test] + fn finds_flat_box_release_package_dir() { + let root = + std::env::temp_dir().join(format!("a3s-box-flat-dir-test-{}", std::process::id())); + make_executable(&root.join(BOX_BINARY)); + + let found = extracted_box_package_dir(&root, "2.5.2", "linux-x86_64").unwrap(); + + assert_eq!(found, root); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn finds_executable_on_path() { + let _guard = env_guard(); + let root = std::env::temp_dir().join(format!("a3s-box-test-{}", std::process::id())); + let bin = root.join("a3s-box-test-bin"); + make_executable(&bin); + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let joined = std::env::join_paths( + std::iter::once(root.clone()).chain(std::env::split_paths(&old_path)), + ) + .unwrap(); + std::env::set_var("PATH", joined); + + let found = find_on_path("a3s-box-test-bin"); + + std::env::set_var("PATH", old_path); + let _ = std::fs::remove_dir_all(root); + + assert_eq!(found, Some(bin)); + } + + fn make_executable(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + fn restore_var(key: &str, value: Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + + fn env_guard() -> std::sync::MutexGuard<'static, ()> { + crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()) + } +} diff --git a/src/claude.rs b/src/claude.rs new file mode 100644 index 0000000..806af53 --- /dev/null +++ b/src/claude.rs @@ -0,0 +1,103 @@ +//! Claude Code account-backed `LlmClient`. +//! +//! The public surface is intentionally small: the TUI asks for a model and gets +//! an `LlmClient`. Internally, the client can use either the raw Anthropic +//! Messages API with Claude Code OAuth credentials or the installed `claude` +//! CLI stream-json transport when the raw OAuth bridge is rejected. + +mod code_cli; +mod credentials; +mod host_tools; +mod model; +mod protocol; +mod raw_messages; + +use a3s_code_core::llm::{ + default_http_client, HttpClient, LlmClient, LlmResponse, Message, StreamEvent, ToolDefinition, +}; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use code_cli::ClaudeCodeCliAdapter; +use credentials::ClaudeCredentials; +use raw_messages::RawMessagesClient; + +pub(crate) use credentials::has_claude_login; +pub(crate) use model::canonical_model_name; + +static PREFER_CLAUDE_CLI_TRANSPORT: AtomicBool = AtomicBool::new(false); + +pub struct ClaudeClient { + raw_messages: RawMessagesClient, + code_cli: ClaudeCodeCliAdapter, +} + +impl ClaudeClient { + pub fn from_claude_login(model: &str) -> Result { + Self::from_claude_login_with_http(model, default_http_client()) + } + + fn from_claude_login_with_http(model: &str, http: Arc) -> Result { + let model = canonical_model_name(model); + let credentials = ClaudeCredentials::from_disk()?; + Ok(Self { + raw_messages: RawMessagesClient::new(credentials.access_token, &model, http), + code_cli: ClaudeCodeCliAdapter::new(&model), + }) + } +} + +#[async_trait] +impl LlmClient for ClaudeClient { + async fn complete( + &self, + messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + ) -> Result { + let mut rx = self + .complete_streaming(messages, system, tools, CancellationToken::new()) + .await?; + while let Some(event) = rx.recv().await { + if let StreamEvent::Done(response) = event { + return Ok(response); + } + } + Err(anyhow!("claude stream closed before message_stop")) + } + + async fn complete_streaming( + &self, + messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + cancel_token: CancellationToken, + ) -> Result> { + if PREFER_CLAUDE_CLI_TRANSPORT.load(Ordering::Relaxed) { + return self + .code_cli + .complete_streaming(messages, system, tools, cancel_token) + .await; + } + + match self + .raw_messages + .complete_streaming(messages, system, tools, cancel_token.clone()) + .await + { + Ok(rx) => Ok(rx), + Err(error) if error.should_use_cli_fallback() => { + PREFER_CLAUDE_CLI_TRANSPORT.store(true, Ordering::Relaxed); + self.code_cli + .complete_streaming(messages, system, tools, cancel_token) + .await + .with_context(|| format!("{error}; Claude Code CLI adapter also failed")) + } + Err(error) => Err(error.into()), + } + } +} diff --git a/src/claude/code_cli.rs b/src/claude/code_cli.rs new file mode 100644 index 0000000..995a67e --- /dev/null +++ b/src/claude/code_cli.rs @@ -0,0 +1,631 @@ +use super::host_tools::{host_tool_instructions, parse_host_tool_calls, HostToolParseResult}; +use super::model::canonical_model_name; +use super::protocol::{parse_claude_cli_stream_event, AnthropicEventMapper, StreamMeta}; +use a3s_code_core::llm::{ + ContentBlock, LlmResponse, LlmResponseMeta, Message, StreamEvent, TokenUsage, ToolDefinition, +}; +use anyhow::{Context, Result}; +use serde_json::json; +use std::fmt::Write as _; +use std::process::Stdio; +use std::time::Instant; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub(crate) struct ClaudeCodeCliAdapter { + model: String, +} + +impl ClaudeCodeCliAdapter { + pub(crate) fn new(model: &str) -> Self { + Self { + model: canonical_model_name(model), + } + } + + pub(crate) async fn complete_streaming( + &self, + messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + cancel_token: CancellationToken, + ) -> Result> { + let request_started_at = Instant::now(); + let prompt = claude_cli_prompt(messages); + let appended_system = claude_cli_system_prompt(system, tools); + let args = claude_cli_args(&self.model, appended_system.as_deref()); + let mut child = Command::new("claude") + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("start Claude Code CLI adapter (`claude`)")?; + + let mut stdin = child + .stdin + .take() + .context("open Claude Code CLI adapter stdin")?; + tokio::spawn(async move { + let _ = stdin.write_all(prompt.as_bytes()).await; + }); + + let stdout = child + .stdout + .take() + .context("capture Claude Code CLI adapter stdout")?; + let stderr = child + .stderr + .take() + .context("capture Claude Code CLI adapter stderr")?; + + let (tx, rx) = mpsc::channel(100); + let request_model = self.model.clone(); + let request_url = claude_cli_request_label(&args); + let host_tools = tools.to_vec(); + + tokio::spawn(async move { + let mut stderr_reader = BufReader::new(stderr); + let stderr_task = tokio::spawn(async move { + let mut stderr = String::new(); + let _ = stderr_reader.read_to_string(&mut stderr).await; + stderr + }); + + let meta = StreamMeta { + provider: "claude-code-cli", + request_model, + request_url, + started_at: request_started_at, + }; + let mut lines = BufReader::new(stdout).lines(); + let mut stream_mapper = host_tools + .is_empty() + .then(|| AnthropicEventMapper::new(meta.clone())); + let mut host_tool_mapper = + (!host_tools.is_empty()).then(|| ClaudeCliHostToolMapper::new(meta, host_tools)); + + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + let _ = child.kill().await; + return; + } + line = lines.next_line() => { + let line = match line { + Ok(Some(line)) => line, + Ok(None) | Err(_) => break, + }; + let Some(event) = parse_claude_cli_stream_event(&line) else { + continue; + }; + let done = if let Some(mapper) = stream_mapper.as_mut() { + mapper.handle(event, &tx).await + } else if let Some(mapper) = host_tool_mapper.as_mut() { + mapper.handle(event, &tx).await + } else { + false + }; + if done { + let _ = child.wait().await; + let _ = stderr_task.await; + return; + } + } + } + } + + let status = child.wait().await; + let stderr = stderr_task.await.unwrap_or_default(); + if let Ok(status) = status { + if !status.success() && !stderr.trim().is_empty() { + let _ = tx + .send(StreamEvent::TextDelta(format!( + "Claude Code CLI adapter failed: {}", + stderr.trim() + ))) + .await; + } + } + }); + + Ok(rx) + } +} + +fn claude_cli_args(model: &str, appended_system: Option<&str>) -> Vec { + let mut args = vec![ + "-p".into(), + "--safe-mode".into(), + "--model".into(), + canonical_model_name(model), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--tools".into(), + String::new(), + "--no-session-persistence".into(), + ]; + if let Some(appended_system) = + appended_system.filter(|appended_system| !appended_system.trim().is_empty()) + { + args.push("--append-system-prompt".into()); + args.push(appended_system.to_string()); + } + args +} + +fn claude_cli_request_label(args: &[String]) -> String { + let mut redacted = Vec::with_capacity(args.len()); + let mut skip_next = false; + for arg in args { + if skip_next { + redacted.push("[system prompt redacted]".to_string()); + skip_next = false; + continue; + } + redacted.push(arg.clone()); + if arg == "--append-system-prompt" || arg == "--system-prompt" { + skip_next = true; + } + } + format!("claude {}", redacted.join(" ")) +} + +fn claude_cli_system_prompt(system: Option<&str>, tools: &[ToolDefinition]) -> Option { + let mut prompt = String::new(); + if let Some(system) = system.filter(|system| !system.trim().is_empty()) { + prompt.push_str("# A3S System\n\n"); + prompt.push_str(system.trim()); + prompt.push_str("\n\n"); + } + + if !tools.is_empty() { + if let Some(instructions) = host_tool_instructions(tools) { + prompt.push_str(&instructions); + } + } + + (!prompt.trim().is_empty()).then_some(prompt) +} + +fn claude_cli_prompt(messages: &[Message]) -> String { + let mut prompt = String::new(); + prompt.push_str("# Conversation\n"); + for message in messages { + prompt.push('\n'); + prompt.push_str(match message.role.as_str() { + "assistant" => "Assistant", + "user" => "User", + "system" => "System", + role => role, + }); + prompt.push_str(":\n"); + for block in &message.content { + match block { + ContentBlock::Text { text } => { + prompt.push_str(text); + prompt.push('\n'); + } + ContentBlock::Image { source } => { + prompt.push_str(&format!("[image omitted: {}]\n", source.media_type)); + } + ContentBlock::ToolUse { id, name, input } => { + let block = json!({ + "id": id, + "name": name, + "input": input, + }); + prompt.push_str("\n"); + let _ = writeln!(prompt, "{block}"); + prompt.push_str("\n"); + } + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } => { + let status = if is_error.unwrap_or(false) { + "error" + } else { + "ok" + }; + let block = json!({ + "tool_use_id": tool_use_id, + "status": status, + "content": content.as_text(), + }); + prompt.push_str("\n"); + let _ = writeln!(prompt, "{block}"); + prompt.push_str("\n"); + } + } + } + } + prompt +} + +struct ClaudeCliHostToolMapper { + meta: StreamMeta, + tools: Vec, + text: String, + usage: TokenUsage, + stop_reason: Option, + response_id: Option, + response_model: Option, + response_object: Option, + first_token_ms: Option, +} + +impl ClaudeCliHostToolMapper { + fn new(meta: StreamMeta, tools: Vec) -> Self { + Self { + meta, + tools, + text: String::new(), + usage: TokenUsage::default(), + stop_reason: None, + response_id: None, + response_model: None, + response_object: Some("message".into()), + first_token_ms: None, + } + } + + async fn handle( + &mut self, + event: super::protocol::AnthropicStreamEvent, + tx: &mpsc::Sender, + ) -> bool { + match event { + super::protocol::AnthropicStreamEvent::MessageStart { message } => { + self.response_id = message.id; + self.response_model = message.model; + self.response_object = message.message_type; + self.usage.prompt_tokens = message.usage.input_tokens; + self.usage.cache_read_tokens = message.usage.cache_read_input_tokens; + self.usage.cache_write_tokens = message.usage.cache_creation_input_tokens; + } + super::protocol::AnthropicStreamEvent::ContentBlockDelta { + delta: super::protocol::AnthropicDelta::TextDelta { text }, + .. + } => { + self.mark_first_token(); + self.text.push_str(&text); + } + super::protocol::AnthropicStreamEvent::ContentBlockDelta { .. } => {} + super::protocol::AnthropicStreamEvent::MessageDelta { delta, usage } => { + self.stop_reason = Some(delta.stop_reason); + self.usage.completion_tokens = usage.output_tokens; + self.usage.total_tokens = self.usage.prompt_tokens + self.usage.completion_tokens; + } + super::protocol::AnthropicStreamEvent::MessageStop => { + self.finish(tx).await; + return true; + } + super::protocol::AnthropicStreamEvent::Error => return true, + _ => {} + } + false + } + + async fn finish(&mut self, tx: &mpsc::Sender) { + let mut content = Vec::new(); + let mut stop_reason = self.stop_reason.clone(); + + match parse_host_tool_calls(&self.text, &self.tools) { + HostToolParseResult::Calls(calls) => { + stop_reason = Some("tool_use".into()); + for call in calls { + let input_delta = call.input.to_string(); + let _ = tx + .send(StreamEvent::ToolUseStart { + id: call.id.clone(), + name: call.name.clone(), + }) + .await; + let _ = tx.send(StreamEvent::ToolUseInputDelta(input_delta)).await; + content.push(call.into_content_block()); + } + } + HostToolParseResult::Invalid(reason) => { + stop_reason = Some("host_tool_protocol_error".into()); + content.push(ContentBlock::Text { + text: format!( + "I need to retry the a3s host tool call because {reason}. I should output exactly one valid Claude Code block next." + ), + }); + } + HostToolParseResult::NoCall if !self.text.is_empty() => { + let text = std::mem::take(&mut self.text); + let _ = tx.send(StreamEvent::TextDelta(text.clone())).await; + content.push(ContentBlock::Text { text }); + } + HostToolParseResult::NoCall => {} + } + + let response = LlmResponse { + message: Message { + role: "assistant".into(), + content, + reasoning_content: None, + }, + usage: self.usage.clone(), + stop_reason, + meta: Some(LlmResponseMeta { + provider: Some(self.meta.provider.into()), + request_model: Some(self.meta.request_model.clone()), + request_url: Some(self.meta.request_url.clone()), + response_id: self.response_id.clone(), + response_model: self.response_model.clone(), + response_object: self.response_object.clone(), + first_token_ms: self.first_token_ms, + duration_ms: Some(self.meta.started_at.elapsed().as_millis() as u64), + }), + }; + let _ = tx.send(StreamEvent::Done(response)).await; + } + + fn mark_first_token(&mut self) { + if self.first_token_ms.is_none() { + self.first_token_ms = Some(self.meta.started_at.elapsed().as_millis() as u64); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn claude_cli_args_use_safe_text_only_streaming_mode() { + let args = claude_cli_args(" claude-opus-4-8[1m] ", Some("secret system prompt")); + + assert!(args.contains(&"--safe-mode".to_string())); + assert!(args.contains(&"--no-session-persistence".to_string())); + assert!(args.contains(&"--include-partial-messages".to_string())); + assert_eq!( + args.windows(2) + .find(|window| window[0] == "--model") + .map(|window| window[1].as_str()), + Some("claude-opus-4-8") + ); + assert_eq!( + args.windows(2) + .find(|window| window[0] == "--tools") + .map(|window| window[1].as_str()), + Some("") + ); + assert_eq!( + args.windows(2) + .find(|window| window[0] == "--append-system-prompt") + .map(|window| window[1].as_str()), + Some("secret system prompt") + ); + assert_eq!( + claude_cli_request_label(&args), + "claude -p --safe-mode --model claude-opus-4-8 --output-format stream-json --verbose --include-partial-messages --tools --no-session-persistence --append-system-prompt [system prompt redacted]" + ); + } + + #[test] + fn claude_cli_system_prompt_injects_host_tool_protocol() { + let prompt = claude_cli_system_prompt( + Some("Be concise."), + &[ToolDefinition { + name: "read_file".into(), + description: "Read a file".into(), + parameters: json!({"type":"object"}), + }], + ) + .unwrap(); + + assert!(prompt.contains("# A3S System")); + assert!(prompt.contains("Be concise.")); + assert!(prompt.contains("# A3S Host Tools")); + assert!(prompt.contains("")); + assert!(!prompt.contains("")); + } + + #[test] + fn claude_cli_prompt_flattens_history_as_structured_tool_blocks() { + let prompt = claude_cli_prompt(&[ + Message::user("hello"), + Message { + role: "assistant".into(), + content: vec![ContentBlock::ToolUse { + id: "toolu_1".into(), + name: "read_file".into(), + input: json!({"file_path":"README.md"}), + }], + reasoning_content: None, + }, + Message::tool_result("toolu_1", "contents", false), + ]); + + assert!(!prompt.contains("# A3S Host Tools")); + assert!(prompt.contains("User:\nhello")); + assert!(prompt.contains("")); + assert!(prompt.contains("\"id\":\"toolu_1\"")); + assert!(prompt.contains("")); + assert!(prompt.contains("\"status\":\"ok\"")); + } + + #[test] + fn claude_cli_prompt_keeps_system_out_of_user_prompt() { + let prompt = claude_cli_prompt(&[ + Message::user("hello"), + Message { + role: "assistant".into(), + content: vec![ContentBlock::ToolUse { + id: "toolu_1".into(), + name: "read_file".into(), + input: json!({"path":"README.md"}), + }], + reasoning_content: None, + }, + Message::tool_result("toolu_1", "contents", false), + ]); + + assert!(!prompt.contains("Be concise.")); + assert!(!prompt.contains("")); + assert!(prompt.contains("User:\nhello")); + } + + #[tokio::test] + async fn host_tool_mapper_converts_envelope_to_a3s_tool_use() { + let tools = vec![ToolDefinition { + name: "read".into(), + description: "Read a file".into(), + parameters: json!({ + "type":"object", + "properties":{"file_path":{"type":"string"}}, + "required":["file_path"] + }), + }]; + let mut mapper = ClaudeCliHostToolMapper::new( + StreamMeta { + provider: "claude-code-cli", + request_model: "claude-opus-4-8".into(), + request_url: "claude -p".into(), + started_at: Instant::now(), + }, + tools, + ); + let (tx, mut rx) = mpsc::channel(10); + let lines = [ + r#"{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_1","type":"message","model":"claude-opus-4-8","usage":{"input_tokens":3,"output_tokens":0}}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{\"calls\":[{\"name\":\"Read\",\"input\":{\"path\":\"README.md\"}}]}"}}}"#, + r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}}"#, + r#"{"type":"stream_event","event":{"type":"message_stop"}}"#, + ]; + + for line in lines { + let event = parse_claude_cli_stream_event(line).unwrap(); + if mapper.handle(event, &tx).await { + break; + } + } + + assert!(matches!( + rx.recv().await, + Some(StreamEvent::ToolUseStart { name, .. }) if name == "read" + )); + assert!(matches!( + rx.recv().await, + Some(StreamEvent::ToolUseInputDelta(delta)) if delta.contains("README.md") + )); + let Some(StreamEvent::Done(response)) = rx.recv().await else { + panic!("expected done"); + }; + let calls = response.tool_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].args, json!({"file_path":"README.md"})); + } + + #[tokio::test] + async fn host_tool_mapper_converts_claude_function_calls_to_a3s_tool_use() { + let tools = vec![ToolDefinition { + name: "read".into(), + description: "Read a file".into(), + parameters: json!({ + "type":"object", + "properties":{"file_path":{"type":"string"}}, + "required":["file_path"] + }), + }]; + let mut mapper = ClaudeCliHostToolMapper::new( + StreamMeta { + provider: "claude-code-cli", + request_model: "claude-opus-4-8".into(), + request_url: "claude -p".into(), + started_at: Instant::now(), + }, + tools, + ); + let (tx, mut rx) = mpsc::channel(10); + let lines = [ + r#"{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_1","type":"message","model":"claude-opus-4-8","usage":{"input_tokens":3,"output_tokens":0}}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"README.md"}}}"#, + r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}}"#, + r#"{"type":"stream_event","event":{"type":"message_stop"}}"#, + ]; + + for line in lines { + let event = parse_claude_cli_stream_event(line).unwrap(); + if mapper.handle(event, &tx).await { + break; + } + } + + assert!(matches!( + rx.recv().await, + Some(StreamEvent::ToolUseStart { name, .. }) if name == "read" + )); + assert!(matches!( + rx.recv().await, + Some(StreamEvent::ToolUseInputDelta(delta)) if delta.contains("README.md") + )); + let Some(StreamEvent::Done(response)) = rx.recv().await else { + panic!("expected done"); + }; + let calls = response.tool_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].args, json!({"file_path":"README.md"})); + } + + #[tokio::test] + async fn host_tool_mapper_hides_invalid_protocol_text_and_requests_retry() { + let tools = vec![ToolDefinition { + name: "bash".into(), + description: "Run a command".into(), + parameters: json!({ + "type":"object", + "properties":{"command":{"type":"string"}}, + "required":["command"] + }), + }]; + let mut mapper = ClaudeCliHostToolMapper::new( + StreamMeta { + provider: "claude-code-cli", + request_model: "claude-opus-4-8".into(), + request_url: "claude -p".into(), + started_at: Instant::now(), + }, + tools, + ); + let (tx, mut rx) = mpsc::channel(10); + let lines = [ + r#"{"type":"stream_event","event":{"type":"message_start","message":{"id":"msg_1","type":"message","model":"claude-opus-4-8","usage":{"input_tokens":3,"output_tokens":0}}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"{\"calls\":[{\"name\":\"bash\",\"input\":{}}]}"}}}"#, + r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}}"#, + r#"{"type":"stream_event","event":{"type":"message_stop"}}"#, + ]; + + for line in lines { + let event = parse_claude_cli_stream_event(line).unwrap(); + if mapper.handle(event, &tx).await { + break; + } + } + + let Some(StreamEvent::Done(response)) = rx.recv().await else { + panic!("expected done"); + }; + assert_eq!(response.tool_calls().len(), 0); + assert_eq!( + response.stop_reason.as_deref(), + Some("host_tool_protocol_error") + ); + assert!(response.text().contains("retry the a3s host tool call")); + assert!(!response.text().contains("")); + drop(tx); + assert!(rx.recv().await.is_none()); + } +} diff --git a/src/claude/credentials.rs b/src/claude/credentials.rs new file mode 100644 index 0000000..564ab78 --- /dev/null +++ b/src/claude/credentials.rs @@ -0,0 +1,248 @@ +use anyhow::{anyhow, Context, Result}; +use serde_json::Value; +use std::path::{Path, PathBuf}; +#[cfg(target_os = "macos")] +use std::process::Command; +#[cfg(target_os = "macos")] +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) struct ClaudeCredentials { + pub(crate) access_token: String, + // Read only in the macOS keychain path (`expires_soon`); dead on other OSes. + #[allow(dead_code)] + expires_at_ms: Option, +} + +impl ClaudeCredentials { + pub(crate) fn from_disk() -> Result { + if let Some(access_token) = + env_token("CLAUDE_CODE_OAUTH_TOKEN").or_else(|| env_token("ANTHROPIC_AUTH_TOKEN")) + { + return Ok(Self { + access_token, + expires_at_ms: None, + }); + } + + for path in claude_credentials_paths() { + if let Ok(credentials) = Self::from_file(&path) { + return Ok(credentials); + } + } + + #[cfg(target_os = "macos")] + if let Ok(credentials) = Self::from_macos_keychain() { + return Ok(credentials); + } + + Err(anyhow!( + "no Claude OAuth access token found; run `claude auth login` or `claude setup-token`" + )) + } + + fn from_file(path: &Path) -> Result { + let raw = std::fs::read_to_string(path)?; + Self::from_json_str(&raw).with_context(|| format!("read {}", path.display())) + } + + fn from_json_str(raw: &str) -> Result { + let value: Value = serde_json::from_str(raw).context("parse Claude credentials")?; + Self::from_value(&value) + } + + fn from_value(value: &Value) -> Result { + let access_token = credential_string( + value, + &[ + "/claudeAiOauth/accessToken", + "/claudeAiOauth/access_token", + "/oauth/accessToken", + "/oauth/access_token", + "/tokens/accessToken", + "/tokens/access_token", + "/accessToken", + "/access_token", + ], + ) + .ok_or_else(|| anyhow!("no Claude OAuth access token found; run `claude /login`"))?; + Ok(Self { + access_token, + expires_at_ms: credential_u64(value, &["/claudeAiOauth/expiresAt", "/oauth/expiresAt"]), + }) + } + + // Only called from the macOS keychain path; dead code elsewhere. + #[allow(dead_code)] + fn expires_soon(&self) -> bool { + let Some(expires_at_ms) = self.expires_at_ms else { + return false; + }; + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(); + expires_at_ms <= now_ms.saturating_add(60_000) + } + + #[cfg(target_os = "macos")] + fn from_macos_keychain() -> Result { + let credentials = Self::read_macos_keychain()?; + if !credentials.expires_soon() { + return Ok(credentials); + } + + let _ = Command::new("claude") + .args(["auth", "status", "--json"]) + .output(); + Self::read_macos_keychain().or(Ok(credentials)) + } + + #[cfg(target_os = "macos")] + fn read_macos_keychain() -> Result { + let output = Command::new("security") + .args([ + "find-generic-password", + "-w", + "-s", + "Claude Code-credentials", + ]) + .output() + .context("read Claude Code credentials from macOS Keychain")?; + if !output.status.success() { + return Err(anyhow!( + "Claude Code credentials not found in macOS Keychain" + )); + } + let raw = String::from_utf8(output.stdout).context("decode keychain credentials")?; + Self::from_json_str(raw.trim()) + } +} + +pub(crate) fn claude_credentials_paths() -> Vec { + let mut paths = Vec::new(); + std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".claude"))) + .map(|dir| dir.join(".credentials.json")) + .into_iter() + .for_each(|path| paths.push(path)); + if let Some(home) = std::env::var_os("HOME") { + paths.push(Path::new(&home).join(".claude.json")); + } + paths +} + +pub(crate) fn has_claude_login() -> bool { + if env_token("CLAUDE_CODE_OAUTH_TOKEN") + .or_else(|| env_token("ANTHROPIC_AUTH_TOKEN")) + .is_some() + { + return true; + } + + if claude_credentials_paths() + .iter() + .any(|path| ClaudeCredentials::from_file(path).is_ok()) + { + return true; + } + + #[cfg(target_os = "macos")] + { + static KEYCHAIN_LOGIN: OnceLock = OnceLock::new(); + if *KEYCHAIN_LOGIN.get_or_init(|| ClaudeCredentials::from_macos_keychain().is_ok()) { + return true; + } + } + + false +} + +fn env_token(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn credential_string(value: &Value, pointers: &[&str]) -> Option { + pointers + .iter() + .filter_map(|pointer| value.pointer(pointer)) + .filter_map(Value::as_str) + .map(str::trim) + .find(|value| !value.is_empty()) + .map(str::to_string) +} + +fn credential_u64(value: &Value, pointers: &[&str]) -> Option { + pointers.iter().find_map(|pointer| { + let value = value.pointer(pointer)?; + value + .as_u64() + .or_else(|| value.as_str().and_then(|s| s.trim().parse::().ok())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_claude_credentials_access_token() { + let root = std::env::temp_dir().join(format!( + "a3s-claude-credentials-test-{}", + std::process::id() + )); + let path = root.join(".credentials.json"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + &path, + r#"{"claudeAiOauth":{"accessToken":"test-access-token"}}"#, + ) + .unwrap(); + + let credentials = ClaudeCredentials::from_file(&path).unwrap(); + + assert_eq!(credentials.access_token, "test-access-token"); + assert_eq!(credentials.expires_at_ms, None); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn reads_claude_keychain_credential_shape() { + let credentials = ClaudeCredentials::from_json_str( + r#"{"claudeAiOauth":{"accessToken":"test-access-token","refreshToken":"refresh","expiresAt":4102444800000}}"#, + ) + .unwrap(); + + assert_eq!(credentials.access_token, "test-access-token"); + assert_eq!(credentials.expires_at_ms, Some(4_102_444_800_000)); + assert!(!credentials.expires_soon()); + } + + #[test] + fn reads_claude_token_from_env() { + let _guard = crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let old_claude = std::env::var_os("CLAUDE_CODE_OAUTH_TOKEN"); + let old_anthropic = std::env::var_os("ANTHROPIC_AUTH_TOKEN"); + std::env::set_var("CLAUDE_CODE_OAUTH_TOKEN", "env-token"); + std::env::remove_var("ANTHROPIC_AUTH_TOKEN"); + + let credentials = ClaudeCredentials::from_disk().unwrap(); + + restore_var("CLAUDE_CODE_OAUTH_TOKEN", old_claude); + restore_var("ANTHROPIC_AUTH_TOKEN", old_anthropic); + assert_eq!(credentials.access_token, "env-token"); + } + + fn restore_var(key: &str, value: Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } +} diff --git a/src/claude/host_tools.rs b/src/claude/host_tools.rs new file mode 100644 index 0000000..a266143 --- /dev/null +++ b/src/claude/host_tools.rs @@ -0,0 +1,632 @@ +use a3s_code_core::llm::{ContentBlock, ToolDefinition}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; + +const TOOL_CALLS_OPEN: &str = ""; +const TOOL_CALLS_CLOSE: &str = ""; +const PROTOCOL_VERSION: &str = "a3s.host_tools.v1"; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct HostToolCall { + pub id: String, + pub name: String, + pub input: Value, +} + +impl HostToolCall { + pub(crate) fn into_content_block(self) -> ContentBlock { + ContentBlock::ToolUse { + id: self.id, + name: self.name, + input: self.input, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum HostToolParseResult { + NoCall, + Calls(Vec), + Invalid(String), +} + +pub(crate) fn host_tool_instructions(tools: &[ToolDefinition]) -> Option { + if tools.is_empty() { + return None; + } + + let names = tools + .iter() + .map(|tool| format!("`{}`", tool.name)) + .collect::>() + .join(", "); + let tools_json = serde_json::to_string_pretty( + &tools + .iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "input_schema": tool.parameters, + }) + }) + .collect::>(), + ) + .unwrap_or_else(|_| "[]".to_string()); + + Some(format!( + "# A3S Host Tools\n\n\ + Protocol: {PROTOCOL_VERSION}\n\n\ + a3s-code host tools are available in this session. Claude Code's own \ + built-in tools are disabled for this transport, so when you need files, \ + commands, web access, skills, or subagents, request a3s host tools \ + instead of trying to execute Claude Code tools directly. Do not describe \ + the tool call in prose.\n\n\ + Preferred Claude Code-compatible form:\n\n\ + \n\ + \n\ + README.md\n\ + \n\ + \n\n\ + For complex JSON inputs, put the JSON value inside the parameter text:\n\n\ + \n\ + \n\ + [{{\"agent\":\"explore\",\"description\":\"Find API entrypoints\",\"prompt\":\"Inspect the API module.\"}}]\n\ + \n\ + \n\n\ + Rules:\n\ + - Use only these tool names: {names}.\n\ + - Parameter names and values must match the tool's JSON schema exactly; \ + for file tools use `file_path`, not `path`.\n\ + - Request multiple independent tools with multiple `` blocks \ + when useful; a3s decides whether they can run in parallel.\n\ + - If you need a final answer and no tool is needed, answer normally with \ + no envelope.\n\ + - After a3s returns `` history blocks, continue from \ + those observations.\n\n\ + Available tools:\n\ + ```json\n{tools_json}\n```\n\n" + )) +} + +pub(crate) fn parse_host_tool_calls(text: &str, tools: &[ToolDefinition]) -> HostToolParseResult { + if let Some(payload) = extract_tool_payload(text) { + let envelope = match parse_tool_envelope(payload) { + Ok(envelope) => envelope, + Err(error) => return HostToolParseResult::Invalid(error), + }; + return build_host_tool_calls(envelope.calls, tools, true); + } + + if text.contains("") || text.contains(", + tools: &[ToolDefinition], + strict_unknown_tools: bool, +) -> HostToolParseResult { + if items.is_empty() { + return HostToolParseResult::Invalid("tool envelope contains no calls".into()); + } + let valid_tools = tools.iter().map(|tool| tool.name.as_str()).collect(); + let tool_by_name = tools + .iter() + .map(|tool| (tool.name.as_str(), tool)) + .collect::>(); + let tool_names = tool_name_lookup(tools); + let mut calls = Vec::new(); + for (index, call) in items.into_iter().enumerate() { + let Some(raw_name) = call.name.filter(|name| !name.trim().is_empty()) else { + return HostToolParseResult::Invalid(format!( + "tool call {} is missing `name`", + index + 1 + )); + }; + let Some(name) = normalize_tool_name(&raw_name, &valid_tools, &tool_names) else { + if !strict_unknown_tools { + continue; + } + return HostToolParseResult::Invalid(format!( + "unknown a3s host tool `{}`", + raw_name.trim() + )); + }; + let Some(tool) = tool_by_name.get(name.as_str()) else { + return HostToolParseResult::Invalid(format!( + "unknown a3s host tool `{}`", + raw_name.trim() + )); + }; + let input = match normalize_tool_input(&name, call.input) { + Ok(input) => input, + Err(error) => return HostToolParseResult::Invalid(error), + }; + if let Err(error) = validate_required_input(tool, &input) { + return HostToolParseResult::Invalid(error); + } + calls.push(HostToolCall { + id: call + .id + .filter(|id| !id.trim().is_empty()) + .unwrap_or_else(|| format!("claude_cli_tool_{}", index + 1)), + name, + input, + }); + } + if calls.is_empty() { + HostToolParseResult::Invalid("tool output did not contain any usable a3s host calls".into()) + } else { + HostToolParseResult::Calls(calls) + } +} + +fn extract_tool_payload(text: &str) -> Option<&str> { + let start = text.find(TOOL_CALLS_OPEN)? + TOOL_CALLS_OPEN.len(); + let rest = &text[start..]; + let end = rest.find(TOOL_CALLS_CLOSE)?; + Some(&rest[..end]) +} + +fn parse_tool_envelope(payload: &str) -> Result { + let payload = strip_markdown_json_fence(payload.trim()); + serde_json::from_str::(payload) + .or_else(|first_error| { + extract_first_json_object(payload) + .ok_or_else(|| first_error.to_string()) + .and_then(|json| { + serde_json::from_str::(json).map_err(|error| { + format!("invalid a3s host tool JSON: {error}; first parse: {first_error}") + }) + }) + }) + .map_err(|error| format!("invalid a3s host tool JSON: {error}")) +} + +fn strip_markdown_json_fence(payload: &str) -> &str { + let trimmed = payload.trim(); + let Some(after_ticks) = trimmed.strip_prefix("```") else { + return trimmed; + }; + let after_header = after_ticks + .find('\n') + .map(|idx| &after_ticks[idx + 1..]) + .unwrap_or(after_ticks); + after_header + .trim() + .strip_suffix("```") + .unwrap_or(after_header) + .trim() +} + +fn extract_first_json_object(input: &str) -> Option<&str> { + let start = input.find('{')?; + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (offset, ch) in input[start..].char_indices() { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + match ch { + '"' => in_string = true, + '{' => depth += 1, + '}' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(&input[start..start + offset + ch.len_utf8()]); + } + } + _ => {} + } + } + None +} + +fn parse_claude_function_calls(text: &str, tools: &[ToolDefinition]) -> HostToolParseResult { + let mut items = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find("') else { + return HostToolParseResult::Invalid("unterminated Claude function invoke".into()); + }; + let header = &rest[..=header_end]; + let body_start = header_end + 1; + let Some(body_end) = rest[body_start..].find("") else { + return HostToolParseResult::Invalid("unterminated Claude function invoke".into()); + }; + let body = &rest[body_start..body_start + body_end]; + let name = xml_attr(header, "name"); + let input = Value::Object(parse_claude_parameters(body)); + items.push(ToolCallEnvelopeItem { + id: None, + name, + input: Some(input), + }); + rest = &rest[body_start + body_end + "".len()..]; + } + + build_host_tool_calls(dedupe_items(items), tools, false) +} + +fn parse_claude_parameters(body: &str) -> serde_json::Map { + let mut params = serde_json::Map::new(); + let mut rest = body; + while let Some(start) = rest.find("') else { + break; + }; + let header = &rest[..=header_end]; + let value_start = header_end + 1; + let Some(value_end) = rest[value_start..].find("") else { + break; + }; + if let Some(name) = xml_attr(header, "name") { + let raw = decode_xml_entities(rest[value_start..value_start + value_end].trim()); + params.insert(name, parse_parameter_value(&raw)); + } + rest = &rest[value_start + value_end + "".len()..]; + } + params +} + +fn xml_attr(tag: &str, attr: &str) -> Option { + let needle = format!("{attr}=\""); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let end = rest.find('"')?; + Some(decode_xml_entities(&rest[..end])) +} + +fn decode_xml_entities(value: &str) -> String { + value + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn parse_parameter_value(raw: &str) -> Value { + serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())) +} + +fn dedupe_items(items: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for item in items { + let key = format!( + "{}\n{}", + item.name.as_deref().unwrap_or_default(), + item.input + .as_ref() + .map(Value::to_string) + .unwrap_or_else(String::new) + ); + if seen.insert(key) { + out.push(item); + } + } + out +} + +fn tool_name_lookup(tools: &[ToolDefinition]) -> HashMap { + let mut names = HashMap::new(); + let available = tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + + for tool in tools { + names.insert(tool_name_key(&tool.name), tool.name.clone()); + } + + for (alias, canonical) in [ + ("Read", "read"), + ("ReadFile", "read"), + ("read_file", "read"), + ("Bash", "bash"), + ("Shell", "bash"), + ("Run", "bash"), + ("Grep", "grep"), + ("Search", "grep"), + ("Glob", "glob"), + ("LS", "ls"), + ("List", "ls"), + ("Write", "write"), + ("WriteFile", "write"), + ("write_file", "write"), + ("Edit", "edit"), + ("Update", "edit"), + ("Patch", "patch"), + ("Task", "task"), + ("ParallelTask", "parallel_task"), + ("parallelTask", "parallel_task"), + ("WebSearch", "web_search"), + ("WebFetch", "web_fetch"), + ("Skill", "Skill"), + ] { + if available.contains(canonical) { + names + .entry(tool_name_key(alias)) + .or_insert(canonical.into()); + } + } + + names +} + +fn normalize_tool_name( + raw: &str, + valid_tools: &HashSet<&str>, + tool_names: &HashMap, +) -> Option { + let trimmed = raw.trim(); + if valid_tools.contains(trimmed) { + return Some(trimmed.to_string()); + } + tool_names.get(&tool_name_key(trimmed)).cloned() +} + +fn tool_name_key(name: &str) -> String { + name.trim() + .chars() + .flat_map(char::to_lowercase) + .map(|ch| if ch == '-' || ch == ' ' { '_' } else { ch }) + .collect() +} + +fn normalize_tool_input(name: &str, input: Option) -> Result { + let mut input = input.unwrap_or_else(|| json!({})); + if let Value::String(raw) = &input { + input = serde_json::from_str(raw.trim()).map_err(|error| { + format!("tool `{name}` arguments were a string but not valid JSON: {error}") + })?; + } + + let Value::Object(map) = &mut input else { + return Err(format!("tool `{name}` input must be a JSON object")); + }; + + if requires_file_path(name) && !map.contains_key("file_path") { + for alias in ["path", "file", "filename", "filepath"] { + if let Some(value) = map.remove(alias) { + map.insert("file_path".into(), value); + break; + } + } + } + if name == "grep" && !map.contains_key("pattern") { + if let Some(value) = map.remove("query") { + map.insert("pattern".into(), value); + } + } + if name == "web_search" && !map.contains_key("query") { + for alias in ["pattern", "search"] { + if let Some(value) = map.remove(alias) { + map.insert("query".into(), value); + break; + } + } + } + + Ok(input) +} + +fn requires_file_path(name: &str) -> bool { + matches!(name, "read" | "write" | "edit" | "patch") +} + +fn validate_required_input(tool: &ToolDefinition, input: &Value) -> Result<(), String> { + let required = tool + .parameters + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if required.is_empty() { + return Ok(()); + } + + let Some(map) = input.as_object() else { + return Err(format!("tool `{}` input must be a JSON object", tool.name)); + }; + let missing = required + .iter() + .filter(|field| !map.contains_key(**field)) + .copied() + .collect::>(); + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "tool `{}` input is missing required field(s): {}", + tool.name, + missing.join(", ") + )) + } +} + +#[derive(Debug, Deserialize)] +struct ToolCallEnvelope { + #[serde(default)] + calls: Vec, +} + +#[derive(Debug, Deserialize)] +struct ToolCallEnvelopeItem { + #[serde(default)] + id: Option, + #[serde(default, alias = "tool")] + name: Option, + #[serde(default)] + #[serde(alias = "args", alias = "arguments")] + input: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tools() -> Vec { + vec![ + ToolDefinition { + name: "read".into(), + description: "Read a file".into(), + parameters: json!({ + "type":"object", + "properties":{"file_path":{"type":"string"}}, + "required":["file_path"] + }), + }, + ToolDefinition { + name: "bash".into(), + description: "Run a command".into(), + parameters: json!({ + "type":"object", + "properties":{"command":{"type":"string"}}, + "required":["command"] + }), + }, + ] + } + + #[test] + fn instructions_include_tool_schema_and_claude_code_protocol() { + let instructions = host_tool_instructions(&tools()).unwrap(); + + assert!(!instructions.contains("")); + assert!(instructions.contains("")); + assert!(instructions.contains("")); + assert!(instructions.contains("\"name\": \"read\"")); + assert!(instructions.contains("\"file_path\"")); + assert!(instructions.contains("input_schema")); + } + + #[test] + fn parses_valid_host_tool_calls() { + let result = parse_host_tool_calls( + r#"noise + +{"calls":[{"name":"read","input":{"file_path":"README.md"}},{"id":"custom","name":"bash","input":{"command":"pwd"}}]} +"#, + &tools(), + ); + let HostToolParseResult::Calls(calls) = result else { + panic!("expected calls, got {result:?}"); + }; + + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].id, "claude_cli_tool_1"); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].input, json!({"file_path":"README.md"})); + assert_eq!(calls[1].id, "custom"); + } + + #[test] + fn normalizes_common_claude_code_tool_names_and_args() { + let result = parse_host_tool_calls( + r#"{"calls":[{"tool":"Read","args":{"path":"README.md"}}]}"#, + &tools(), + ); + let HostToolParseResult::Calls(calls) = result else { + panic!("expected calls, got {result:?}"); + }; + + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].input, json!({"file_path":"README.md"})); + } + + #[test] + fn rejects_unknown_tools_plain_text_and_missing_required_fields() { + assert_eq!( + parse_host_tool_calls("plain answer", &tools()), + HostToolParseResult::NoCall + ); + assert!(matches!( + parse_host_tool_calls( + r#"{"calls":[{"name":"unknown","input":{}}]}"#, + &tools(), + ), + HostToolParseResult::Invalid(reason) if reason.contains("unknown") + )); + assert!(matches!( + parse_host_tool_calls( + r#"{"calls":[{"name":"bash","input":{}}]}"#, + &tools(), + ), + HostToolParseResult::Invalid(reason) if reason.contains("command") + )); + } + + #[test] + fn accepts_fenced_json_inside_envelope() { + let result = parse_host_tool_calls( + r#" +```json +{"calls":[{"name":"bash","input":{"command":"pwd"}}]} +``` +"#, + &tools(), + ); + + assert!(matches!(result, HostToolParseResult::Calls(calls) if calls[0].name == "bash")); + } + + #[test] + fn parses_claude_code_function_call_xml() { + let result = parse_host_tool_calls( + r#" + +README.md + + + + +pwd + +"#, + &tools(), + ); + let HostToolParseResult::Calls(calls) = result else { + panic!("expected calls, got {result:?}"); + }; + + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "read"); + assert_eq!(calls[0].input, json!({"file_path":"README.md"})); + assert_eq!(calls[1].name, "bash"); + assert_eq!(calls[1].input, json!({"command":"pwd"})); + } + + #[test] + fn dedupes_repeated_claude_code_function_calls() { + let result = parse_host_tool_calls( + r#"README.md +README.md"#, + &tools(), + ); + let HostToolParseResult::Calls(calls) = result else { + panic!("expected calls, got {result:?}"); + }; + + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].input, json!({"file_path":"README.md"})); + } +} diff --git a/src/claude/model.rs b/src/claude/model.rs new file mode 100644 index 0000000..b38f147 --- /dev/null +++ b/src/claude/model.rs @@ -0,0 +1,41 @@ +pub(crate) fn canonical_model_name(model: &str) -> String { + let trimmed = model.trim(); + if !trimmed.starts_with("claude") { + return trimmed.to_string(); + } + let Some(open_bracket) = trimmed.rfind('[') else { + return trimmed.to_string(); + }; + if trimmed.ends_with(']') { + let stripped = trimmed[..open_bracket].trim_end(); + if !stripped.is_empty() { + return stripped.to_string(); + } + } + trimmed.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_model_name_strips_claude_code_context_suffix() { + assert_eq!( + canonical_model_name(" claude-opus-4-8[1m] "), + "claude-opus-4-8" + ); + assert_eq!( + canonical_model_name("claude-opus-4-8 [1m]"), + "claude-opus-4-8" + ); + assert_eq!( + canonical_model_name("claude-sonnet-4-6"), + "claude-sonnet-4-6" + ); + assert_eq!( + canonical_model_name("openai/gpt-5[preview]"), + "openai/gpt-5[preview]" + ); + } +} diff --git a/src/claude/protocol.rs b/src/claude/protocol.rs new file mode 100644 index 0000000..b26d071 --- /dev/null +++ b/src/claude/protocol.rs @@ -0,0 +1,295 @@ +use a3s_code_core::llm::{ + ContentBlock, LlmResponse, LlmResponseMeta, Message, StreamEvent, TokenUsage, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::time::Instant; +use tokio::sync::mpsc; + +#[derive(Clone)] +pub(crate) struct StreamMeta { + pub provider: &'static str, + pub request_model: String, + pub request_url: String, + pub started_at: Instant, +} + +pub(crate) struct AnthropicEventMapper { + meta: StreamMeta, + content_blocks: Vec, + text_content: String, + current_tool_id: String, + current_tool_name: String, + current_tool_input: String, + usage: TokenUsage, + stop_reason: Option, + response_id: Option, + response_model: Option, + response_object: Option, + first_token_ms: Option, +} + +impl AnthropicEventMapper { + pub fn new(meta: StreamMeta) -> Self { + Self { + meta, + content_blocks: Vec::new(), + text_content: String::new(), + current_tool_id: String::new(), + current_tool_name: String::new(), + current_tool_input: String::new(), + usage: TokenUsage::default(), + stop_reason: None, + response_id: None, + response_model: None, + response_object: Some("message".to_string()), + first_token_ms: None, + } + } + + pub async fn handle( + &mut self, + event: AnthropicStreamEvent, + tx: &mpsc::Sender, + ) -> bool { + match event { + AnthropicStreamEvent::MessageStart { message } => { + self.response_id = message.id; + self.response_model = message.model; + self.response_object = message.message_type; + self.usage.prompt_tokens = message.usage.input_tokens; + self.usage.cache_read_tokens = message.usage.cache_read_input_tokens; + self.usage.cache_write_tokens = message.usage.cache_creation_input_tokens; + } + AnthropicStreamEvent::ContentBlockStart { content_block, .. } => match content_block { + AnthropicContentBlock::Text { text } => { + let _ = text; + } + AnthropicContentBlock::ToolUse { id, name, input } => { + if !self.text_content.is_empty() { + self.content_blocks.push(ContentBlock::Text { + text: std::mem::take(&mut self.text_content), + }); + } + self.current_tool_id = id.clone(); + self.current_tool_name = name.clone(); + self.current_tool_input = initial_tool_input_json(&input).unwrap_or_default(); + let _ = tx.send(StreamEvent::ToolUseStart { id, name }).await; + if !self.current_tool_input.is_empty() { + self.mark_first_token(); + let _ = tx + .send(StreamEvent::ToolUseInputDelta( + self.current_tool_input.clone(), + )) + .await; + } + } + }, + AnthropicStreamEvent::ContentBlockDelta { delta, .. } => match delta { + AnthropicDelta::TextDelta { text } => { + self.mark_first_token(); + self.text_content.push_str(&text); + let _ = tx.send(StreamEvent::TextDelta(text)).await; + } + AnthropicDelta::InputJsonDelta { partial_json } => { + self.mark_first_token(); + self.current_tool_input.push_str(&partial_json); + let _ = tx.send(StreamEvent::ToolUseInputDelta(partial_json)).await; + } + }, + AnthropicStreamEvent::ContentBlockStop if !self.current_tool_id.is_empty() => { + let input = parse_tool_input(&self.current_tool_input); + self.content_blocks.push(ContentBlock::ToolUse { + id: self.current_tool_id.clone(), + name: self.current_tool_name.clone(), + input, + }); + self.current_tool_id.clear(); + self.current_tool_name.clear(); + self.current_tool_input.clear(); + } + AnthropicStreamEvent::MessageDelta { + delta, + usage: msg_usage, + } => { + self.stop_reason = Some(delta.stop_reason); + self.usage.completion_tokens = msg_usage.output_tokens; + self.usage.total_tokens = self.usage.prompt_tokens + self.usage.completion_tokens; + } + AnthropicStreamEvent::MessageStop => { + if !self.text_content.is_empty() { + self.content_blocks.push(ContentBlock::Text { + text: std::mem::take(&mut self.text_content), + }); + } + let response = LlmResponse { + message: Message { + role: "assistant".to_string(), + content: std::mem::take(&mut self.content_blocks), + reasoning_content: None, + }, + usage: self.usage.clone(), + stop_reason: self.stop_reason.clone(), + meta: Some(LlmResponseMeta { + provider: Some(self.meta.provider.into()), + request_model: Some(self.meta.request_model.clone()), + request_url: Some(self.meta.request_url.clone()), + response_id: self.response_id.clone(), + response_model: self.response_model.clone(), + response_object: self.response_object.clone(), + first_token_ms: self.first_token_ms, + duration_ms: Some(self.meta.started_at.elapsed().as_millis() as u64), + }), + }; + let _ = tx.send(StreamEvent::Done(response)).await; + return true; + } + AnthropicStreamEvent::ContentBlockStop | AnthropicStreamEvent::Ping => {} + AnthropicStreamEvent::Error => return true, + } + false + } + + fn mark_first_token(&mut self) { + if self.first_token_ms.is_none() { + self.first_token_ms = Some(self.meta.started_at.elapsed().as_millis() as u64); + } + } +} + +pub(crate) fn parse_sse_data(data: &str) -> Option { + if data == "[DONE]" { + return None; + } + serde_json::from_str(data).ok() +} + +pub(crate) fn parse_claude_cli_stream_event(line: &str) -> Option { + let value = serde_json::from_str::(line).ok()?; + if value.get("type").and_then(Value::as_str) != Some("stream_event") { + return None; + } + serde_json::from_value(value.get("event")?.clone()).ok() +} + +fn initial_tool_input_json(input: &Value) -> Option { + match input { + Value::Object(map) if map.is_empty() => None, + Value::Null => None, + value => serde_json::to_string(value).ok(), + } +} + +fn parse_tool_input(input: &str) -> Value { + if input.trim().is_empty() { + return json!({}); + } + serde_json::from_str(input).unwrap_or_else(|error| { + json!({ + "__parse_error": format!( + "Malformed tool arguments: {error}. Raw input: {input}" + ) + }) + }) +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum AnthropicContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: Value, + }, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AnthropicUsage { + pub(crate) input_tokens: usize, + #[serde(rename = "output_tokens")] + _output_tokens: usize, + #[serde(default)] + pub(crate) cache_read_input_tokens: Option, + #[serde(default)] + pub(crate) cache_creation_input_tokens: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum AnthropicStreamEvent { + #[serde(rename = "message_start")] + MessageStart { message: AnthropicMessageStart }, + #[serde(rename = "content_block_start")] + ContentBlockStart { + content_block: AnthropicContentBlock, + }, + #[serde(rename = "content_block_delta")] + ContentBlockDelta { delta: AnthropicDelta }, + #[serde(rename = "content_block_stop")] + ContentBlockStop, + #[serde(rename = "message_delta")] + MessageDelta { + delta: AnthropicMessageDeltaData, + usage: AnthropicOutputUsage, + }, + #[serde(rename = "message_stop")] + MessageStop, + #[serde(rename = "ping")] + Ping, + #[serde(rename = "error")] + Error, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AnthropicMessageStart { + #[serde(default)] + pub(crate) id: Option, + #[serde(default)] + pub(crate) model: Option, + #[serde(rename = "type", default)] + pub(crate) message_type: Option, + pub(crate) usage: AnthropicUsage, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum AnthropicDelta { + #[serde(rename = "text_delta")] + TextDelta { text: String }, + #[serde(rename = "input_json_delta")] + InputJsonDelta { partial_json: String }, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AnthropicMessageDeltaData { + pub(crate) stop_reason: String, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct AnthropicOutputUsage { + pub(crate) output_tokens: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_claude_cli_stream_event_lines() { + let event = parse_claude_cli_stream_event( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#, + ) + .unwrap(); + + assert!(matches!( + event, + AnthropicStreamEvent::ContentBlockDelta { + delta: AnthropicDelta::TextDelta { text } + } if text == "hi" + )); + assert!(parse_claude_cli_stream_event(r#"{"type":"system"}"#).is_none()); + } +} diff --git a/src/claude/raw_messages.rs b/src/claude/raw_messages.rs new file mode 100644 index 0000000..6548511 --- /dev/null +++ b/src/claude/raw_messages.rs @@ -0,0 +1,435 @@ +use super::model::canonical_model_name; +use super::protocol::{parse_sse_data, AnthropicEventMapper, StreamMeta}; +use a3s_code_core::llm::{HttpClient, Message, StreamEvent, ToolDefinition}; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::error::Error; +use std::fmt; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +const ANTHROPIC_BASE: &str = "https://api.anthropic.com"; +const ANTHROPIC_VERSION: &str = "2023-06-01"; +const ANTHROPIC_BETA: &str = "oauth-2025-04-20"; +const DEFAULT_MAX_TOKENS: usize = 8192; + +pub(crate) struct RawMessagesClient { + access_token: String, + model: String, + base_url: String, + max_tokens: usize, + http: Arc, +} + +impl RawMessagesClient { + pub(crate) fn new(access_token: String, model: &str, http: Arc) -> Self { + Self { + access_token, + model: canonical_model_name(model), + base_url: ANTHROPIC_BASE.to_string(), + max_tokens: DEFAULT_MAX_TOKENS, + http, + } + } + + pub(crate) async fn complete_streaming( + &self, + messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + cancel_token: CancellationToken, + ) -> std::result::Result, RawMessagesError> { + let request_started_at = Instant::now(); + let request_body = self.build_request(messages, system, tools, true); + let url = format!("{}/v1/messages", self.base_url); + let header_values = self.headers(); + let headers = header_values + .iter() + .map(|(key, value)| (*key, value.as_str())) + .collect::>(); + + let response = self + .http + .post_streaming(&url, headers, &request_body, cancel_token) + .await + .map_err(RawMessagesError::terminal)?; + if !(200..300).contains(&response.status) { + let message = + format_api_error(&url, &self.model, response.status, &response.error_body); + return Err(RawMessagesError { + message, + use_cli_fallback: should_fallback_to_claude_cli( + response.status, + &response.error_body, + ), + }); + } + + let (tx, rx) = mpsc::channel(100); + let mut stream = response.byte_stream; + let mut mapper = AnthropicEventMapper::new(StreamMeta { + provider: "claude-code", + request_model: self.model.clone(), + request_url: url, + started_at: request_started_at, + }); + + tokio::spawn(async move { + let mut buffer = String::new(); + while let Some(chunk_result) = stream.next().await { + let Ok(chunk) = chunk_result else { + break; + }; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(event_end) = buffer.find("\n\n") { + let event_data: String = buffer.drain(..event_end).collect(); + buffer.drain(..2); + + for line in event_data.lines() { + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + let Some(event) = parse_sse_data(data) else { + continue; + }; + if mapper.handle(event, &tx).await { + return; + } + } + } + } + }); + + Ok(rx) + } + + fn build_request( + &self, + messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + stream: bool, + ) -> Value { + let model = canonical_model_name(&self.model); + let mut request = json!({ + "model": model, + "max_tokens": self.max_tokens, + "messages": messages, + "stream": stream, + }); + + if let Some(system) = system { + request["system"] = json!([ + { + "type": "text", + "text": system, + "cache_control": { "type": "ephemeral" } + } + ]); + } + + if !tools.is_empty() { + let mut tool_defs = tools + .iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "input_schema": tool.parameters, + }) + }) + .collect::>(); + if let Some(last) = tool_defs.last_mut() { + last["cache_control"] = json!({ "type": "ephemeral" }); + } + request["tools"] = json!(tool_defs); + } + + request + } + + fn headers(&self) -> Vec<(&'static str, String)> { + vec![ + ("Authorization", format!("Bearer {}", self.access_token)), + ("anthropic-version", ANTHROPIC_VERSION.to_string()), + ("anthropic-beta", ANTHROPIC_BETA.to_string()), + ] + } +} + +#[derive(Debug)] +pub(crate) struct RawMessagesError { + message: String, + use_cli_fallback: bool, +} + +impl RawMessagesError { + pub(crate) fn should_use_cli_fallback(&self) -> bool { + self.use_cli_fallback + } + + fn terminal(error: anyhow::Error) -> Self { + Self { + message: error.to_string(), + use_cli_fallback: false, + } + } +} + +impl fmt::Display for RawMessagesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl Error for RawMessagesError {} + +#[derive(Debug, Deserialize)] +struct ClaudeApiErrorEnvelope { + #[serde(default)] + error: Option, + #[serde(default)] + request_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeApiError { + #[serde(rename = "type", default)] + error_type: Option, + #[serde(default)] + message: Option, +} + +fn format_api_error(url: &str, model: &str, status: u16, body: &str) -> String { + let parsed = serde_json::from_str::(body).ok(); + let error_type = parsed + .as_ref() + .and_then(|error| error.error.as_ref()) + .and_then(|error| error.error_type.as_deref()); + let message = parsed + .as_ref() + .and_then(|error| error.error.as_ref()) + .and_then(|error| error.message.as_deref()) + .filter(|message| !message.trim().is_empty()) + .unwrap_or(body); + let request_id = parsed + .as_ref() + .and_then(|error| error.request_id.as_deref()) + .map(|id| format!(", request_id={id}")) + .unwrap_or_default(); + + if status == 429 || error_type == Some("rate_limit_error") { + return format!( + "Claude Code OAuth bridge rate-limited for {model} at {url} ({status}{request_id}). \ + The installed `claude` CLI may still work because it uses Claude Code's full first-party client path; \ + this a3s bridge currently uses raw OAuth Messages API auth. Server message: {message}" + ); + } + + if status == 401 || error_type == Some("authentication_error") { + return format!( + "Claude Code OAuth bridge authentication failed for {model} at {url} ({status}{request_id}). \ + The installed `claude` CLI may still work because it can use Claude Code's refreshed local login path; \ + this a3s bridge currently uses raw OAuth Messages API auth. Server message: {message}" + ); + } + + format!("Claude account API error at {url} ({status}{request_id}): {message}") +} + +fn should_fallback_to_claude_cli(status: u16, body: &str) -> bool { + if matches!(status, 401 | 429) { + return true; + } + serde_json::from_str::(body) + .ok() + .and_then(|error| error.error) + .and_then(|error| error.error_type) + .as_deref() + .is_some_and(|error_type| matches!(error_type, "authentication_error" | "rate_limit_error")) +} + +#[cfg(test)] +mod tests { + use super::*; + use a3s_code_core::llm::{default_http_client, HttpResponse, StreamingHttpResponse}; + use anyhow::Result; + use async_trait::async_trait; + use std::sync::Mutex; + + #[test] + fn builds_bearer_headers_for_claude_account() { + let client = + RawMessagesClient::new("token-123".into(), "claude-sonnet-4", default_http_client()); + + let headers = client.headers(); + + assert!(headers.contains(&("Authorization", "Bearer token-123".to_string()))); + assert!(headers.contains(&("anthropic-version", ANTHROPIC_VERSION.to_string()))); + assert!(headers.contains(&("anthropic-beta", ANTHROPIC_BETA.to_string()))); + } + + #[test] + fn formats_claude_rate_limit_as_bridge_error() { + let message = format_api_error( + "https://api.anthropic.com/v1/messages", + "claude-opus-4-8", + 429, + r#"{"type":"error","error":{"type":"rate_limit_error","message":"Error"},"request_id":"req_123"}"#, + ); + + assert!(message.contains("Claude Code OAuth bridge rate-limited")); + assert!(message.contains("claude-opus-4-8")); + assert!(message.contains("request_id=req_123")); + assert!(message.contains("raw OAuth Messages API auth")); + } + + #[test] + fn formats_claude_authentication_as_bridge_error() { + let message = format_api_error( + "https://api.anthropic.com/v1/messages", + "claude-opus-4-8", + 401, + r#"{"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"},"request_id":"req_123"}"#, + ); + + assert!(message.contains("Claude Code OAuth bridge authentication failed")); + assert!(message.contains("claude-opus-4-8")); + assert!(message.contains("request_id=req_123")); + assert!(message.contains("refreshed local login path")); + } + + #[test] + fn falls_back_to_claude_cli_for_bridge_auth_or_rate_limit_errors() { + assert!(should_fallback_to_claude_cli(401, "{}")); + assert!(should_fallback_to_claude_cli(429, "{}")); + assert!(should_fallback_to_claude_cli( + 400, + r#"{"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}"# + )); + assert!(should_fallback_to_claude_cli( + 400, + r#"{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}"# + )); + assert!(!should_fallback_to_claude_cli( + 404, + r#"{"type":"error","error":{"type":"not_found_error","message":"missing"}}"# + )); + } + + #[test] + fn builds_messages_request_with_prompt_cache() { + let client = + RawMessagesClient::new("token".into(), "claude-sonnet-4[1m]", default_http_client()); + let tools = vec![ToolDefinition { + name: "read_file".into(), + description: "Read a file".into(), + parameters: json!({"type":"object"}), + }]; + + let body = client.build_request(&[Message::user("hello")], Some("system"), &tools, true); + + assert_eq!(body["model"], "claude-sonnet-4"); + assert_eq!(body["messages"][0]["role"], "user"); + assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral"); + assert_eq!(body["tools"][0]["name"], "read_file"); + assert_eq!(body["tools"][0]["cache_control"]["type"], "ephemeral"); + assert_eq!(body["stream"], true); + } + + #[tokio::test] + async fn streaming_maps_anthropic_events_to_a3s_events() { + let http = Arc::new(MockHttp::new( + r#"data: {"type":"message_start","message":{"id":"msg_1","type":"message","model":"claude-sonnet-4","usage":{"input_tokens":3,"output_tokens":0,"cache_read_input_tokens":1}}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}} + +data: {"type":"message_stop"} + +"#, + )); + let client = RawMessagesClient::new("token".into(), "claude-sonnet-4", http.clone()); + + let mut rx = client + .complete_streaming( + &[Message::user("hello")], + None, + &[], + CancellationToken::new(), + ) + .await + .unwrap(); + + assert!(matches!(rx.recv().await, Some(StreamEvent::TextDelta(text)) if text == "hi")); + let Some(StreamEvent::Done(response)) = rx.recv().await else { + panic!("expected done event"); + }; + assert_eq!(response.text(), "hi"); + assert_eq!(response.usage.prompt_tokens, 3); + assert_eq!(response.usage.completion_tokens, 2); + assert_eq!(response.usage.total_tokens, 5); + assert_eq!(response.usage.cache_read_tokens, Some(1)); + assert_eq!(response.usage.cache_write_tokens, None); + assert_eq!( + response.meta.unwrap().provider.as_deref(), + Some("claude-code") + ); + + let headers = http.headers.lock().unwrap().clone(); + assert!(headers.contains(&("Authorization".into(), "Bearer token".into()))); + assert!(headers.contains(&("anthropic-beta".into(), ANTHROPIC_BETA.into()))); + } + + struct MockHttp { + stream: String, + headers: Mutex>, + } + + impl MockHttp { + fn new(stream: &str) -> Self { + Self { + stream: stream.to_string(), + headers: Mutex::new(Vec::new()), + } + } + } + + #[async_trait] + impl HttpClient for MockHttp { + async fn post( + &self, + _url: &str, + _headers: Vec<(&str, &str)>, + _body: &Value, + _cancel_token: CancellationToken, + ) -> Result { + unreachable!("RawMessagesClient uses streaming") + } + + async fn post_streaming( + &self, + _url: &str, + headers: Vec<(&str, &str)>, + _body: &Value, + _cancel_token: CancellationToken, + ) -> Result { + *self.headers.lock().unwrap() = headers + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + let chunks = vec![Ok(self.stream.clone().into())]; + Ok(StreamingHttpResponse { + status: 200, + retry_after: None, + byte_stream: Box::pin(futures::stream::iter(chunks)), + error_body: String::new(), + }) + } + } +} diff --git a/src/main.rs b/src/main.rs index 9546156..74ecff7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,17 +3,26 @@ //! `a3s code` launches the interactive terminal UI (the coding agent); the //! rest are basic commands. +mod a3s_os; +mod box_cmd; +mod claude; mod codex; +mod tools; mod top; mod tui; mod update; +#[cfg(test)] +static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn usage() { println!("a3s {} — A3S coding agent CLI\n", env!("CARGO_PKG_VERSION")); println!("usage:"); println!(" a3s code launch the interactive coding agent (TUI)"); println!(" a3s code resume resume a saved session by id"); - println!(" a3s top live monitor for agents, containers, and processes"); + println!(" a3s box run a3s-box, installing it automatically if needed"); + println!(" a3s list list installed a3s-* tools on PATH"); + println!(" a3s top live monitor for boxes, agents, and diagnostics"); println!(" a3s update check for and install a newer version"); println!(" a3s --version show version"); println!(" a3s --help show this help"); @@ -54,6 +63,11 @@ async fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); match args.next().as_deref() { Some("update") => self_update().await, + Some("box") => box_cmd::run(args.collect()).await, + Some("list") => { + tools::print_tool_list(); + Ok(()) + } Some("top") => top::run(args.collect()).await, // Pass any trailing args (e.g. `resume `) through to the TUI. Some("code") => { @@ -81,9 +95,14 @@ async fn main() -> anyhow::Result<()> { #[cfg(test)] mod tests { + fn cargo_command() -> std::ffi::OsString { + std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()) + } + #[tokio::test] async fn test_help_command() { - let output = std::process::Command::new("cargo") + let _guard = cargo_run_guard(); + let output = std::process::Command::new(cargo_command()) .args(["run", "--", "--help"]) .output() .expect("Failed to execute process"); @@ -95,7 +114,8 @@ mod tests { #[tokio::test] async fn test_version_command() { - let output = std::process::Command::new("cargo") + let _guard = cargo_run_guard(); + let output = std::process::Command::new(cargo_command()) .args(["run", "--", "--version"]) .output() .expect("Failed to execute process"); @@ -104,4 +124,10 @@ mod tests { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains(env!("CARGO_PKG_VERSION"))); } + + fn cargo_run_guard() -> std::sync::MutexGuard<'static, ()> { + crate::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()) + } } diff --git a/src/tools.rs b/src/tools.rs new file mode 100644 index 0000000..8e579b7 --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,132 @@ +//! Local A3S tool discovery for `a3s list`. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolEntry { + command: String, + binary: String, + path: PathBuf, +} + +pub(crate) fn print_tool_list() { + let tools = discover_a3s_tools(std::env::var_os("PATH")); + + println!("a3s built-ins"); + println!(" code launch the interactive coding agent"); + println!(" top live monitor for agents, boxes, and processes"); + println!(" box run a3s-box, installing it automatically if needed"); + println!(" update update the a3s CLI"); + + println!("\ninstalled a3s-* tools on PATH"); + if tools.is_empty() { + println!(" none found"); + return; + } + for tool in tools { + println!( + " {:<10} {} ({})", + tool.command, + tool.binary, + tool.path.display() + ); + } +} + +fn discover_a3s_tools(path_env: Option) -> Vec { + let Some(path_env) = path_env else { + return Vec::new(); + }; + + let mut by_command = BTreeMap::new(); + for dir in std::env::split_paths(&path_env) { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !is_executable(&path) { + continue; + } + let Some(binary) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Some(command) = binary.strip_prefix("a3s-") else { + continue; + }; + if command.is_empty() { + continue; + } + by_command.entry(command.to_string()).or_insert(ToolEntry { + command: command.to_string(), + binary: binary.to_string(), + path, + }); + } + } + + by_command.into_values().collect() +} + +fn is_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|meta| meta.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovers_a3s_tools_on_path_once_sorted_by_command() { + let root = std::env::temp_dir().join(format!("a3s-tools-test-{}", std::process::id())); + let first = root.join("first"); + let second = root.join("second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + make_executable(&first.join("a3s-box")); + make_executable(&first.join("a3s-search")); + make_executable(&second.join("a3s-box")); + make_executable(&second.join("not-a3s-tool")); + + let path = std::env::join_paths([first.clone(), second]).unwrap(); + let tools = discover_a3s_tools(Some(path)); + + let names = tools + .iter() + .map(|tool| (tool.command.as_str(), tool.binary.as_str())) + .collect::>(); + assert_eq!(names, vec![("box", "a3s-box"), ("search", "a3s-search")]); + assert_eq!(tools[0].path, first.join("a3s-box")); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn discover_handles_missing_path() { + assert!(discover_a3s_tools(None).is_empty()); + } + + fn make_executable(path: &Path) { + std::fs::write(path, b"#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } +} diff --git a/src/top/collect.rs b/src/top/collect.rs new file mode 100644 index 0000000..342e899 --- /dev/null +++ b/src/top/collect.rs @@ -0,0 +1,277 @@ +//! Process model + collector shared by `a3s top` and the `/top` panel in +//! `a3s code`. Kept independent from the TUI layer so the same `ProcessRow` +//! snapshot feeds both renderers (and `--json`). + +use std::collections::{HashMap, HashSet}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use a3s_tui::style::Color; +use futures::stream::{self, StreamExt}; +use tokio::process::Command; + +use super::{ACCENT, GREEN, ORANGE, RED, YELLOW}; + +/// A coding agent recognised from a process command line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentKind { + A3sCode, + ClaudeCode, + Codex, + Cursor, + Gemini, +} + +impl AgentKind { + pub(crate) const ALL: [AgentKind; 5] = [ + AgentKind::A3sCode, + AgentKind::ClaudeCode, + AgentKind::Codex, + AgentKind::Cursor, + AgentKind::Gemini, + ]; + + pub(crate) fn label(self) -> &'static str { + match self { + AgentKind::A3sCode => "a3s-code", + AgentKind::ClaudeCode => "claude", + AgentKind::Codex => "codex", + AgentKind::Cursor => "cursor", + AgentKind::Gemini => "gemini", + } + } + + pub(crate) fn color(self) -> Color { + match self { + AgentKind::A3sCode => ACCENT, + AgentKind::ClaudeCode => ORANGE, + AgentKind::Codex => Color::Rgb(16, 163, 127), + AgentKind::Cursor => Color::Rgb(180, 182, 200), + AgentKind::Gemini => Color::Rgb(124, 137, 245), + } + } +} + +/// Coarse risk classification for a process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Risk { + Low, + Medium, + High, +} + +impl Risk { + pub(crate) fn label(self) -> &'static str { + match self { + Risk::Low => "low", + Risk::Medium => "med", + Risk::High => "high", + } + } + + pub(crate) fn color(self) -> Color { + match self { + Risk::Low => GREEN, + Risk::Medium => YELLOW, + Risk::High => RED, + } + } +} + +/// One host process row. +#[derive(Debug, Clone)] +pub(crate) struct ProcessRow { + pub(crate) pid: u32, + pub(crate) ppid: u32, + pub(crate) cpu_pct: f32, + pub(crate) mem_pct: f32, + pub(crate) elapsed: String, + pub(crate) cwd: Option, + pub(crate) command: String, + pub(crate) agent: Option, + pub(crate) risk: Risk, +} + +/// Snapshot the host process table via `ps`, sorted agents-first then CPU desc. +pub(crate) async fn collect_processes() -> anyhow::Result> { + let output = Command::new("ps") + .args(["-axo", "pid=,ppid=,pcpu=,pmem=,etime=,args="]) + .output() + .await?; + if !output.status.success() { + return Err(anyhow::anyhow!("ps exited with status {}", output.status)); + } + let text = String::from_utf8_lossy(&output.stdout); + let mut rows = text + .lines() + .filter_map(parse_process_line) + .collect::>(); + enrich_agent_process_cwds(&mut rows).await; + rows.sort_by(|a, b| { + b.agent.is_some().cmp(&a.agent.is_some()).then( + b.cpu_pct + .partial_cmp(&a.cpu_pct) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }); + Ok(rows) +} + +pub(crate) fn parse_process_line(line: &str) -> Option { + let mut it = line.split_whitespace(); + let pid = it.next()?.parse().ok()?; + let ppid = it.next()?.parse().ok()?; + let cpu_pct = it.next()?.parse().ok()?; + let mem_pct = it.next()?.parse().ok()?; + let elapsed = it.next()?.to_string(); + // Command is the verbatim remainder after the 5 fixed columns — slice it + // from the original line instead of collect()+join() to avoid a Vec alloc + // per process per tick (and to preserve the command's internal spacing). + let command = remainder_after_fields(line, 5)?.trim_end(); + if command.is_empty() { + return None; + } + let command = command.to_string(); + let agent = detect_agent(&command); + Some(ProcessRow { + pid, + ppid, + cpu_pct, + mem_pct, + elapsed, + cwd: None, + risk: process_risk(&command, agent), + command, + agent, + }) +} + +/// Return the slice of `line` after skipping `n` whitespace-separated fields. +fn remainder_after_fields(line: &str, n: usize) -> Option<&str> { + let mut rest = line.trim_start(); + for _ in 0..n { + let end = rest.find(char::is_whitespace)?; + rest = rest[end..].trim_start(); + } + Some(rest) +} + +/// Process CWDs change rarely, so cache them by pid across refreshes; only +/// brand-new agent pids pay the `lsof`/proc lookup. Pruned to live pids each +/// pass, so the map stays bounded by the agent processes on the host. +// ponytail: process-global cache shared by both `top` callers; fine because a +// pid's cwd is stable and the map is pruned to live pids every call. +static CWD_CACHE: OnceLock>> = OnceLock::new(); + +async fn enrich_agent_process_cwds(rows: &mut [ProcessRow]) { + let cache = CWD_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let live_pids: HashSet = rows.iter().map(|row| row.pid).collect(); + + // Serve cached cwds and collect the misses (capped at 16 lookups/pass). + let mut misses = Vec::new(); + { + let map = cache.lock().unwrap(); + for row in rows.iter_mut() { + if row.agent.is_some() { + match map.get(&row.pid) { + Some(cwd) => row.cwd = Some(cwd.clone()), + None => misses.push(row.pid), + } + } + } + } + misses.truncate(16); + + if !misses.is_empty() { + let resolved: Vec<(u32, Option)> = stream::iter(misses) + .map(|pid| async move { (pid, process_cwd(pid).await) }) + .buffer_unordered(8) + .collect() + .await; + let mut map = cache.lock().unwrap(); + for (pid, cwd) in resolved { + if let Some(cwd) = cwd.filter(|cwd| !cwd.is_empty()) { + map.insert(pid, cwd); + } + } + for row in rows.iter_mut() { + if row.agent.is_some() && row.cwd.is_none() { + row.cwd = map.get(&row.pid).cloned(); + } + } + } + + cache + .lock() + .unwrap() + .retain(|pid, _| live_pids.contains(pid)); +} + +async fn process_cwd(pid: u32) -> Option { + // `/proc//cwd` only exists on Linux; macOS/BSD fall straight to lsof. + #[cfg(target_os = "linux")] + { + if let Ok(path) = tokio::fs::read_link(format!("/proc/{pid}/cwd")).await { + return Some(path.display().to_string()); + } + } + process_cwd_from_lsof(pid).await +} + +async fn process_cwd_from_lsof(pid: u32) -> Option { + let mut command = Command::new("lsof"); + let pid = pid.to_string(); + command.args(["-a", "-p", &pid, "-d", "cwd", "-Fn"]); + let output = tokio::time::timeout(Duration::from_millis(200), command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + parse_lsof_cwd(&String::from_utf8_lossy(&output.stdout)) +} + +pub(crate) fn parse_lsof_cwd(text: &str) -> Option { + text.lines() + .find_map(|line| line.strip_prefix('n')) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToString::to_string) +} + +pub(crate) fn detect_agent(command: &str) -> Option { + let l = command.to_lowercase(); + if l.contains("a3s-code") || l.contains("a3s code") || l.ends_with("/a3s") { + Some(AgentKind::A3sCode) + } else if l.contains("claude") { + Some(AgentKind::ClaudeCode) + } else if l.contains("codex") { + Some(AgentKind::Codex) + } else if l.contains("cursor-agent") || l.contains("cursor") { + Some(AgentKind::Cursor) + } else if l.contains("gemini") { + Some(AgentKind::Gemini) + } else { + None + } +} + +pub(crate) fn process_risk(command: &str, agent: Option) -> Risk { + let lower = command.to_lowercase(); + if lower.contains("sudo ") + || lower.contains(" rm -rf ") + || lower.contains("ptrace") + || lower.contains("nmap ") + { + Risk::High + } else if agent.is_some() + || lower.contains("docker ") + || lower.contains("curl ") + || lower.contains("bash -c") + { + Risk::Medium + } else { + Risk::Low + } +} diff --git a/src/top/mod.rs b/src/top/mod.rs index 17d7753..303cf02 100644 --- a/src/top/mod.rs +++ b/src/top/mod.rs @@ -1,58 +1,93 @@ -//! `a3s top` — a ctop-style monitor for containers, processes, and coding agents. +//! `a3s top` — a ctop-style monitor for boxes, coding agents, and diagnostics. //! //! The first milestone intentionally keeps collectors independent from the TUI //! layer. That lets the same snapshot model later feed `--json`, remote //! dashboards, or the lightweight `/top` panel inside `a3s code`. -use std::collections::HashMap; -use std::time::{Duration, Instant}; +use std::collections::{HashMap, HashSet}; +use std::io::SeekFrom; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use a3s_tui::cmd::{self, Cmd}; -use a3s_tui::components::StatusBar; +use a3s_tui::components::{ + CellAlign, DataColumn, DataRow, DataTable, Meter, MetricTrend, MultiSelect, MultiSelectMsg, + Select, SelectMsg, Sparkline, StatusBar, Tree, TreeNode, +}; use a3s_tui::event::KeyEvent; use a3s_tui::keymap::{KeyBinding, Keymap}; use a3s_tui::layout::{Constraint, Layout}; use a3s_tui::style::{Color, Style}; use a3s_tui::{Event, KeyCode, KeyModifiers, Model, ProgramBuilder}; +use futures::stream::{self, StreamExt}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio::process::Command; +mod collect; +mod view; +pub(crate) use collect::{collect_processes, AgentKind, ProcessRow, Risk}; +#[cfg(test)] +use collect::{detect_agent, parse_lsof_cwd, parse_process_line, process_risk}; +pub(crate) use view::{render_process_table, ProcessTableView}; + const ACCENT: Color = Color::Rgb(122, 162, 247); const GREEN: Color = Color::Rgb(158, 206, 106); const YELLOW: Color = Color::Rgb(224, 175, 104); const RED: Color = Color::Rgb(247, 118, 142); const CYAN: Color = Color::Rgb(125, 207, 255); const ORANGE: Color = Color::Rgb(255, 158, 100); +const HISTORY_LIMIT: usize = 30; +const A3S_BOX_INSPECT_LIMIT: usize = 32; +const OBSERVER_EVENT_LIMIT: usize = 200; +const OBSERVER_AUTO_MAX_FILES_PER_AGENT: usize = 8; +const OBSERVER_AUTO_MAX_SCAN_FILES: usize = 512; +const OBSERVER_AUTO_INITIAL_TAIL_BYTES: u64 = 256 * 1024; +const A3S_BOX_PIDS_CONCURRENCY: usize = 4; +const A3S_BOX_PIDS_TIMEOUT: Duration = Duration::from_millis(900); +const AGENTS_TREE_SESSION_LIMIT: usize = 4; +const AGENTS_TREE_PROCESS_LIMIT: usize = 5; +const AGENTS_TREE_EVENT_LIMIT: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Tab { Agents, Containers, - Processes, + Sessions, Events, + Processes, } impl Tab { - const ALL: [Tab; 4] = [Tab::Agents, Tab::Containers, Tab::Processes, Tab::Events]; + const PRIMARY: [Tab; 2] = [Tab::Agents, Tab::Containers]; + const ALL: [Tab; 5] = [ + Tab::Agents, + Tab::Containers, + Tab::Sessions, + Tab::Events, + Tab::Processes, + ]; - fn index(self) -> usize { - Self::ALL.iter().position(|t| *t == self).unwrap_or(0) + fn primary_index(self) -> usize { + Self::PRIMARY.iter().position(|t| *t == self).unwrap_or(0) } fn label(self) -> &'static str { match self { Tab::Agents => "Agents", Tab::Containers => "Containers", - Tab::Processes => "Processes", + Tab::Sessions => "Sessions", Tab::Events => "Events", + Tab::Processes => "Processes", } } fn next(self) -> Self { - Self::ALL[(self.index() + 1) % Self::ALL.len()] + Self::PRIMARY[(self.primary_index() + 1) % Self::PRIMARY.len()] } fn prev(self) -> Self { - Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + Self::PRIMARY[(self.primary_index() + Self::PRIMARY.len() - 1) % Self::PRIMARY.len()] } } @@ -60,15 +95,30 @@ impl Tab { enum SortBy { Cpu, Mem, + Net, + Block, + Pids, + State, + Id, + Uptime, Name, + Tokens, } impl SortBy { + #[cfg(test)] fn next(self) -> Self { match self { SortBy::Cpu => SortBy::Mem, - SortBy::Mem => SortBy::Name, - SortBy::Name => SortBy::Cpu, + SortBy::Mem => SortBy::Net, + SortBy::Net => SortBy::Block, + SortBy::Block => SortBy::Pids, + SortBy::Pids => SortBy::State, + SortBy::State => SortBy::Id, + SortBy::Id => SortBy::Uptime, + SortBy::Uptime => SortBy::Name, + SortBy::Name => SortBy::Tokens, + SortBy::Tokens => SortBy::Cpu, } } @@ -76,68 +126,382 @@ impl SortBy { match self { SortBy::Cpu => "cpu", SortBy::Mem => "mem", + SortBy::Net => "net", + SortBy::Block => "block", + SortBy::Pids => "pids", + SortBy::State => "state", + SortBy::Id => "id", + SortBy::Uptime => "uptime", SortBy::Name => "name", + SortBy::Tokens => "tokens", + } + } + + fn from_label(label: &str) -> Option { + let normalized = label.trim().to_ascii_lowercase(); + match normalized.as_str() { + "cpu" => Some(SortBy::Cpu), + "mem" | "mem%" | "mem %" | "mem-pct" | "mem_pct" | "memory" => Some(SortBy::Mem), + "net" | "network" => Some(SortBy::Net), + "block" | "blk" | "disk" | "io" => Some(SortBy::Block), + "pid" | "pids" | "processes" => Some(SortBy::Pids), + "state" | "status" => Some(SortBy::State), + "id" | "container-id" | "container_id" => Some(SortBy::Id), + "uptime" | "up" | "started" => Some(SortBy::Uptime), + "name" => Some(SortBy::Name), + "tok" | "token" | "tokens" | "llm" => Some(SortBy::Tokens), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ContainerConnector { + A3sBox, + Docker, + RunC, +} + +impl ContainerConnector { + fn label(self) -> &'static str { + match self { + ContainerConnector::A3sBox => "a3s-box", + ContainerConnector::Docker => "docker", + ContainerConnector::RunC => "runc", + } + } + + fn from_label(label: &str) -> Option { + match label { + "a3s-box" | "a3sbox" | "box" => Some(ContainerConnector::A3sBox), + "docker" => Some(ContainerConnector::Docker), + "runc" => Some(ContainerConnector::RunC), + _ => None, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Risk { - Low, +enum RiskFilter { + All, Medium, High, } -impl Risk { +impl RiskFilter { + fn next(self) -> Self { + match self { + RiskFilter::All => RiskFilter::Medium, + RiskFilter::Medium => RiskFilter::High, + RiskFilter::High => RiskFilter::All, + } + } + fn label(self) -> &'static str { match self { - Risk::Low => "low", - Risk::Medium => "med", - Risk::High => "high", + RiskFilter::All => "all", + RiskFilter::Medium => "medium", + RiskFilter::High => "high", + } + } + + fn from_label(label: &str) -> Option { + match label { + "all" => Some(RiskFilter::All), + "medium" | "med" => Some(RiskFilter::Medium), + "high" => Some(RiskFilter::High), + _ => None, } } - fn color(self) -> Color { + fn matches(self, risk: Risk) -> bool { match self { - Risk::Low => GREEN, - Risk::Medium => YELLOW, - Risk::High => RED, + RiskFilter::All => true, + RiskFilter::Medium => matches!(risk, Risk::Medium | Risk::High), + RiskFilter::High => risk == Risk::High, } } } -#[derive(Debug, Clone)] -struct ProcessRow { - pid: u32, - ppid: u32, - cpu_pct: f32, - mem_pct: f32, - elapsed: String, - command: String, - agent: Option, - risk: Risk, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KindFilter { + All, + Tool, + Security, + File, + Egress, + Llm, + Other, +} + +impl KindFilter { + fn next(self) -> Self { + match self { + KindFilter::All => KindFilter::Tool, + KindFilter::Tool => KindFilter::Security, + KindFilter::Security => KindFilter::File, + KindFilter::File => KindFilter::Egress, + KindFilter::Egress => KindFilter::Llm, + KindFilter::Llm => KindFilter::Other, + KindFilter::Other => KindFilter::All, + } + } + + fn label(self) -> &'static str { + match self { + KindFilter::All => "all", + KindFilter::Tool => "tool", + KindFilter::Security => "security", + KindFilter::File => "file", + KindFilter::Egress => "egress", + KindFilter::Llm => "llm", + KindFilter::Other => "other", + } + } + + fn from_label(label: &str) -> Option { + match label { + "all" => Some(KindFilter::All), + "tool" | "tools" | "tool-exec" | "toolexec" => Some(KindFilter::Tool), + "security" | "security-action" | "securityaction" => Some(KindFilter::Security), + "file" | "files" => Some(KindFilter::File), + "egress" | "network" => Some(KindFilter::Egress), + "llm" | "model" | "tokens" => Some(KindFilter::Llm), + "other" => Some(KindFilter::Other), + _ => None, + } + } + + fn matches(self, kind: &str) -> bool { + match self { + KindFilter::All => true, + KindFilter::Tool => kind == "ToolExec", + KindFilter::Security => kind == "SecurityAction", + KindFilter::File => matches!(kind, "FileAccess" | "FileDelete"), + KindFilter::Egress => kind == "Egress", + KindFilter::Llm => is_llm_event_kind(kind), + KindFilter::Other => { + !matches!( + kind, + "ToolExec" | "SecurityAction" | "FileAccess" | "FileDelete" | "Egress" + ) && !is_llm_event_kind(kind) + } + } + } } #[derive(Debug, Clone)] struct ContainerRow { + connector: ContainerConnector, id: String, name: String, image: String, status: String, + inspect: ContainerInspect, cpu_pct: Option, + cpu_count: Option, + cpu_usage_total_ns: Option, + mem_pct: Option, mem_usage: String, net_io: String, block_io: String, pids: String, + ports: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ContainerInspect { + health: String, + restarts: String, + restart_policy: String, + created: String, + started: String, + exit: String, + mounts: String, + env: String, + labels: String, + networks: String, +} + +impl Default for ContainerInspect { + fn default() -> Self { + Self { + health: "-".into(), + restarts: "-".into(), + restart_policy: "-".into(), + created: "-".into(), + started: "-".into(), + exit: "-".into(), + mounts: "-".into(), + env: "-".into(), + labels: "-".into(), + networks: "-".into(), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ContainerStateSummary { + total: usize, + running: usize, + restarting: usize, + paused: usize, + exited: usize, + created: usize, + dead: usize, + other: usize, +} + +#[derive(Debug, Clone, Default)] +struct RuncStats { + cpu_usage_total_ns: Option, + memory_usage: Option, + memory_limit: Option, + pids_current: Option, + net_rx: u64, + net_tx: u64, + block_read: u64, + block_write: u64, +} + +#[derive(Debug, Clone, Default)] +struct MetricHistory { + cpu: Vec, + mem: Vec, + net_io_bytes: Vec, + block_io_bytes: Vec, + cpu_usage_total_ns: Option, + raw_sample_at: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +struct ProcessTreeUsage { + cpu_pct: f32, + mem_pct: f32, + descendants: usize, } #[derive(Debug, Clone)] struct EventRow { ts: String, source: String, + session: Option, + task: Option, + pid: Option, + ppid: Option, kind: String, message: String, + details: Vec<(String, String)>, + risk: Risk, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct AgentActivity { + events: usize, + sessions: usize, + tools: usize, + security: usize, + files: usize, + egress: usize, + llm: usize, + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, + model: String, + provider: String, + latency_ms: u64, + latency_samples: u64, + ttft_ms: u64, + ttft_samples: u64, + req_bytes: u64, + resp_bytes: u64, + high_risk: usize, +} + +#[derive(Debug, Clone)] +struct AgentTreeGroup { + agent: AgentKind, + sessions: Vec, + processes: Vec, + events: Vec, + activity: AgentActivity, + usage: ProcessTreeUsage, +} + +impl AgentTreeGroup { + fn active(&self) -> bool { + !self.sessions.is_empty() || !self.processes.is_empty() || !self.events.is_empty() + } + + fn risk(&self) -> Risk { + self.processes + .iter() + .map(|process| process.risk) + .chain(self.events.iter().map(|event| event.risk)) + .chain(self.sessions.iter().map(|session| session.risk)) + .max_by_key(|risk| risk_rank(*risk)) + .unwrap_or(Risk::Low) + } +} + +#[derive(Debug, Clone)] +struct SessionRow { + source: String, + session: String, + task: String, + workspace: String, + events: usize, + tools: usize, + security: usize, + files: usize, + egress: usize, + llm: usize, + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, + model: String, + provider: String, + latency_ms: u64, + latency_samples: u64, + ttft_ms: u64, + ttft_samples: u64, + req_bytes: u64, + resp_bytes: u64, + high_risk: usize, risk: Risk, + last_kind: String, + last_message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionFocus { + source: String, + session: String, +} + +impl SessionFocus { + fn from_row(row: &SessionRow) -> Self { + Self { + source: row.source.clone(), + session: row.session.clone(), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TokenUsageSummary { + prompt: u64, + completion: u64, + total: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct LlmNetworkSummary { + provider: Option, + latency_ms: Option, + ttft_ms: Option, + req_bytes: u64, + resp_bytes: u64, } #[derive(Debug, Clone, Default)] @@ -148,50 +512,228 @@ struct TopSnapshot { errors: Vec, } +#[derive(Debug, Clone, Default)] +struct ObserverState { + paths: Vec, + auto_paths: HashSet, + files: HashMap, + events: Vec, +} + +#[derive(Debug, Clone, Default)] +struct ObserverFileState { + offset: u64, + pending: String, + json_events_seen: usize, +} + +#[derive(Debug, Clone)] +enum Action { + KillProcess(u32, String), + StartContainer(ContainerConnector, String, String), + StopContainer(ContainerConnector, String, String), + RestartContainer(ContainerConnector, String, String), + PauseContainer(ContainerConnector, String, String), + UnpauseContainer(ContainerConnector, String, String), + RemoveContainer(ContainerConnector, String, String), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AgentKind { - A3sCode, - ClaudeCode, - Codex, - Cursor, - Gemini, +enum ContainerMenuAction { + Focus, + Logs, + ExecShell, + OpenBrowser, + Start, + Stop, + Restart, + Pause, + Unpause, + Remove, } -impl AgentKind { - fn label(self) -> &'static str { - match self { - AgentKind::A3sCode => "a3s-code", - AgentKind::ClaudeCode => "claude", - AgentKind::Codex => "codex", - AgentKind::Cursor => "cursor", - AgentKind::Gemini => "gemini", - } - } +#[derive(Debug, Clone)] +struct ContainerMenuItem { + action: ContainerMenuAction, + key: char, + label: String, +} - fn color(self) -> Color { - match self { - AgentKind::A3sCode => ACCENT, - AgentKind::ClaudeCode => ORANGE, - AgentKind::Codex => Color::Rgb(16, 163, 127), - AgentKind::Cursor => Color::Rgb(180, 182, 200), - AgentKind::Gemini => Color::Rgb(124, 137, 245), +#[derive(Debug, Clone)] +enum ExternalAction { + ContainerShell { + connector: ContainerConnector, + id: String, + name: String, + }, + OpenBrowser { + url: String, + name: String, + }, +} + +#[derive(Debug, Clone)] +struct TopConfig { + show_all_containers: bool, + show_header: bool, + reverse_sort: bool, + sort_by: SortBy, + risk_filter: RiskFilter, + kind_filter: KindFilter, + connector: ContainerConnector, + filter: String, + hidden_columns: HashSet, +} + +impl Default for TopConfig { + fn default() -> Self { + Self { + show_all_containers: false, + show_header: true, + reverse_sort: false, + sort_by: SortBy::Cpu, + risk_filter: RiskFilter::All, + kind_filter: KindFilter::All, + connector: ContainerConnector::A3sBox, + filter: String::new(), + hidden_columns: default_hidden_columns(), } } } +fn default_hidden_columns() -> HashSet { + [ + "agents.pid", + "agents.cpu_history", + "agents.mem_history", + "agents.sessions", + "agents.session", + "agents.task", + "agents.tools", + "agents.security", + "agents.files", + "agents.egress", + "agents.llm", + "agents.model", + "agents.provider", + "agents.latency", + "agents.high_risk", + "agents.children", + "agents.elapsed", + "agents.cwd", + "sessions.task", + "sessions.tools", + "sessions.security", + "sessions.files", + "sessions.egress", + "sessions.model", + "sessions.provider", + "sessions.latency", + "sessions.high_risk", + "processes.ppid", + "processes.cpu_history", + "processes.mem_history", + "processes.elapsed", + "processes.cwd", + "containers.cpus", + "containers.image", + "containers.mem_usage", + "containers.ports", + "containers.health", + "events.session", + "events.task", + "events.pid", + "events.ppid", + ] + .into_iter() + .map(String::from) + .collect() +} + #[derive(Debug, Clone)] -enum Action { - KillProcess(u32, String), - StopContainer(String, String), - RestartContainer(String, String), +struct LogPanel { + connector: ContainerConnector, + container_id: String, + container_name: String, + text: String, + scroll: usize, + timestamps: bool, + loading: bool, + refreshing: bool, + follow: bool, +} + +#[derive(Debug, Clone, Default)] +struct ContainerProcessRow { + pid: String, + ppid: String, + cpu_pct: Option, + mem_pct: Option, + elapsed: String, + command: String, +} + +#[derive(Debug, Clone)] +struct ContainerProcessPanel { + container_id: String, + container_name: String, + rows: Vec, + scroll: usize, + error: Option, + loading: bool, +} + +#[derive(Debug, Clone)] +struct ColumnChoice { + id: &'static str, + label: &'static str, +} + +struct ColumnPanel { + tab: Tab, + choices: Vec, + select: MultiSelect, +} + +struct SortPanel { + choices: Vec, + select: Select, +} + +struct ConnectorPanel { + choices: Vec, + select: Select, +} + +struct ContainerMenu { + container: ContainerRow, + items: Vec, + select: Select, } #[derive(Debug, Clone)] enum Msg { Term(Event), - Snapshot(TopSnapshot), + Snapshot { + connector: ContainerConnector, + snapshot: TopSnapshot, + observer: ObserverState, + }, Tick, ActionDone(String), + ContainerLogs { + connector: ContainerConnector, + id: String, + name: String, + timestamps: bool, + result: Result, + }, + ContainerProcesses { + id: String, + name: String, + result: Result, String>, + }, + ConfigSaved(Result), } impl From for Msg { @@ -200,11 +742,13 @@ impl From for Msg { } } -#[derive(Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] enum TopKey { Quit, Up, Down, + Home, + End, PageUp, PageDown, NextTab, @@ -212,22 +756,57 @@ enum TopKey { Filter, Sort, TogglePause, + ToggleAll, + ToggleHeader, + ToggleReverse, + ToggleRiskFilter, + ToggleKindFilter, Detail, + Open, + Logs, + OpenBrowser, + Columns, + Connector, + ExecShell, + SaveConfig, + Help, Kill, - Restart, } struct TopApp { snapshot: TopSnapshot, + observer: ObserverState, + runtime_events: Vec, + history: HashMap, tab: Tab, sort_by: SortBy, selected: usize, scroll: usize, filter: String, editing_filter: bool, + filter_before_edit: Option, + help: bool, detail: bool, paused: bool, + show_all_containers: bool, + show_header: bool, + reverse_sort: bool, + risk_filter: RiskFilter, + kind_filter: KindFilter, + invert_colors: bool, + connector: ContainerConnector, + hidden_columns: HashSet, + focused_container: Option, + focused_agent_pid: Option, + focused_session: Option, confirm: Option, + log: Option, + container_processes: Option, + column_panel: Option, + sort_panel: Option, + connector_panel: Option, + container_menu: Option, + external_action: Arc>>, note: Option, interval: Duration, width: u16, @@ -239,24 +818,73 @@ struct TopApp { impl TopApp { fn new(options: TopOptions) -> Self { let (width, height) = a3s_tui::terminal::Terminal::size().unwrap_or((100, 30)); - Self { + let mut app = Self { snapshot: TopSnapshot::default(), + observer: ObserverState::default(), + runtime_events: Vec::new(), + history: HashMap::new(), tab: options.tab, - sort_by: SortBy::Cpu, + sort_by: options.config.sort_by, selected: 0, scroll: 0, - filter: String::new(), + filter: options.config.filter, editing_filter: false, + filter_before_edit: None, + help: options.start_help, detail: false, paused: false, + show_all_containers: options.config.show_all_containers, + show_header: options.config.show_header, + reverse_sort: options.config.reverse_sort, + risk_filter: options.config.risk_filter, + kind_filter: options.config.kind_filter, + invert_colors: options.invert_colors, + connector: options.config.connector, + hidden_columns: options.config.hidden_columns, + focused_container: options.container_query.clone(), + focused_agent_pid: None, + focused_session: None, confirm: None, + log: None, + container_processes: None, + column_panel: None, + sort_panel: None, + connector_panel: None, + container_menu: None, + external_action: options.external_action, note: None, interval: options.interval, width, height, last_refresh: None, keymap: top_keymap(), + }; + app.ensure_visible_columns(); + app + } + + fn apply_snapshot( + &mut self, + mut snapshot: TopSnapshot, + observer: ObserverState, + connector: ContainerConnector, + ) { + if self.last_refresh.is_some() { + push_runtime_events( + &mut self.runtime_events, + container_lifecycle_events( + connector, + &self.snapshot.containers, + &snapshot.containers, + ), + ); } + prepend_runtime_events(&mut snapshot.events, &self.runtime_events); + self.record_history(&mut snapshot); + self.snapshot = snapshot; + self.observer = observer; + self.last_refresh = Some(Instant::now()); + self.clamp_selection(); } fn reset_position(&mut self) { @@ -267,7 +895,10 @@ impl TopApp { fn visible_len(&self) -> usize { match self.tab { - Tab::Agents => self.filtered_agents().len(), + Tab::Agents if self.focused_agent_pid.is_some() => self.filtered_agents().len(), + Tab::Agents => self.agent_tree_groups().len(), + Tab::Sessions if self.focused_session.is_some() => self.focused_session_events().len(), + Tab::Sessions => self.filtered_sessions().len(), Tab::Containers => self.filtered_containers().len(), Tab::Processes => self.filtered_processes().len(), Tab::Events => self.filtered_events().len(), @@ -279,6 +910,100 @@ impl TopApp { (self.height as usize).saturating_sub(reserved).max(3) } + fn log_visible_height(&self) -> usize { + (self.height as usize).saturating_sub(7).max(3) + } + + fn record_history(&mut self, snapshot: &mut TopSnapshot) { + let now = Instant::now(); + let mut live_keys = HashSet::new(); + for process in &snapshot.processes { + let key = process_history_key(process.pid); + live_keys.insert(key.clone()); + push_history( + self.history.entry(key).or_default(), + process.cpu_pct, + process.mem_pct, + ); + } + let agent_tree_usages = snapshot + .processes + .iter() + .filter(|process| process.agent.is_some()) + .map(|process| { + ( + agent_tree_history_key(process.pid), + process_tree_usage(&snapshot.processes, process.pid), + ) + }) + .collect::>(); + for (key, usage) in agent_tree_usages { + live_keys.insert(key.clone()); + push_history( + self.history.entry(key).or_default(), + usage.cpu_pct, + usage.mem_pct, + ); + } + for container in &mut snapshot.containers { + let key = container_history_key(&container.id); + live_keys.insert(key.clone()); + let history = self.history.entry(key).or_default(); + let cpu_pct = container.cpu_pct.unwrap_or_else(|| { + observe_raw_cpu_pct(history, container.cpu_usage_total_ns, now).unwrap_or_default() + }); + if container.cpu_pct.is_none() && container.cpu_usage_total_ns.is_some() { + container.cpu_pct = Some(cpu_pct); + } + push_history(history, cpu_pct, container.mem_pct.unwrap_or_default()); + push_io_history( + history, + container_net_total(container), + container_block_total(container), + ); + } + self.history.retain(|key, _| live_keys.contains(key)); + } + + fn metric_history(&self, key: &str) -> MetricHistory { + self.history.get(key).cloned().unwrap_or_default() + } + + fn sparkline(&self, values: &[f32], color: Color) -> String { + Sparkline::new(values.iter().copied().map(f64::from)) + .width(8) + .range(0.0, 100.0) + .fg(color) + .view() + } + + fn metric_cell(&self, pct: Option, values: &[f32], color: Color) -> String { + MetricTrend::new(pct.map(f64::from), values.iter().copied().map(f64::from)) + .width(15) + .trend_width(8) + .range(0.0, 100.0) + .fg(color) + .view() + } + + fn scaled_cpu_metric_cell( + &self, + pct: Option, + values: &[f32], + cpus: Option, + ) -> String { + let history = values + .iter() + .copied() + .map(|value| scale_cpu_pct_for_cpus(value, cpus)) + .collect::>(); + self.metric_cell( + pct.map(|value| scale_cpu_pct_for_cpus(value, cpus)), + &history, + CYAN, + ) + } + fn clamp_selection(&mut self) { let len = self.visible_len(); if len == 0 { @@ -295,78 +1020,866 @@ impl TopApp { } } - fn selected_action(&self, restart: bool) -> Option { + fn selected_action(&self) -> Option { match self.tab { Tab::Agents | Tab::Processes => self .current_process() .map(|p| Action::KillProcess(p.pid, display_cmd(&p.command))), - Tab::Containers if restart => self.current_container().map(|c| { - Action::RestartContainer(c.id.clone(), format!("{} ({})", c.name, short_id(&c.id))) - }), Tab::Containers => self.current_container().map(|c| { - Action::StopContainer(c.id.clone(), format!("{} ({})", c.name, short_id(&c.id))) + Action::StopContainer( + c.connector, + c.id.clone(), + format!("{} ({})", c.name, short_id(&c.id)), + ) }), Tab::Events => None, + Tab::Sessions => None, } } fn current_process(&self) -> Option { match self.tab { - Tab::Agents => self.filtered_agents().get(self.selected).cloned(), + Tab::Agents if self.focused_agent_pid.is_some() => { + self.filtered_agents().first().cloned() + } + Tab::Agents => self + .current_agent_group() + .and_then(|group| group.processes.first().cloned()), Tab::Processes => self.filtered_processes().get(self.selected).cloned(), _ => None, } } + fn current_agent_group(&self) -> Option { + if self.tab != Tab::Agents || self.focused_agent_pid.is_some() { + return None; + } + self.agent_tree_groups().get(self.selected).cloned() + } + + fn current_session(&self) -> Option { + (self.tab == Tab::Sessions) + .then(|| self.filtered_sessions().get(self.selected).cloned()) + .flatten() + } + + fn current_event(&self) -> Option { + match self.tab { + Tab::Events => self.filtered_events().get(self.selected).cloned(), + Tab::Sessions if self.focused_session.is_some() => { + self.focused_session_events().get(self.selected).cloned() + } + _ => None, + } + } + fn current_container(&self) -> Option { self.filtered_containers().get(self.selected).cloned() } - fn filtered_agents(&self) -> Vec { - let mut rows: Vec<_> = self + fn descendant_count(&self, pid: u32) -> usize { + self.process_tree_usage(pid).descendants + } + + fn process_tree_usage(&self, pid: u32) -> ProcessTreeUsage { + process_tree_usage(&self.snapshot.processes, pid) + } + + fn process_tree(&self, pid: u32) -> Option { + let mut visited = HashSet::new(); + self.process_tree_node(pid, &mut visited) + } + + fn process_tree_node(&self, pid: u32, visited: &mut HashSet) -> Option { + if !visited.insert(pid) { + return None; + } + let process = self.snapshot.processes.iter().find(|p| p.pid == pid)?; + let mut children = self .snapshot .processes .iter() - .filter(|p| p.agent.is_some()) - .filter(|p| self.matches_filter(&p.command)) - .cloned() - .collect(); - sort_processes(&mut rows, self.sort_by); - rows + .filter(|p| p.ppid == pid) + .collect::>(); + children.sort_by_key(|p| p.pid); + let children = children + .into_iter() + .filter_map(|child| self.process_tree_node(child.pid, visited)) + .collect::>(); + let label = format!( + "{} cpu {:.1}% mem {:.1}% {}", + process.pid, + process.cpu_pct, + process.mem_pct, + display_cmd(&process.command) + ); + if children.is_empty() { + Some(TreeNode::leaf(label)) + } else { + Some(TreeNode::branch(label, children)) + } } - fn filtered_processes(&self) -> Vec { - let mut rows: Vec<_> = self - .snapshot - .processes + fn recent_agent_events_for_process(&self, process: &ProcessRow) -> Vec { + if process.agent.is_none() { + return Vec::new(); + } + self.snapshot + .events .iter() - .filter(|p| self.matches_filter(&p.command) || self.matches_filter(&p.pid.to_string())) + .filter(|event| self.event_matches_agent_process(process, event)) + .take(3) .cloned() - .collect(); - sort_processes(&mut rows, self.sort_by); - rows + .collect() } - fn filtered_containers(&self) -> Vec { - let mut rows: Vec<_> = self - .snapshot - .containers + #[cfg(test)] + fn recent_agent_events(&self, agent: AgentKind) -> Vec { + self.snapshot + .events .iter() - .filter(|c| { - self.matches_filter(&c.name) - || self.matches_filter(&c.image) - || self.matches_filter(&c.status) - }) + .filter(|event| agent_matches_source(agent, &event.source)) + .take(3) .cloned() - .collect(); - match self.sort_by { - SortBy::Cpu => rows.sort_by(|a, b| { - b.cpu_pct - .unwrap_or_default() + .collect() + } + + #[cfg(test)] + fn agent_activity(&self, agent: AgentKind) -> AgentActivity { + self.agent_activity_from_events( + self.snapshot + .events + .iter() + .filter(|event| agent_matches_source(agent, &event.source)), + ) + } + + fn agent_activity_for_process(&self, process: &ProcessRow) -> AgentActivity { + if process.agent.is_none() { + return AgentActivity::default(); + } + self.agent_activity_from_events( + self.snapshot + .events + .iter() + .filter(|event| self.event_matches_agent_process(process, event)), + ) + } + + fn agent_activity_from_events<'a>( + &self, + events: impl IntoIterator, + ) -> AgentActivity { + let mut sessions = HashSet::new(); + let mut activity = AgentActivity::default(); + for event in events { + activity.events += 1; + if event.kind == "ToolExec" { + activity.tools += 1; + } + if event.kind == "SecurityAction" { + activity.security += 1; + } + if matches!(event.kind.as_str(), "FileAccess" | "FileDelete") { + activity.files += 1; + } + if event.kind == "Egress" { + activity.egress += 1; + } + if is_llm_event_kind(&event.kind) { + activity.llm += 1; + } + if let Some(tokens) = event_token_usage(event) { + activity.prompt_tokens += tokens.prompt; + activity.completion_tokens += tokens.completion; + activity.total_tokens += tokens.total; + } + if activity.model.is_empty() { + if let Some(model) = event_model(event) { + activity.model = model; + } + } + if let Some(network) = event_llm_network(event) { + if activity.provider.is_empty() { + if let Some(provider) = network.provider { + activity.provider = provider; + } + } + if let Some(latency_ms) = network.latency_ms { + activity.latency_ms += latency_ms; + activity.latency_samples += 1; + } + if let Some(ttft_ms) = network.ttft_ms { + activity.ttft_ms += ttft_ms; + activity.ttft_samples += 1; + } + activity.req_bytes += network.req_bytes; + activity.resp_bytes += network.resp_bytes; + } + if event.risk == Risk::High { + activity.high_risk += 1; + } + if let Some(session) = event.session.as_ref().or(event.task.as_ref()) { + sessions.insert(session.clone()); + } + } + activity.sessions = sessions.len(); + activity + } + + fn event_matches_agent_process(&self, process: &ProcessRow, event: &EventRow) -> bool { + let Some(agent) = process.agent else { + return false; + }; + if !agent_matches_source(agent, &event.source) { + return false; + } + if let Some(pid) = event.pid { + if self.pid_belongs_to_process(process.pid, pid) { + return true; + } + } + if let Some(ppid) = event.ppid { + if self.pid_belongs_to_process(process.pid, ppid) { + return true; + } + } + if event.pid.is_some() || event.ppid.is_some() { + return false; + } + self.event_workspace_matches_process(process, event) + || self.agent_source_is_unambiguous(agent) + } + + fn event_workspace_matches_process(&self, process: &ProcessRow, event: &EventRow) -> bool { + let Some(agent) = process.agent else { + return false; + }; + let Some(workspace) = event_workspace(event) else { + return false; + }; + if !process + .cwd + .as_deref() + .is_some_and(|cwd| workspace_paths_overlap(cwd, &workspace)) + { + return false; + } + + self.snapshot + .processes + .iter() + .filter(|candidate| candidate.agent == Some(agent)) + .filter(|candidate| { + candidate + .cwd + .as_deref() + .is_some_and(|cwd| workspace_paths_overlap(cwd, &workspace)) + }) + .take(2) + .count() + == 1 + } + + fn pid_belongs_to_process(&self, root_pid: u32, pid: u32) -> bool { + if pid == root_pid { + return true; + } + let mut current = pid; + let mut visited = HashSet::new(); + while visited.insert(current) { + let Some(process) = self.snapshot.processes.iter().find(|p| p.pid == current) else { + return false; + }; + if process.ppid == root_pid { + return true; + } + if process.ppid == 0 || process.ppid == current { + return false; + } + current = process.ppid; + } + false + } + + fn agent_source_is_unambiguous(&self, agent: AgentKind) -> bool { + self.snapshot + .processes + .iter() + .filter(|process| process.agent == Some(agent)) + .count() + == 1 + } + + fn process_matches_risk_filter(&self, process: &ProcessRow) -> bool { + if self.risk_filter.matches(process.risk) { + return true; + } + self.risk_filter == RiskFilter::High + && process.agent.is_some() + && self.agent_activity_for_process(process).high_risk > 0 + } + + fn process_token_total(&self, process: &ProcessRow) -> u64 { + self.agent_activity_for_process(process).total_tokens + } + + fn process_cpu_total(&self, process: &ProcessRow) -> f32 { + if process.agent.is_some() { + self.process_tree_usage(process.pid).cpu_pct + } else { + process.cpu_pct + } + } + + fn process_mem_total(&self, process: &ProcessRow) -> f32 { + if process.agent.is_some() { + self.process_tree_usage(process.pid).mem_pct + } else { + process.mem_pct + } + } + + fn sort_process_rows(&self, rows: &mut [ProcessRow]) { + match self.sort_by { + SortBy::Cpu => rows.sort_by(|a, b| { + b.agent + .is_some() + .cmp(&a.agent.is_some()) + .then( + self.process_cpu_total(b) + .partial_cmp(&self.process_cpu_total(a)) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .then(a.command.cmp(&b.command)) + }), + SortBy::Mem => rows.sort_by(|a, b| { + b.agent + .is_some() + .cmp(&a.agent.is_some()) + .then( + self.process_mem_total(b) + .partial_cmp(&self.process_mem_total(a)) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .then(a.command.cmp(&b.command)) + }), + SortBy::Net => rows.sort_by(|a, b| { + self.agent_activity_for_process(b) + .egress + .cmp(&self.agent_activity_for_process(a).egress) + .then(b.agent.is_some().cmp(&a.agent.is_some())) + .then(a.command.cmp(&b.command)) + }), + SortBy::Pids => rows.sort_by(|a, b| { + self.process_tree_usage(b.pid) + .descendants + .cmp(&self.process_tree_usage(a.pid).descendants) + .then(b.agent.is_some().cmp(&a.agent.is_some())) + .then(a.command.cmp(&b.command)) + }), + SortBy::Id => rows.sort_by(|a, b| a.pid.cmp(&b.pid).then(a.command.cmp(&b.command))), + SortBy::Block | SortBy::State | SortBy::Uptime | SortBy::Name => { + rows.sort_by(|a, b| a.command.cmp(&b.command)) + } + SortBy::Tokens => rows.sort_by(|a, b| { + self.process_token_total(b) + .cmp(&self.process_token_total(a)) + .then(b.agent.is_some().cmp(&a.agent.is_some())) + .then( + self.process_cpu_total(b) + .partial_cmp(&self.process_cpu_total(a)) + .unwrap_or(std::cmp::Ordering::Equal), + ) + .then(a.command.cmp(&b.command)) + }), + } + } + + fn column_visible(&self, id: &str) -> bool { + !self.hidden_columns.contains(id) + } + + fn configured_column(&self, id: &str, column: DataColumn) -> DataColumn { + if self.column_visible(id) { + column + } else { + column.hidden() + } + } + + fn configured_container_column(&self, id: &str, column: DataColumn) -> DataColumn { + let column = if container_sort_column_id(self.sort_by) == Some(id) { + column.header_suffix(if self.reverse_sort { "↑" } else { "↓" }) + } else { + column + }; + self.configured_column(id, column) + } + + fn column_choices(&self, tab: Tab) -> Vec { + match tab { + Tab::Agents => vec![ + ColumnChoice { + id: "agents.pid", + label: "PID", + }, + ColumnChoice { + id: "agents.agent", + label: "Agent", + }, + ColumnChoice { + id: "agents.cpu", + label: "CPU %", + }, + ColumnChoice { + id: "agents.cpu_history", + label: "CPU trend", + }, + ColumnChoice { + id: "agents.mem", + label: "Memory %", + }, + ColumnChoice { + id: "agents.mem_history", + label: "Memory trend", + }, + ColumnChoice { + id: "agents.risk", + label: "Risk", + }, + ColumnChoice { + id: "agents.events", + label: "Events", + }, + ColumnChoice { + id: "agents.sessions", + label: "Sessions", + }, + ColumnChoice { + id: "agents.session", + label: "Top session", + }, + ColumnChoice { + id: "agents.task", + label: "Top task", + }, + ColumnChoice { + id: "agents.tools", + label: "Tools", + }, + ColumnChoice { + id: "agents.security", + label: "Security", + }, + ColumnChoice { + id: "agents.files", + label: "Files", + }, + ColumnChoice { + id: "agents.egress", + label: "Egress", + }, + ColumnChoice { + id: "agents.llm", + label: "LLM", + }, + ColumnChoice { + id: "agents.tokens", + label: "Tokens", + }, + ColumnChoice { + id: "agents.model", + label: "Model", + }, + ColumnChoice { + id: "agents.provider", + label: "Provider", + }, + ColumnChoice { + id: "agents.latency", + label: "Latency", + }, + ColumnChoice { + id: "agents.high_risk", + label: "High-risk", + }, + ColumnChoice { + id: "agents.children", + label: "Children", + }, + ColumnChoice { + id: "agents.elapsed", + label: "Elapsed", + }, + ColumnChoice { + id: "agents.cwd", + label: "Working directory", + }, + ColumnChoice { + id: "agents.command", + label: "Command", + }, + ], + Tab::Sessions => vec![ + ColumnChoice { + id: "sessions.source", + label: "Agent", + }, + ColumnChoice { + id: "sessions.session", + label: "Session", + }, + ColumnChoice { + id: "sessions.task", + label: "Task", + }, + ColumnChoice { + id: "sessions.workspace", + label: "Workspace", + }, + ColumnChoice { + id: "sessions.events", + label: "Events", + }, + ColumnChoice { + id: "sessions.tools", + label: "Tools", + }, + ColumnChoice { + id: "sessions.security", + label: "Security", + }, + ColumnChoice { + id: "sessions.files", + label: "Files", + }, + ColumnChoice { + id: "sessions.egress", + label: "Egress", + }, + ColumnChoice { + id: "sessions.llm", + label: "LLM", + }, + ColumnChoice { + id: "sessions.tokens", + label: "Tokens", + }, + ColumnChoice { + id: "sessions.model", + label: "Model", + }, + ColumnChoice { + id: "sessions.provider", + label: "Provider", + }, + ColumnChoice { + id: "sessions.latency", + label: "Latency", + }, + ColumnChoice { + id: "sessions.high_risk", + label: "High-risk", + }, + ColumnChoice { + id: "sessions.risk", + label: "Risk", + }, + ColumnChoice { + id: "sessions.kind", + label: "Last kind", + }, + ColumnChoice { + id: "sessions.message", + label: "Last message", + }, + ], + Tab::Processes => vec![ + ColumnChoice { + id: "processes.pid", + label: "PID", + }, + ColumnChoice { + id: "processes.ppid", + label: "PPID", + }, + ColumnChoice { + id: "processes.cpu", + label: "CPU %", + }, + ColumnChoice { + id: "processes.cpu_history", + label: "CPU trend", + }, + ColumnChoice { + id: "processes.mem", + label: "Memory %", + }, + ColumnChoice { + id: "processes.mem_history", + label: "Memory trend", + }, + ColumnChoice { + id: "processes.risk", + label: "Risk", + }, + ColumnChoice { + id: "processes.elapsed", + label: "Elapsed", + }, + ColumnChoice { + id: "processes.cwd", + label: "Working directory", + }, + ColumnChoice { + id: "processes.command", + label: "Command", + }, + ], + Tab::Containers => vec![ + ColumnChoice { + id: "containers.status", + label: "Status", + }, + ColumnChoice { + id: "containers.name", + label: "Name", + }, + ColumnChoice { + id: "containers.id", + label: "CID", + }, + ColumnChoice { + id: "containers.cpu", + label: "CPU", + }, + ColumnChoice { + id: "containers.cpus", + label: "CPU scaled", + }, + ColumnChoice { + id: "containers.mem", + label: "Memory", + }, + ColumnChoice { + id: "containers.net", + label: "Net I/O", + }, + ColumnChoice { + id: "containers.block", + label: "IO", + }, + ColumnChoice { + id: "containers.pids", + label: "PIDs", + }, + ColumnChoice { + id: "containers.uptime", + label: "Uptime", + }, + ColumnChoice { + id: "containers.image", + label: "Image", + }, + ColumnChoice { + id: "containers.mem_usage", + label: "Memory usage", + }, + ColumnChoice { + id: "containers.ports", + label: "Ports", + }, + ColumnChoice { + id: "containers.health", + label: "Health", + }, + ], + Tab::Events => vec![ + ColumnChoice { + id: "events.time", + label: "Time", + }, + ColumnChoice { + id: "events.source", + label: "Source", + }, + ColumnChoice { + id: "events.session", + label: "Session", + }, + ColumnChoice { + id: "events.task", + label: "Task", + }, + ColumnChoice { + id: "events.pid", + label: "PID", + }, + ColumnChoice { + id: "events.ppid", + label: "PPID", + }, + ColumnChoice { + id: "events.kind", + label: "Kind", + }, + ColumnChoice { + id: "events.risk", + label: "Risk", + }, + ColumnChoice { + id: "events.message", + label: "Message", + }, + ], + } + } + + fn ensure_visible_columns(&mut self) { + for tab in Tab::ALL { + let choices = self.column_choices(tab); + if !choices.is_empty() + && choices + .iter() + .all(|choice| self.hidden_columns.contains(choice.id)) + { + self.hidden_columns.remove(choices[0].id); + } + } + } + + fn filtered_agents(&self) -> Vec { + let mut rows: Vec<_> = self + .snapshot + .processes + .iter() + .filter(|p| p.agent.is_some()) + .filter(|p| self.focused_agent_pid.is_none_or(|pid| p.pid == pid)) + .filter(|p| self.process_matches_risk_filter(p)) + .filter(|p| self.agent_matches_filter(p)) + .cloned() + .collect(); + self.sort_process_rows(&mut rows); + if self.reverse_sort { + rows.reverse(); + } + rows + } + + fn filtered_processes(&self) -> Vec { + let mut rows: Vec<_> = self + .snapshot + .processes + .iter() + .filter(|p| self.process_matches_risk_filter(p)) + .filter(|p| { + self.matches_filter(&p.command) + || self.matches_filter(&p.pid.to_string()) + || p.cwd.as_ref().is_some_and(|cwd| self.matches_filter(cwd)) + }) + .cloned() + .collect(); + self.sort_process_rows(&mut rows); + if self.reverse_sort { + rows.reverse(); + } + rows + } + + fn filtered_sessions(&self) -> Vec { + let events = self + .snapshot + .events + .iter() + .filter(|event| self.event_matches_scope_filters(event)) + .cloned() + .collect::>(); + let mut rows = session_rows(&events) + .into_iter() + .filter(|row| { + self.matches_filter(&row.source) + || self.matches_filter(&row.session) + || self.matches_filter(&row.task) + || self.matches_filter(&row.workspace) + || self.matches_filter(&row.last_kind) + || self.matches_filter(&row.last_message) + }) + .collect::>(); + sort_sessions(&mut rows, self.sort_by); + if self.reverse_sort { + rows.reverse(); + } + rows + } + + fn filtered_containers(&self) -> Vec { + let mut rows: Vec<_> = self + .snapshot + .containers + .iter() + .filter(|c| { + self.focused_container + .as_ref() + .is_none_or(|query| container_matches_query(c, query)) + && (self.matches_filter(&c.name) + || self.matches_filter(&c.id) + || self.matches_filter(short_id(&c.id)) + || self.matches_filter(&c.image) + || self.matches_filter(&c.status) + || self.matches_filter(&c.ports) + || self.matches_filter(&c.inspect.health) + || self.matches_filter(&c.inspect.restart_policy) + || self.matches_filter(&c.inspect.mounts) + || self.matches_filter(&c.inspect.labels) + || self.matches_filter(&c.inspect.networks)) + }) + .cloned() + .collect(); + match self.sort_by { + SortBy::Cpu => rows.sort_by(|a, b| { + b.cpu_pct + .unwrap_or_default() .partial_cmp(&a.cpu_pct.unwrap_or_default()) .unwrap_or(std::cmp::Ordering::Equal) }), - SortBy::Mem | SortBy::Name => rows.sort_by(|a, b| a.name.cmp(&b.name)), + SortBy::Mem => rows.sort_by(|a, b| { + b.mem_pct + .unwrap_or_default() + .partial_cmp(&a.mem_pct.unwrap_or_default()) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.name.cmp(&b.name)) + }), + SortBy::Net => rows.sort_by(|a, b| { + container_net_total(b) + .cmp(&container_net_total(a)) + .then(a.name.cmp(&b.name)) + }), + SortBy::Block => rows.sort_by(|a, b| { + container_block_total(b) + .cmp(&container_block_total(a)) + .then(a.name.cmp(&b.name)) + }), + SortBy::Pids => rows.sort_by(|a, b| { + container_pid_count(b) + .cmp(&container_pid_count(a)) + .then(a.name.cmp(&b.name)) + }), + SortBy::State => rows.sort_by(|a, b| { + container_state_rank(b) + .cmp(&container_state_rank(a)) + .then(a.name.cmp(&b.name)) + }), + SortBy::Id => rows.sort_by(|a, b| a.id.cmp(&b.id).then(a.name.cmp(&b.name))), + SortBy::Uptime => rows.sort_by(|a, b| { + container_uptime_seconds(b) + .cmp(&container_uptime_seconds(a)) + .then(a.name.cmp(&b.name)) + }), + SortBy::Name | SortBy::Tokens => rows.sort_by(|a, b| a.name.cmp(&b.name)), + } + if self.reverse_sort { + rows.reverse(); } rows } @@ -375,15 +1888,104 @@ impl TopApp { self.snapshot .events .iter() - .filter(|e| { - self.matches_filter(&e.source) - || self.matches_filter(&e.kind) - || self.matches_filter(&e.message) - }) + .filter(|event| self.event_matches_scope_filters(event)) + .filter(|e| self.event_matches_filter(e)) + .cloned() + .collect() + } + + fn focused_session_events(&self) -> Vec { + let Some(focus) = &self.focused_session else { + return Vec::new(); + }; + self.snapshot + .events + .iter() + .filter(|event| session_focus_matches_event(focus, event)) + .filter(|event| self.event_matches_scope_filters(event)) + .filter(|event| self.event_matches_filter(event)) .cloned() .collect() } + fn focused_session_row(&self) -> Option { + let focus = self.focused_session.as_ref()?; + let events = self + .snapshot + .events + .iter() + .filter(|event| self.event_matches_scope_filters(event)) + .cloned() + .collect::>(); + session_rows(&events) + .into_iter() + .find(|row| row.source == focus.source && row.session == focus.session) + } + + fn event_matches_scope_filters(&self, event: &EventRow) -> bool { + self.risk_filter.matches(event.risk) && self.kind_filter.matches(&event.kind) + } + + fn event_matches_filter(&self, event: &EventRow) -> bool { + self.matches_filter(&event.source) + || event + .session + .as_ref() + .is_some_and(|session| self.matches_filter(session)) + || event + .task + .as_ref() + .is_some_and(|task| self.matches_filter(task)) + || self.matches_filter(&event.kind) + || self.matches_filter(&event.message) + || event + .details + .iter() + .any(|(key, value)| self.matches_filter(key) || self.matches_filter(value)) + } + + fn agent_matches_filter(&self, process: &ProcessRow) -> bool { + if self.filter.is_empty() { + return true; + } + if self.matches_filter(&process.command) + || self.matches_filter(&process.pid.to_string()) + || process + .cwd + .as_ref() + .is_some_and(|cwd| self.matches_filter(cwd)) + || process + .agent + .is_some_and(|agent| self.matches_filter(agent.label())) + { + return true; + } + + let activity = self.agent_activity_for_process(process); + if self.matches_filter(&activity.model) + || self.matches_filter(&activity.provider) + || self.matches_filter(&format_count(activity.total_tokens)) + { + return true; + } + + agent_session_rows(self, process).iter().any(|session| { + self.matches_filter(&session.source) + || self.matches_filter(&session.session) + || self.matches_filter(&session.task) + || self.matches_filter(&session.workspace) + || self.matches_filter(&session.model) + || self.matches_filter(&session.provider) + || self.matches_filter(&session.last_kind) + || self.matches_filter(&session.last_message) + }) || self + .snapshot + .events + .iter() + .filter(|event| self.event_matches_agent_process(process, event)) + .any(|event| self.event_matches_filter(event)) + } + fn matches_filter(&self, text: &str) -> bool { self.filter.is_empty() || text.to_lowercase().contains(&self.filter.to_lowercase()) } @@ -395,10 +1997,33 @@ impl TopApp { .iter() .filter(|p| p.agent.is_some()) .count(); - let containers = self.snapshot.containers.len(); + let containers = container_state_summary(&self.snapshot.containers); let processes = self.snapshot.processes.len(); - let title = - format!(" a3s top agents:{agents} containers:{containers} processes:{processes} "); + let high_events = self + .snapshot + .events + .iter() + .filter(|event| event.risk == Risk::High) + .count(); + let llm_events = self + .snapshot + .events + .iter() + .filter(|event| is_llm_event_kind(&event.kind)) + .count(); + let total_tokens = self + .snapshot + .events + .iter() + .filter_map(event_token_usage) + .map(|tokens| tokens.total) + .sum::(); + let title = format!( + " a3s top boxes:{} agents:{agents} processes:{processes} events:{} high:{high_events} llm:{llm_events} tok:{} ", + containers.header_label(), + self.snapshot.events.len(), + format_count(total_tokens) + ); let right = if self.paused { "paused".to_string() } else { @@ -422,7 +2047,11 @@ impl TopApp { fn tabs(&self) -> String { let mut parts = Vec::new(); - for tab in Tab::ALL { + let mut tabs = Tab::PRIMARY.to_vec(); + if !tabs.contains(&self.tab) { + tabs.push(self.tab); + } + for tab in tabs { let label = format!(" {} ", tab.label()); if tab == self.tab { parts.push( @@ -445,1029 +2074,13010 @@ impl TopApp { format!(" /{}", self.filter) }; line.push_str(&Style::new().fg(Color::BrightBlack).render(&filter)); + if let Some(id) = &self.focused_container { + let label = self + .snapshot + .containers + .iter() + .find(|c| c.id == *id || short_id(&c.id) == id) + .map(|c| c.name.as_str()) + .unwrap_or(id); + line.push_str( + &Style::new() + .fg(CYAN) + .render(&format!(" focus:{}", truncate(label, 20))), + ); + } + if let Some(pid) = self.focused_agent_pid { + let label = self + .snapshot + .processes + .iter() + .find(|p| p.pid == pid) + .and_then(|p| p.agent.map(|agent| agent.label())) + .unwrap_or("agent"); + line.push_str( + &Style::new() + .fg(ACCENT) + .render(&format!(" agent:{label}/{pid}")), + ); + } + if let Some(focus) = &self.focused_session { + line.push_str(&Style::new().fg(ORANGE).render(&format!( + " session:{}/{}", + focus.source, + truncate(&focus.session, 18) + ))); + } pad_line(&line, self.width as usize) } fn table(&self) -> String { + if self.column_panel.is_some() { + return self.column_panel_view(); + } + if self.sort_panel.is_some() { + return self.sort_panel_view(); + } + if self.connector_panel.is_some() { + return self.connector_panel_view(); + } + if self.log.is_some() { + return self.logs_view(); + } + if self.container_menu.is_some() { + return self.container_menu_view(); + } + if self.help { + return self.help_view(); + } match self.tab { - Tab::Agents => self.process_table(self.filtered_agents(), true), - Tab::Processes => self.process_table(self.filtered_processes(), false), + Tab::Agents if self.focused_agent_pid.is_some() => { + self.agent_focus_view(self.filtered_agents()) + } + Tab::Agents => self.agents_tree_view(), + Tab::Sessions if self.focused_session.is_some() => { + self.session_focus_view(self.focused_session_events()) + } + Tab::Sessions => self.sessions_table(self.filtered_sessions()), + Tab::Processes => self.render_process_tab(), + Tab::Containers if self.focused_container.is_some() => { + self.container_focus_view(self.filtered_containers()) + } Tab::Containers => self.container_table(self.filtered_containers()), Tab::Events => self.events_table(self.filtered_events()), } } - fn process_table(&self, rows: Vec, agents_only: bool) -> String { - let title = if agents_only { - " PID AGENT CPU% MEM% RISK ELAPSED COMMAND" - } else { - " PID PPID CPU% MEM% RISK ELAPSED COMMAND" - }; - let mut out = Vec::new(); - out.push(Style::new().fg(Color::BrightBlack).render(title)); - let body = self.visible_height(); - if rows.is_empty() { - out.push( - Style::new() - .fg(Color::BrightBlack) - .italic() - .render(if agents_only { - " no coding-agent processes found" - } else { - " no processes match the current filter" - }), - ); - return out.join("\n"); - } - for (idx, row) in rows.iter().enumerate().skip(self.scroll).take(body) { - let agent = row.agent.map(|a| a.label()).unwrap_or("-"); - let first_cols = if agents_only { - format!( - " {:<7} {:<10} {:>5.1} {:>5.1} {:<4} {:<8} ", - row.pid, - agent, - row.cpu_pct, - row.mem_pct, - row.risk.label(), - row.elapsed - ) - } else { - format!( - " {:<7} {:<7} {:>5.1} {:>5.1} {:<4} {:<8} ", - row.pid, - row.ppid, - row.cpu_pct, - row.mem_pct, - row.risk.label(), - row.elapsed - ) - }; - let cmd_width = (self.width as usize) - .saturating_sub(first_cols.len()) - .max(16); - let raw = format!("{first_cols}{}", truncate(&row.command, cmd_width)); - let color = row - .agent - .map(|a| a.color()) - .unwrap_or_else(|| row.risk.color()); - out.push(self.style_row(idx, &raw, color)); - } - out.join("\n") + fn agents_tree_view(&self) -> String { + let root = self.agents_tree_root(); + Tree::new(root) + .branch_color(ACCENT) + .leaf_color(Color::BrightBlack) + .view(self.width, self.visible_height() + 2) } - fn container_table(&self, rows: Vec) -> String { - let mut out = Vec::new(); - out.push(Style::new().fg(Color::BrightBlack).render( - " CONTAINER CPU% MEM NET I/O BLOCK I/O PIDS STATUS / IMAGE", - )); - let body = self.visible_height(); - if rows.is_empty() { - out.push( - Style::new() - .fg(Color::BrightBlack) - .italic() - .render(" no running containers found or Docker is unavailable"), - ); - return out.join("\n"); - } - for (idx, row) in rows.iter().enumerate().skip(self.scroll).take(body) { - let name = truncate(&row.name, 15); - let cpu = row - .cpu_pct - .map(|v| format!("{v:.1}")) - .unwrap_or_else(|| "-".to_string()); - let prefix = format!( - " {:<15} {:>5} {:<16} {:<16} {:<16} {:>4} ", - name, - cpu, - truncate(&row.mem_usage, 16), - truncate(&row.net_io, 16), - truncate(&row.block_io, 16), - truncate(&row.pids, 4) - ); - let tail = format!("{} · {}", row.status, row.image); - let raw = format!( - "{prefix}{}", - truncate(&tail, (self.width as usize).saturating_sub(prefix.len())) - ); - out.push(self.style_row(idx, &raw, CYAN)); + fn agents_tree_root(&self) -> TreeNode { + let groups = self.agent_tree_groups(); + let mut children = groups + .iter() + .enumerate() + .map(|(idx, group)| self.agent_tree_agent_node(group, idx == self.selected)) + .collect::>(); + + if children.is_empty() { + children.push(TreeNode::leaf( + "no coding-agent activity matched the current filters", + )); } - out.join("\n") + + TreeNode::branch(self.agent_tree_summary_label(&groups), children) } - fn events_table(&self, rows: Vec) -> String { - let mut out = Vec::new(); - out.push( - Style::new() - .fg(Color::BrightBlack) - .render(" TIME SOURCE KIND RISK MESSAGE"), - ); - let body = self.visible_height(); - if rows.is_empty() { - out.push( - Style::new() - .fg(Color::BrightBlack) - .italic() - .render(" no observer events yet · set A3S_TOP_OBSERVER_LOG to an NDJSON file"), - ); - return out.join("\n"); - } - for (idx, row) in rows.iter().enumerate().skip(self.scroll).take(body) { - let prefix = format!( - " {:<9} {:<13} {:<13} {:<4} ", - truncate(&row.ts, 9), - truncate(&row.source, 13), - truncate(&row.kind, 13), - row.risk.label() - ); - let raw = format!( - "{prefix}{}", - truncate( - &row.message, - (self.width as usize).saturating_sub(prefix.len()) + fn agent_tree_groups(&self) -> Vec { + let sessions = self.filtered_sessions(); + let processes = self.filtered_agents(); + let events = self.filtered_events(); + let mut groups = AgentKind::ALL + .iter() + .copied() + .filter_map(|agent| { + let sessions = sessions + .iter() + .filter(|session| agent_matches_source(agent, &session.source)) + .cloned() + .collect::>(); + let processes = processes + .iter() + .filter(|process| process.agent == Some(agent)) + .cloned() + .collect::>(); + let events = events + .iter() + .filter(|event| agent_matches_source(agent, &event.source)) + .cloned() + .collect::>(); + if !self.filter.is_empty() + && sessions.is_empty() + && processes.is_empty() + && events.is_empty() + { + return None; + } + let activity = self.agent_activity_from_events(events.iter()); + let usage = self.agent_tree_usage(&processes); + Some(AgentTreeGroup { + agent, + sessions, + processes, + events, + activity, + usage, + }) + }) + .collect::>(); + groups.sort_by(|a, b| { + b.active() + .cmp(&a.active()) + .then(risk_rank(b.risk()).cmp(&risk_rank(a.risk()))) + .then( + b.usage + .cpu_pct + .partial_cmp(&a.usage.cpu_pct) + .unwrap_or(std::cmp::Ordering::Equal), ) - ); - out.push(self.style_row(idx, &raw, row.risk.color())); + .then(b.activity.total_tokens.cmp(&a.activity.total_tokens)) + .then(b.events.len().cmp(&a.events.len())) + .then(agent_order(a.agent).cmp(&agent_order(b.agent))) + }); + groups + } + + fn agent_tree_summary_label(&self, groups: &[AgentTreeGroup]) -> String { + let active = groups.iter().filter(|group| group.active()).count(); + let sessions = groups + .iter() + .map(|group| group.sessions.len()) + .sum::(); + let processes = groups + .iter() + .map(|group| self.agent_system_processes(group).len()) + .sum::(); + let events = groups.iter().map(|group| group.events.len()).sum::(); + let high = groups + .iter() + .map(|group| group.activity.high_risk) + .sum::(); + let tokens = groups + .iter() + .map(|group| group.activity.total_tokens) + .sum::(); + format!( + "Agents · active {active}/{} · sessions {sessions} · processes {processes} · events {events} · high {high} · tok {}", + groups.len(), + format_count(tokens) + ) + } + + fn agent_tree_agent_node(&self, group: &AgentTreeGroup, selected: bool) -> TreeNode { + let activity = &group.activity; + let usage = group.usage; + let state = agent_tree_state_label(group); + let system_processes = self.agent_system_processes(group); + let prefix = if selected { "> " } else { " " }; + let label = agent_tree_label( + group.agent, + format!( + "{prefix}{} [{state}] · S{} P{} E{} · CPU {:.1}% · MEM {:.1}% · TOK {}", + group.agent.label(), + group.sessions.len(), + system_processes.len(), + group.events.len(), + usage.cpu_pct, + usage.mem_pct, + format_count(activity.total_tokens) + ), + ); + TreeNode::branch( + label, + vec![ + self.agent_tree_resources_node(group), + self.agent_tree_sessions_node(group.agent, &group.sessions), + self.agent_tree_processes_node( + group.agent, + &system_processes, + &group.processes, + selected, + ), + self.agent_tree_events_node(group.agent, &group.events), + ], + ) + } + + fn agent_tree_resources_node(&self, group: &AgentTreeGroup) -> TreeNode { + let history = self.agent_group_history(&group.processes); + let activity = &group.activity; + let mut children = vec![ + TreeNode::leaf(agent_tree_label( + group.agent, + format!( + "CPU {:>5.1}% {}", + group.usage.cpu_pct, + self.sparkline(&history.cpu, metric_color(group.usage.cpu_pct)) + ), + )), + TreeNode::leaf(agent_tree_label( + group.agent, + format!( + "MEM {:>5.1}% {}", + group.usage.mem_pct, + self.sparkline(&history.mem, metric_color(group.usage.mem_pct)) + ), + )), + TreeNode::leaf(agent_tree_label( + group.agent, + format!("children {}", group.usage.descendants), + )), + ]; + let model = display_model(&activity.model); + let provider = display_provider(&activity.provider); + let latency = format_avg_ms(activity.latency_ms, activity.latency_samples); + let ttft = format_avg_ms(activity.ttft_ms, activity.ttft_samples); + if activity.events > 0 || activity.total_tokens > 0 { + children.push(TreeNode::leaf(agent_tree_label(group.agent, format!( + "activity evt {} · tool {} · sec {} · file {} · net {} · llm {} · tok {} · model {} · provider {} · lat {} · ttft {} · high {}", + activity.events, + activity.tools, + activity.security, + activity.files, + activity.egress, + activity.llm, + format_count(activity.total_tokens), + model, + provider, + latency, + ttft, + activity.high_risk + )))); } - out.join("\n") + TreeNode::branch(agent_tree_label(group.agent, "Resources"), children) } - fn style_row(&self, idx: usize, raw: &str, color: Color) -> String { - let line = pad_plain(raw, self.width as usize); - if idx == self.selected { - Style::new().fg(Color::Black).bg(color).bold().render(&line) - } else { - Style::new().fg(color).render(&line) + fn agent_group_history(&self, processes: &[ProcessRow]) -> MetricHistory { + let mut history = MetricHistory::default(); + for process in self.agent_group_root_processes(processes) { + let process_history = self.metric_history(&agent_tree_history_key(process.pid)); + add_aligned_f32_history(&mut history.cpu, &process_history.cpu); + add_aligned_f32_history(&mut history.mem, &process_history.mem); } + history } - fn details(&self) -> String { - if !self.detail { - return String::new(); + fn agent_tree_usage(&self, processes: &[ProcessRow]) -> ProcessTreeUsage { + let mut usage = ProcessTreeUsage::default(); + for process in self.agent_group_root_processes(processes) { + let tree = self.process_tree_usage(process.pid); + usage.cpu_pct += tree.cpu_pct; + usage.mem_pct += tree.mem_pct; + usage.descendants += tree.descendants; } - let mut lines = Vec::new(); - lines.push( - Style::new() - .fg(Color::BrightBlack) - .render(&"─".repeat(self.width as usize)), - ); - match self.tab { - Tab::Agents | Tab::Processes => { - if let Some(row) = self.current_process() { - lines.push( - Style::new() - .fg(row.agent.map(|a| a.color()).unwrap_or(ACCENT)) - .bold() - .render(&format!( - " process {} · ppid {} · risk {}", - row.pid, - row.ppid, - row.risk.label() - )), - ); - lines.push(format!( - " cpu {:.1}% · mem {:.1}% · elapsed {}", - row.cpu_pct, row.mem_pct, row.elapsed - )); - lines.push(format!( - " agent {}", - row.agent.map(|a| a.label()).unwrap_or("none") - )); - lines.push(format!(" command {}", row.command)); - } - } - Tab::Containers => { - if let Some(row) = self.current_container() { - lines.push(Style::new().fg(CYAN).bold().render(&format!( - " container {} ({})", - row.name, - short_id(&row.id) - ))); - lines.push(format!(" image {}", row.image)); - lines.push(format!(" status {}", row.status)); - lines.push(format!( - " cpu {} · mem {} · net {} · block {} · pids {}", - row.cpu_pct - .map(|v| format!("{v:.1}%")) - .unwrap_or_else(|| "-".to_string()), - row.mem_usage, - row.net_io, - row.block_io, - row.pids - )); - } + usage + } + + fn agent_group_root_processes(&self, processes: &[ProcessRow]) -> Vec { + let agent_pids = processes + .iter() + .map(|process| process.pid) + .collect::>(); + processes + .iter() + .filter(|process| !self.has_ancestor_in_set(process.pid, &agent_pids)) + .cloned() + .collect() + } + + fn has_ancestor_in_set(&self, pid: u32, ancestors: &HashSet) -> bool { + let mut current = pid; + let mut visited = HashSet::new(); + while visited.insert(current) { + let Some(process) = self + .snapshot + .processes + .iter() + .find(|process| process.pid == current) + else { + return false; + }; + if ancestors.contains(&process.ppid) { + return true; } - Tab::Events => { - if let Some(row) = self.filtered_events().get(self.selected) { - lines.push(Style::new().fg(row.risk.color()).bold().render(&format!( - " event {} · {} · risk {}", - row.source, - row.kind, - row.risk.label() - ))); - lines.push(format!(" time {}", row.ts)); - lines.push(format!(" message {}", row.message)); - } + if process.ppid == 0 || process.ppid == current { + return false; } + current = process.ppid; } - lines - .into_iter() - .take(10) - .map(|line| pad_line(&line, self.width as usize)) - .collect::>() - .join("\n") + false } - fn confirm_view(&self) -> String { - let Some(action) = &self.confirm else { - return String::new(); - }; - let (title, target) = match action { - Action::KillProcess(pid, label) => { - ("Terminate process?", format!("PID {pid} · {label}")) - } - Action::StopContainer(_, name) => ("Stop container?", name.clone()), - Action::RestartContainer(_, name) => ("Restart container?", name.clone()), - }; - let width = self.width as usize; - let inner = 58.min(width.saturating_sub(4)).max(24); - let line = "─".repeat(inner); - let target = truncate(&target, inner.saturating_sub(4)); - let rows = [ - format!("┌{line}┐"), - format!("│{}│", center(title, inner)), - format!("│{}│", center("", inner)), - format!("│{}│", center(&target, inner)), - format!( - "│{}│", - center("[ y / Enter ] confirm [ n / Esc ] cancel", inner) - ), - format!("└{line}┘"), - ]; - let styled = rows + fn agent_system_processes(&self, group: &AgentTreeGroup) -> Vec { + let mut roots = self.agent_group_root_processes(&group.processes); + roots.sort_by_key(|process| process.pid); + let mut rows = Vec::new(); + let mut visited = roots .iter() - .map(|r| Style::new().fg(Color::BrightWhite).bg(RED).bold().render(r)) - .collect::>() - .join("\n"); - let pad = width.saturating_sub(inner + 2) / 2; - styled - .lines() - .map(|line| format!("{}{}", " ".repeat(pad), line)) - .collect::>() - .join("\n") + .map(|process| process.pid) + .collect::>(); + for root in roots { + self.collect_agent_system_processes(group.agent, root.pid, &mut visited, &mut rows); + } + rows } -} - -impl Model for TopApp { - type Msg = Msg; - fn init(&mut self) -> Option> { - Some(cmd::cmd(|| async { - Msg::Snapshot(collect_snapshot().await) - })) - } + fn collect_agent_system_processes( + &self, + agent: AgentKind, + parent_pid: u32, + visited: &mut HashSet, + rows: &mut Vec, + ) { + let mut children = self + .snapshot + .processes + .iter() + .filter(|process| process.ppid == parent_pid) + .cloned() + .collect::>(); + children.sort_by_key(|process| process.pid); - fn update(&mut self, msg: Msg) -> Option> { - match msg { - Msg::Snapshot(snapshot) => { - self.snapshot = snapshot; - self.last_refresh = Some(Instant::now()); - self.clamp_selection(); - Some(cmd::tick(self.interval, Msg::Tick)) + for child in children { + if !visited.insert(child.pid) { + continue; } - Msg::Tick => { - if self.paused { - Some(cmd::tick(self.interval, Msg::Tick)) - } else { - Some(cmd::cmd(|| async { - Msg::Snapshot(collect_snapshot().await) - })) + match child.agent { + Some(child_agent) if child_agent != agent => continue, + Some(_) => { + self.collect_agent_system_processes(agent, child.pid, visited, rows); + } + None => { + rows.push(child.clone()); + self.collect_agent_system_processes(agent, child.pid, visited, rows); } } - Msg::ActionDone(note) => { - self.note = Some(note); - Some(cmd::cmd(|| async { - Msg::Snapshot(collect_snapshot().await) - })) - } - Msg::Term(Event::Resize { width, height }) => { - self.width = width; - self.height = height; - self.clamp_selection(); - None - } - Msg::Term(Event::Key(key)) => self.handle_key(key), - Msg::Term(_) => None, } } - fn view(&self) -> String { - let mut body = vec![self.header(), self.tabs(), self.table()]; - let details = self.details(); - if !details.is_empty() { - body.push(details); + fn agent_tree_sessions_node(&self, agent: AgentKind, sessions: &[SessionRow]) -> TreeNode { + let mut children = sessions + .iter() + .take(AGENTS_TREE_SESSION_LIMIT) + .map(|session| { + let latency = format_avg_ms(session.latency_ms, session.latency_samples); + TreeNode::leaf(agent_tree_label(agent, format!( + "{} · task {} · cwd {} · evt {} · tools {} · llm {} · tok {} · model {} · lat {} · risk {}", + session.session, + session.task, + display_workspace(Some(&session.workspace)), + session.events, + session.tools, + session.llm, + format_count(session.total_tokens), + display_model(&session.model), + latency, + session.risk.label() + ))) + }) + .collect::>(); + push_agent_more_leaf( + &mut children, + sessions.len(), + AGENTS_TREE_SESSION_LIMIT, + "sessions", + agent, + ); + if children.is_empty() { + children.push(TreeNode::leaf(agent_tree_label(agent, "no sessions"))); } - if let Some(note) = &self.note { - body.push(Style::new().fg(YELLOW).render(&format!(" {note}"))); - } else if !self.snapshot.errors.is_empty() { - body.push( - Style::new() - .fg(YELLOW) - .render(&format!(" {}", self.snapshot.errors.join(" · "))), - ); + TreeNode::branch( + agent_tree_label(agent, format!("Sessions ({})", sessions.len())), + children, + ) + } + + fn agent_tree_processes_node( + &self, + agent: AgentKind, + system_processes: &[ProcessRow], + agent_processes: &[ProcessRow], + selected_group: bool, + ) -> TreeNode { + let mut children = system_processes + .iter() + .enumerate() + .take(AGENTS_TREE_PROCESS_LIMIT) + .map(|(idx, process)| { + let marker = if selected_group && idx == 0 { + "> " + } else { + " " + }; + TreeNode::leaf(agent_tree_label( + agent, + format!( + "{marker}pid {} · ppid {} · CPU {:.1}% · MEM {:.1}% · {}", + process.pid, + process.ppid, + process.cpu_pct, + process.mem_pct, + display_cmd(&process.command) + ), + )) + }) + .collect::>(); + push_agent_more_leaf( + &mut children, + system_processes.len(), + AGENTS_TREE_PROCESS_LIMIT, + "processes", + agent, + ); + if children.is_empty() { + children.push(TreeNode::leaf(agent_tree_label( + agent, + "no system processes from this agent", + ))); } + TreeNode::branch( + agent_tree_label( + agent, + format!( + "Processes ({} system · {} agent)", + system_processes.len(), + agent_processes.len() + ), + ), + children, + ) + } - let main = body.join("\n"); - let help = if self.editing_filter { - "type filter · Enter apply · Esc clear" - } else { - "Tab switch · / filter · s sort · Enter detail · K stop/kill · r restart · q quit" + fn agent_tree_events_node(&self, agent: AgentKind, events: &[EventRow]) -> TreeNode { + let mut children = events + .iter() + .take(AGENTS_TREE_EVENT_LIMIT) + .map(|event| { + TreeNode::leaf(agent_tree_label( + agent, + format!( + "{} · {} · {} · {} · {}", + event.ts, + event.kind, + event.risk.label(), + event_scope_label(event), + event.message + ), + )) + }) + .collect::>(); + push_agent_more_leaf( + &mut children, + events.len(), + AGENTS_TREE_EVENT_LIMIT, + "events", + agent, + ); + if children.is_empty() { + children.push(TreeNode::leaf(agent_tree_label(agent, "no recent events"))); + } + TreeNode::branch( + agent_tree_label(agent, format!("Events ({})", events.len())), + children, + ) + } + + /// Render the Processes tab via the shared process-table renderer, feeding + /// it this app's live CPU/MEM history for the sparkline columns. + fn render_process_tab(&self) -> String { + let rows = self.filtered_processes(); + let history = |pid: u32| { + let h = self.metric_history(&process_history_key(pid)); + (h.cpu, h.mem) }; - let status = StatusBar::new() - .left(format!( - " {} · sort:{} · {} rows", - self.tab.label(), - self.sort_by.label(), - self.visible_len() - )) - .center(help) - .right(if self.paused { "paused" } else { "live" }) - .fg(Color::BrightWhite) - .bg(Color::Rgb(35, 40, 60)) - .view(self.width); + render_process_table( + &rows, + &ProcessTableView { + selected: self.selected, + scroll: self.scroll, + width: self.width, + height: self.visible_height() + 2, + hidden: &self.hidden_columns, + history: Some(&history), + }, + ) + } - let mut screen = Layout::vertical() - .item(&main, Constraint::Fill) - .item(&status, Constraint::Fixed(1)) - .render(self.height); + fn sessions_table(&self, rows: Vec) -> String { + let mut table = DataTable::new(vec![ + self.configured_column("sessions.source", DataColumn::new("AGENT").width(12)), + self.configured_column("sessions.session", DataColumn::new("SESSION").width(14)), + self.configured_column("sessions.task", DataColumn::new("TASK").width(12)), + self.configured_column("sessions.workspace", DataColumn::new("CWD").width(18)), + self.configured_column( + "sessions.events", + DataColumn::new("EVT").width(5).align(CellAlign::Right), + ), + self.configured_column( + "sessions.tools", + DataColumn::new("TOOL").width(5).align(CellAlign::Right), + ), + self.configured_column( + "sessions.security", + DataColumn::new("SEC").width(5).align(CellAlign::Right), + ), + self.configured_column( + "sessions.files", + DataColumn::new("FILE").width(5).align(CellAlign::Right), + ), + self.configured_column( + "sessions.egress", + DataColumn::new("NET").width(5).align(CellAlign::Right), + ), + self.configured_column( + "sessions.llm", + DataColumn::new("LLM").width(5).align(CellAlign::Right), + ), + self.configured_column( + "sessions.tokens", + DataColumn::new("TOK").width(7).align(CellAlign::Right), + ), + self.configured_column("sessions.model", DataColumn::new("MODEL").width(14)), + self.configured_column("sessions.provider", DataColumn::new("PROV").width(10)), + self.configured_column( + "sessions.latency", + DataColumn::new("LAT").width(7).align(CellAlign::Right), + ), + self.configured_column( + "sessions.high_risk", + DataColumn::new("HIGH").width(5).align(CellAlign::Right), + ), + self.configured_column("sessions.risk", DataColumn::new("RISK").width(5)), + self.configured_column("sessions.kind", DataColumn::new("LAST").width(12)), + self.configured_column("sessions.message", DataColumn::new("MESSAGE").min_width(18)), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .selected((!rows.is_empty()).then_some(self.selected)) + .scroll(self.scroll) + .empty("no agent sessions yet - auto-discovery watches Claude, Codex, and A3S Code logs"); - let confirm = self.confirm_view(); - if !confirm.is_empty() { - screen.push('\n'); - screen.push_str(&confirm); + for row in rows { + table.add_row( + DataRow::new(vec![ + row.source, + row.session, + row.task, + row.workspace, + row.events.to_string(), + row.tools.to_string(), + row.security.to_string(), + row.files.to_string(), + row.egress.to_string(), + row.llm.to_string(), + format_count(row.total_tokens), + display_model(&row.model), + display_provider(&row.provider), + format_avg_ms(row.latency_ms, row.latency_samples), + row.high_risk.to_string(), + row.risk.label().to_string(), + row.last_kind, + row.last_message, + ]) + .fg(row.risk.color()), + ); } - screen + table.view(self.width, self.visible_height() + 2) } -} -impl TopApp { - fn handle_key(&mut self, key: KeyEvent) -> Option> { - if self.editing_filter { - return self.handle_filter_key(key); - } - if self.confirm.is_some() { - return self.handle_confirm_key(key); - } + fn session_focus_view(&self, events: Vec) -> String { + let width = self.width as usize; + let Some(focus) = &self.focused_session else { + return String::new(); + }; - let action = self.keymap.resolve(&key); - match action { - Some(TopKey::Quit) => Some(cmd::quit()), - Some(TopKey::Up) => { - self.selected = self.selected.saturating_sub(1); - self.clamp_selection(); - None - } - Some(TopKey::Down) => { - self.selected = (self.selected + 1).min(self.visible_len().saturating_sub(1)); - self.clamp_selection(); - None - } - Some(TopKey::PageUp) => { - self.selected = self.selected.saturating_sub(10); - self.clamp_selection(); - None - } - Some(TopKey::PageDown) => { - self.selected = (self.selected + 10).min(self.visible_len().saturating_sub(1)); - self.clamp_selection(); - None - } - Some(TopKey::NextTab) => { - self.tab = self.tab.next(); - self.reset_position(); - None - } - Some(TopKey::PrevTab) => { - self.tab = self.tab.prev(); - self.reset_position(); - None - } - Some(TopKey::Filter) => { - self.editing_filter = true; - None - } - Some(TopKey::Sort) => { - self.sort_by = self.sort_by.next(); - self.clamp_selection(); - None - } - Some(TopKey::TogglePause) => { - self.paused = !self.paused; - None - } - Some(TopKey::Detail) => { - self.detail = !self.detail; - None - } - Some(TopKey::Kill) => { - self.confirm = self.selected_action(false); - None - } - Some(TopKey::Restart) => { - if self.tab == Tab::Containers { - self.confirm = self.selected_action(true); - } - None - } - None => None, + let row = self.focused_session_row(); + let risk = row.as_ref().map(|row| row.risk).unwrap_or(Risk::Low); + let mut out = Vec::new(); + out.push(Style::new().fg(risk.color()).bold().render(&pad_plain( + &format!(" session view {} · {}", focus.source, focus.session), + width, + ))); + if let Some(row) = &row { + out.push(Style::new().fg(Color::BrightBlack).render(&pad_plain( + &format!( + " task {} · cwd {} · events {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · ttft {} · wire {} / {} · high {} · risk {}", + row.task, + row.workspace, + row.events, + row.tools, + row.security, + row.files, + row.egress, + row.llm, + format_count(row.total_tokens), + display_model(&row.model), + display_provider(&row.provider), + format_avg_ms(row.latency_ms, row.latency_samples), + format_avg_ms(row.ttft_ms, row.ttft_samples), + format_bytes(row.req_bytes), + format_bytes(row.resp_bytes), + row.high_risk, + row.risk.label() + ), + width, + ))); + } else { + out.push( + Style::new() + .fg(Color::BrightBlack) + .italic() + .render(&pad_plain(" focused session has no events", width)), + ); + } + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + + let mut table = DataTable::new(vec![ + DataColumn::new("TIME").width(9), + DataColumn::new("KIND").width(12), + DataColumn::new("RISK").width(5), + DataColumn::new("TASK").width(12), + DataColumn::new("PID").width(7).align(CellAlign::Right), + DataColumn::new("PPID").width(7).align(CellAlign::Right), + DataColumn::new("MESSAGE").min_width(18), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .selected((!events.is_empty()).then_some(self.selected)) + .scroll(self.scroll) + .empty("no events match this session"); + + for event in events { + table.add_row( + DataRow::new(vec![ + event.ts, + event.kind, + event.risk.label().to_string(), + event.task.unwrap_or_else(|| "-".to_string()), + format_event_pid(event.pid), + format_event_pid(event.ppid), + event.message, + ]) + .fg(event.risk.color()), + ); } + + let height = self.visible_height() + 2; + let table_height = height.saturating_sub(out.len()).max(3); + out.push(table.view(self.width, table_height)); + out.join("\n") + .lines() + .take(height) + .map(|line| pad_line(line, width)) + .collect::>() + .join("\n") } - fn handle_filter_key(&mut self, key: KeyEvent) -> Option> { - match key.code { - KeyCode::Esc => { - self.filter.clear(); - self.editing_filter = false; - self.reset_position(); - } - KeyCode::Enter => { - self.editing_filter = false; - self.reset_position(); + fn agent_focus_view(&self, rows: Vec) -> String { + let width = self.width as usize; + let Some(row) = rows.first() else { + return Style::new() + .fg(Color::BrightBlack) + .italic() + .render(&pad_plain(" focused agent is no longer present", width)); + }; + let agent = row.agent.unwrap_or(AgentKind::A3sCode); + let color = agent.color(); + let usage = self.process_tree_usage(row.pid); + let history = self.metric_history(&agent_tree_history_key(row.pid)); + let activity = self.agent_activity_for_process(row); + let mut out = Vec::new(); + + out.push(Style::new().fg(color).bold().render(&pad_plain( + &format!(" agent view {} · pid {}", agent.label(), row.pid), + width, + ))); + out.push(Style::new().fg(Color::BrightBlack).render(&pad_plain( + &format!( + " ppid {} · elapsed {} · children {} · subtree cpu {:.1}% mem {:.1}% · risk {} · cwd {}", + row.ppid, + row.elapsed, + usage.descendants, + usage.cpu_pct, + usage.mem_pct, + row.risk.label(), + display_workspace(row.cwd.as_deref()) + ), + width, + ))); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.push( + Meter::new(usage.cpu_pct as f64) + .label("CPU") + .width(width) + .fg(metric_color(usage.cpu_pct)) + .view(), + ); + out.push(self.focus_trend_line("CPU trend", &history.cpu, metric_color(usage.cpu_pct))); + out.push( + Meter::new(usage.mem_pct as f64) + .label("MEM") + .width(width) + .fg(metric_color(usage.mem_pct)) + .view(), + ); + out.push(self.focus_trend_line("MEM trend", &history.mem, metric_color(usage.mem_pct))); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.push(pad_plain( + &format!( + " activity events {} · sessions {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · ttft {} · wire {} / {} · high {}", + activity.events, + activity.sessions, + activity.tools, + activity.security, + activity.files, + activity.egress, + activity.llm, + format_count(activity.total_tokens), + display_model(&activity.model), + display_provider(&activity.provider), + format_avg_ms(activity.latency_ms, activity.latency_samples), + format_avg_ms(activity.ttft_ms, activity.ttft_samples), + format_bytes(activity.req_bytes), + format_bytes(activity.resp_bytes), + activity.high_risk + ), + width, + )); + let height = self.visible_height() + 2; + let recent_events = self.recent_agent_events_for_process(row); + if !recent_events.is_empty() { + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + let event_height = recent_events.len().saturating_add(2).min(5); + out.extend( + self.agent_events_table(&recent_events, event_height) + .lines() + .map(ToString::to_string), + ); + } + out.push(pad_plain(&format!(" command {}", row.command), width)); + + let remaining = height.saturating_sub(out.len()); + if remaining > 7 { + let reserve_tree = self.process_tree(row.pid).map_or(0, |_| 4); + let session_height = remaining.saturating_sub(1 + reserve_tree).min(7); + if session_height >= 3 { + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + let sessions = agent_session_rows(self, row); + out.extend( + self.agent_sessions_table(&sessions, session_height) + .lines() + .map(ToString::to_string), + ); } - KeyCode::Backspace => { - self.filter.pop(); - self.reset_position(); + } + + let remaining = height.saturating_sub(out.len()); + if remaining > 3 { + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + if let Some(tree) = self.process_tree(row.pid) { + let tree = Tree::new(tree) + .branch_color(color) + .leaf_color(Color::BrightBlack) + .view(self.width, remaining.saturating_sub(1)); + out.extend(tree.lines().map(ToString::to_string)); } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - self.filter.push(c); - self.reset_position(); + } + + out.into_iter() + .take(height) + .map(|line| pad_line(&line, width)) + .collect::>() + .join("\n") + } + + fn agent_sessions_table(&self, sessions: &[SessionRow], height: usize) -> String { + let mut table = DataTable::new(vec![ + DataColumn::new("SESSION").width(14), + DataColumn::new("TASK").width(12), + DataColumn::new("EVT").width(5).align(CellAlign::Right), + DataColumn::new("SEC").width(5).align(CellAlign::Right), + DataColumn::new("LLM").width(5).align(CellAlign::Right), + DataColumn::new("TOK").width(7).align(CellAlign::Right), + DataColumn::new("RISK").width(5), + DataColumn::new("LAST").min_width(16), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .empty("no sessions matched this agent"); + + for row in sessions { + table.add_row( + DataRow::new(vec![ + row.session.clone(), + row.task.clone(), + row.events.to_string(), + row.security.to_string(), + row.llm.to_string(), + format_count(row.total_tokens), + row.risk.label().to_string(), + format!("{} {}", row.last_kind, row.last_message), + ]) + .fg(row.risk.color()), + ); + } + + table.view(self.width, height) + } + + fn agent_events_table(&self, events: &[EventRow], height: usize) -> String { + let mut table = DataTable::new(vec![ + DataColumn::new("TIME").width(9), + DataColumn::new("KIND").width(12), + DataColumn::new("RISK").width(5), + DataColumn::new("SESSION").width(12), + DataColumn::new("TASK").width(12), + DataColumn::new("PID").width(7).align(CellAlign::Right), + DataColumn::new("MESSAGE").min_width(18), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .empty("no recent events matched this agent"); + + for event in events { + table.add_row( + DataRow::new(vec![ + event.ts.clone(), + event.kind.clone(), + event.risk.label().to_string(), + event.session.clone().unwrap_or_else(|| "-".to_string()), + event.task.clone().unwrap_or_else(|| "-".to_string()), + format_event_pid(event.pid), + event.message.clone(), + ]) + .fg(event.risk.color()), + ); + } + + table.view(self.width, height) + } + + fn container_table(&self, rows: Vec) -> String { + let mut table = DataTable::new(vec![ + self.configured_container_column( + "containers.status", + DataColumn::new("STATUS") + .width(9) + .min_width(7) + .priority(100), + ), + self.configured_container_column( + "containers.name", + DataColumn::new("NAME").width(16).min_width(8).priority(98), + ), + self.configured_container_column( + "containers.id", + DataColumn::new("CID").width(12).min_width(12).priority(99), + ), + self.configured_container_column( + "containers.cpu", + DataColumn::new("CPU") + .width(15) + .min_width(15) + .align(CellAlign::Right) + .priority(97), + ), + self.configured_container_column( + "containers.cpus", + DataColumn::new("CPUS") + .width(15) + .min_width(15) + .align(CellAlign::Right) + .priority(40), + ), + self.configured_container_column( + "containers.mem", + DataColumn::new("MEM") + .width(15) + .min_width(15) + .align(CellAlign::Right) + .priority(96), + ), + self.configured_container_column( + "containers.net", + DataColumn::new("NET I/O") + .width(17) + .min_width(11) + .priority(80), + ), + self.configured_container_column( + "containers.block", + DataColumn::new("IO").width(17).min_width(9).priority(79), + ), + self.configured_container_column( + "containers.pids", + DataColumn::new("PIDS") + .width(5) + .align(CellAlign::Right) + .priority(70), + ), + self.configured_container_column( + "containers.uptime", + DataColumn::new("UPTIME") + .width(14) + .min_width(8) + .priority(60), + ), + self.configured_container_column( + "containers.image", + DataColumn::new("IMAGE").width(18).priority(30), + ), + self.configured_container_column( + "containers.mem_usage", + DataColumn::new("MEM USAGE").width(17).priority(25), + ), + self.configured_container_column( + "containers.ports", + DataColumn::new("PORTS").width(24).priority(20), + ), + self.configured_container_column( + "containers.health", + DataColumn::new("HEALTH").width(10).priority(10), + ), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .selected((!rows.is_empty()).then_some(self.selected)) + .scroll(self.scroll) + .empty(if self.focused_container.is_some() { + "focused container is no longer present".to_string() + } else if self.show_all_containers { + format!( + "no containers found or {} is unavailable", + self.connector.label() + ) + } else { + format!( + "no running containers found or {} is unavailable", + self.connector.label() + ) + }); + + for row in rows { + let history = self.metric_history(&container_history_key(&row.id)); + let status = container_state_label(&row.status); + let state_color = container_state_color(&status); + let uptime = container_uptime_label(&row); + let cid = short_id(&row.id).to_string(); + table.add_row( + DataRow::new(vec![ + status, + row.name, + cid, + self.metric_cell(row.cpu_pct, &history.cpu, CYAN), + self.scaled_cpu_metric_cell(row.cpu_pct, &history.cpu, row.cpu_count), + self.metric_cell(row.mem_pct, &history.mem, YELLOW), + row.net_io, + row.block_io, + row.pids, + uptime, + row.image, + row.mem_usage, + display_ports(&row.ports), + row.inspect.health, + ]) + .cell_fg(0, state_color), + ); + } + table.view(self.width, self.visible_height() + 2) + } + + fn container_focus_view(&self, rows: Vec) -> String { + let width = self.width as usize; + let Some(row) = rows.first() else { + return Style::new() + .fg(Color::BrightBlack) + .italic() + .render(&pad_plain(" focused container is no longer present", width)); + }; + + let history = self.metric_history(&container_history_key(&row.id)); + let cpu = row.cpu_pct.unwrap_or_default(); + let mem = row.mem_pct.unwrap_or_default(); + let cpu_color = metric_color(cpu); + let mem_color = metric_color(mem); + let mut out = Vec::new(); + + out.push(Style::new().fg(CYAN).bold().render(&pad_plain( + &format!(" single view {} ({})", row.name, short_id(&row.id)), + width, + ))); + out.push(Style::new().fg(Color::BrightBlack).render(&pad_plain( + &format!( + " status {} · health {} · image {}", + row.status, row.inspect.health, row.image + ), + width, + ))); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.push( + Meter::new(cpu as f64) + .label("CPU") + .width(width) + .fg(cpu_color) + .view(), + ); + out.push(self.focus_trend_line("CPU trend", &history.cpu, cpu_color)); + out.push( + Meter::new(mem as f64) + .label("MEM") + .width(width) + .fg(mem_color) + .view(), + ); + out.push(self.focus_trend_line("MEM trend", &history.mem, mem_color)); + out.push(self.focus_bytes_trend_line( + "NET trend", + container_net_total(row), + &history.net_io_bytes, + GREEN, + )); + out.push(self.focus_bytes_trend_line( + "IO trend", + container_block_total(row), + &history.block_io_bytes, + ORANGE, + )); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.extend( + self.container_resource_table(row, 9) + .lines() + .map(ToString::to_string), + ); + + let height = self.visible_height() + 2; + let remaining = height.saturating_sub(out.len()); + if remaining > 6 { + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + let inspect_height = remaining.saturating_sub(1).min(8); + out.extend( + self.container_inspect_table(row, inspect_height) + .lines() + .map(ToString::to_string), + ); + } + + let remaining = height.saturating_sub(out.len()); + if remaining > 4 { + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.extend( + self.container_processes_table(row, remaining.saturating_sub(1)) + .lines() + .map(ToString::to_string), + ); + } + + out.into_iter() + .take(height) + .map(|line| pad_line(&line, width)) + .collect::>() + .join("\n") + } + + fn container_inspect_table(&self, container: &ContainerRow, height: usize) -> String { + let inspect = &container.inspect; + let rows = [ + ("HEALTH", inspect.health.as_str()), + ("RESTARTS", inspect.restarts.as_str()), + ("RESTART POLICY", inspect.restart_policy.as_str()), + ("CREATED", inspect.created.as_str()), + ("STARTED", inspect.started.as_str()), + ("EXIT", inspect.exit.as_str()), + ("MOUNTS", inspect.mounts.as_str()), + ("NETWORKS", inspect.networks.as_str()), + ("ENV", inspect.env.as_str()), + ("LABELS", inspect.labels.as_str()), + ]; + let mut table = DataTable::new(vec![ + DataColumn::new("INSPECT").width(14), + DataColumn::new("VALUE").min_width(18), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .empty("inspect metadata unavailable"); + + for (key, value) in rows { + if value != "-" { + table.add_row(DataRow::new(vec![key.to_string(), value.to_string()])); } - _ => {} } - None + + table.view(self.width, height) } - fn handle_confirm_key(&mut self, key: KeyEvent) -> Option> { - match key.code { - KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { - self.confirm = None; - None + fn container_resource_table(&self, container: &ContainerRow, height: usize) -> String { + let cpu_count = container + .cpu_count + .map(|cpus| cpus.to_string()) + .unwrap_or_else(|| "-".to_string()); + let rows = vec![ + ("MEMORY".to_string(), container.mem_usage.clone()), + ("NET I/O".to_string(), container.net_io.clone()), + ("IO".to_string(), container.block_io.clone()), + ("PIDS".to_string(), container.pids.clone()), + ("CPUS".to_string(), cpu_count), + ("UPTIME".to_string(), container_uptime_label(container)), + ("PORTS".to_string(), display_ports(&container.ports)), + ]; + let mut table = DataTable::new(vec![ + DataColumn::new("RESOURCE").width(10), + DataColumn::new("VALUE").min_width(18), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .empty("resource metadata unavailable"); + + for (key, value) in rows { + table.add_row(DataRow::new(vec![key, value])); + } + + table.view(self.width, height) + } + + fn container_processes_table(&self, container: &ContainerRow, height: usize) -> String { + let panel = self + .container_processes + .as_ref() + .filter(|panel| panel.container_id == container.id); + let empty = match panel { + Some(panel) if panel.loading && panel.rows.is_empty() => { + format!("loading processes for {}", panel.container_name) } - KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { - let action = self.confirm.take()?; - Some(cmd::cmd(move || async move { - Msg::ActionDone(run_action(action).await) - })) + Some(panel) if panel.error.is_some() && panel.rows.is_empty() => format!( + "container processes unavailable: {}", + panel.error.as_deref().unwrap_or("unknown error") + ), + Some(_) => "no processes returned for this container".to_string(), + None => "loading container processes...".to_string(), + }; + let mut table = DataTable::new(vec![ + DataColumn::new("PID").width(7).align(CellAlign::Right), + DataColumn::new("PPID").width(7).align(CellAlign::Right), + DataColumn::new("CPU%").width(6).align(CellAlign::Right), + DataColumn::new("MEM%").width(6).align(CellAlign::Right), + DataColumn::new("ELAPSED").width(9), + DataColumn::new("COMMAND").min_width(18), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .scroll(panel.map(|panel| panel.scroll).unwrap_or_default()) + .empty(empty); + + if let Some(panel) = panel { + for row in &panel.rows { + table.add_row(DataRow::new(vec![ + row.pid.clone(), + row.ppid.clone(), + format_optional_pct(row.cpu_pct), + format_optional_pct(row.mem_pct), + row.elapsed.clone(), + row.command.clone(), + ])); } - _ => None, } + + table.view(self.width, height) } -} -#[derive(Debug, Clone)] -struct TopOptions { - tab: Tab, - interval: Duration, -} + fn focus_trend_line(&self, label: &str, values: &[f32], color: Color) -> String { + let width = self.width as usize; + let prefix = format!(" {label:<9} "); + let chart_width = width + .saturating_sub(a3s_tui::style::visible_len(&prefix)) + .max(1); + let chart = Sparkline::new(values.iter().copied().map(f64::from)) + .width(chart_width) + .range(0.0, 100.0) + .fg(color) + .view(); + format!("{prefix}{chart}") + } -impl Default for TopOptions { - fn default() -> Self { - Self { + fn focus_bytes_trend_line( + &self, + label: &str, + value: u64, + values: &[f64], + color: Color, + ) -> String { + let width = self.width as usize; + let value_label = format_bytes(value); + let prefix = format!(" {label:<9} {value_label:>10} "); + let chart_width = width + .saturating_sub(a3s_tui::style::visible_len(&prefix)) + .max(1); + let max = values.iter().copied().fold(value as f64, f64::max).max(1.0); + let chart = Sparkline::new(values.iter().copied()) + .width(chart_width) + .range(0.0, max) + .fg(color) + .view(); + format!("{prefix}{chart}") + } + + fn column_panel_view(&self) -> String { + let Some(panel) = &self.column_panel else { + return String::new(); + }; + let width = self.width as usize; + let mut out = Vec::new(); + out.push(Style::new().fg(ACCENT).bold().render(&pad_plain( + &format!(" columns · {}", panel.tab.label()), + width, + ))); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.push(panel.select.view(self.width, self.visible_height())); + out.join("\n") + } + + fn logs_view(&self) -> String { + let Some(log) = &self.log else { + return String::new(); + }; + let width = self.width as usize; + let mut out = Vec::new(); + let state = if log.loading { + "loading" + } else if log.refreshing { + "refreshing" + } else { + "tail 200" + }; + let timestamps = if log.timestamps { + "timestamps:on" + } else { + "timestamps:off" + }; + let follow = if log.follow { + "follow:on" + } else { + "follow:off" + }; + out.push(Style::new().fg(CYAN).bold().render(&pad_plain( + &format!( + " logs {} ({}) · {state} · {timestamps} · {follow}", + log.container_name, + short_id(&log.container_id) + ), + width, + ))); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + + let height = self.log_visible_height(); + let lines = log.text.lines().collect::>(); + if log.loading { + out.push( + Style::new() + .fg(Color::BrightBlack) + .italic() + .render(" loading container logs..."), + ); + } else if lines.is_empty() { + out.push( + Style::new() + .fg(Color::BrightBlack) + .italic() + .render(" no logs returned for this container"), + ); + } else { + for line in lines.iter().skip(log.scroll).take(height) { + out.push(pad_plain(line.trim_end_matches('\r'), width)); + } + } + out.join("\n") + } + + fn container_menu_view(&self) -> String { + let Some(menu) = &self.container_menu else { + return String::new(); + }; + let width = self.width as usize; + let container = &menu.container; + let mut out = Vec::new(); + out.push(Style::new().fg(CYAN).bold().render(&pad_plain( + &format!( + " container menu {} ({})", + container.name, + short_id(&container.id) + ), + width, + ))); + out.push(Style::new().fg(Color::BrightBlack).render(&pad_plain( + &format!(" image {} · status {}", container.image, container.status), + width, + ))); + out.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + ); + out.push(menu.select.view(self.width, self.visible_height())); + out.join("\n") + } + + fn help_view(&self) -> String { + let rows = [ + ("Tab / Shift+Tab", "switch tabs: agents and containers"), + ("↑/↓ or j/k", "select row"), + ("Home / End", "jump to first or last row"), + ("/ or f / Esc", "filter rows / clear filter"), + ("s / r", "select sort field / reverse"), + ("!", "cycle risk filter"), + ("g", "cycle event kind filter"), + ("--sessions", "start on agent session activity"), + ("a / H / Space/p", "all containers / header / pause"), + ("Enter", "container menu on Containers tab"), + ("x", "toggle detail panel"), + ("o", "single container, agent, or session focus"), + ("← / →", "logs / container view"), + ("l / r / t / f", "logs / refresh / timestamps / follow"), + ("e", "exec shell in container"), + ("w", "open first published web port"), + ("r", "reverse sort order"), + ("c", "configure columns"), + ("c then d", "restore compact columns"), + ("C", "switch container connector"), + ("S", "save current top configuration"), + ("K", "terminate process or stop container"), + ("Esc", "clear filter or close panel/focus"), + ("q", "quit"), + ]; + let mut table = DataTable::new(vec![ + DataColumn::new("KEY").width(18), + DataColumn::new("ACTION").min_width(24), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .empty("no help"); + for (key, action) in rows { + table.add_row(DataRow::new(vec![key, action]).fg(Color::BrightWhite)); + } + table.view(self.width, self.visible_height() + 2) + } + + fn sort_panel_view(&self) -> String { + let Some(panel) = &self.sort_panel else { + return String::new(); + }; + let width = self.width as usize; + let out = [ + Style::new() + .fg(CYAN) + .bold() + .render(&pad_plain(" sort by", width)), + Style::new().fg(Color::BrightBlack).render(&pad_plain( + " choose the primary ordering for the current top view", + width, + )), + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + panel.select.view(self.width, self.visible_height()), + ]; + out.join("\n") + } + + fn connector_panel_view(&self) -> String { + let Some(panel) = &self.connector_panel else { + return String::new(); + }; + let width = self.width as usize; + let out = [ + Style::new() + .fg(CYAN) + .bold() + .render(&pad_plain(" container connector", width)), + Style::new().fg(Color::BrightBlack).render(&pad_plain( + " choose which runtime feeds the Containers tab", + width, + )), + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(width)), + panel.select.view(self.width, self.visible_height()), + ]; + out.join("\n") + } + + fn events_table(&self, rows: Vec) -> String { + let mut table = DataTable::new(vec![ + self.configured_column("events.time", DataColumn::new("TIME").width(9)), + self.configured_column("events.source", DataColumn::new("SOURCE").width(12)), + self.configured_column("events.session", DataColumn::new("SESSION").width(12)), + self.configured_column("events.task", DataColumn::new("TASK").width(10)), + self.configured_column( + "events.pid", + DataColumn::new("PID").width(7).align(CellAlign::Right), + ), + self.configured_column( + "events.ppid", + DataColumn::new("PPID").width(7).align(CellAlign::Right), + ), + self.configured_column("events.kind", DataColumn::new("KIND").width(12)), + self.configured_column("events.risk", DataColumn::new("RISK").width(5)), + self.configured_column("events.message", DataColumn::new("MESSAGE").min_width(18)), + ]) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .selected((!rows.is_empty()).then_some(self.selected)) + .scroll(self.scroll) + .empty("no events yet - container changes appear live; agent logs are auto-discovered when present"); + + for row in rows { + table.add_row( + DataRow::new(vec![ + row.ts, + row.source, + row.session.unwrap_or_else(|| "-".to_string()), + row.task.unwrap_or_else(|| "-".to_string()), + format_event_pid(row.pid), + format_event_pid(row.ppid), + row.kind, + row.risk.label().to_string(), + row.message, + ]) + .fg(row.risk.color()), + ); + } + table.view(self.width, self.visible_height() + 2) + } + + fn details(&self) -> String { + if !self.detail { + return String::new(); + } + let mut lines = Vec::new(); + lines.push( + Style::new() + .fg(Color::BrightBlack) + .render(&"─".repeat(self.width as usize)), + ); + match self.tab { + Tab::Agents if self.focused_agent_pid.is_none() => { + if let Some(group) = self.current_agent_group() { + let activity = group.activity.clone(); + let usage = group.usage; + lines.push(Style::new().fg(group.agent.color()).bold().render(&format!( + " agent {} · state {} · risk {}", + group.agent.label(), + agent_tree_state_label(&group), + group.risk().label() + ))); + lines.push(format!( + " resources cpu {:.1}% · mem {:.1}% · system processes {} · agent processes {} · children {}", + usage.cpu_pct, + usage.mem_pct, + self.agent_system_processes(&group).len(), + group.processes.len(), + usage.descendants + )); + lines.push(format!( + " activity sessions {} · events {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · high {}", + group.sessions.len(), + activity.events, + activity.tools, + activity.security, + activity.files, + activity.egress, + activity.llm, + format_count(activity.total_tokens), + display_model(&activity.model), + display_provider(&activity.provider), + format_avg_ms(activity.latency_ms, activity.latency_samples), + activity.high_risk + )); + if let Some(session) = group.sessions.first() { + lines.push(format!( + " top session {} · task {} · cwd {} · risk {}", + session.session, + session.task, + session.workspace, + session.risk.label() + )); + } + if let Some(process) = group.processes.first() { + lines.push(format!( + " top process pid {} · elapsed {} · cwd {}", + process.pid, + process.elapsed, + display_workspace(process.cwd.as_deref()) + )); + } + if let Some(event) = group.events.first() { + lines.push(format!( + " latest event {} · {} · {}", + event.kind, + event_scope_label(event), + event.message + )); + } + lines.push( + " actions o focus top process/session · / filter · ! risk · g kind" + .to_string(), + ); + } + } + Tab::Agents | Tab::Processes => { + if let Some(row) = self.current_process() { + lines.push( + Style::new() + .fg(row.agent.map(|a| a.color()).unwrap_or(ACCENT)) + .bold() + .render(&format!( + " process {} · ppid {} · risk {}", + row.pid, + row.ppid, + row.risk.label() + )), + ); + lines.push(format!( + " cpu {:.1}% · mem {:.1}% · elapsed {} · children {}", + row.cpu_pct, + row.mem_pct, + row.elapsed, + self.descendant_count(row.pid) + )); + lines.push(format!( + " agent {}", + row.agent.map(|a| a.label()).unwrap_or("none") + )); + if row.agent.is_some() { + let activity = self.agent_activity_for_process(&row); + lines.push(format!( + " activity events {} · sessions {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · high {}", + activity.events, + activity.sessions, + activity.tools, + activity.security, + activity.files, + activity.egress, + activity.llm, + format_count(activity.total_tokens), + display_model(&activity.model), + display_provider(&activity.provider), + format_avg_ms(activity.latency_ms, activity.latency_samples), + activity.high_risk + )); + for event in self.recent_agent_events_for_process(&row) { + lines.push(format!( + " event {} · {} · {} · {}", + event.kind, + event.risk.label(), + event_scope_label(&event), + event.message + )); + } + } + lines.push(format!(" cwd {}", display_workspace(row.cwd.as_deref()))); + lines.push(format!(" command {}", row.command)); + } + } + Tab::Sessions => { + if self.focused_session.is_some() { + if let Some(row) = self.current_event() { + self.push_event_detail(&row, &mut lines); + } + } else if let Some(row) = self.current_session() { + lines.push( + Style::new() + .fg(row.risk.color()) + .bold() + .render(&format!(" session {} · {}", row.source, row.session)), + ); + lines.push(format!(" task {}", row.task)); + lines.push(format!(" cwd {}", row.workspace)); + lines.push(format!( + " activity events {} · tools {} · sec {} · files {} · net {} · llm {} · tokens {} · model {} · provider {} · latency {} · high {} · risk {}", + row.events, + row.tools, + row.security, + row.files, + row.egress, + row.llm, + format_count(row.total_tokens), + display_model(&row.model), + display_provider(&row.provider), + format_avg_ms(row.latency_ms, row.latency_samples), + row.high_risk, + row.risk.label() + )); + lines.push(format!(" latest {} · {}", row.last_kind, row.last_message)); + } + } + Tab::Containers => { + if let Some(row) = self.current_container() { + lines.push(Style::new().fg(CYAN).bold().render(&format!( + " container {} ({})", + row.name, + short_id(&row.id) + ))); + lines.push(format!(" image {}", row.image)); + lines.push(format!(" status {}", row.status)); + lines.push(format!( + " cpu {} · mem {} · net {} · block {} · pids {} · ports {}", + row.cpu_pct + .map(|v| format!("{v:.1}%")) + .unwrap_or_else(|| "-".to_string()), + row.mem_usage, + row.net_io, + row.block_io, + row.pids, + display_ports(&row.ports) + )); + lines.push( + " actions Enter menu · l logs · e shell · o focus · K stop".to_string(), + ); + } + } + Tab::Events => { + if let Some(row) = self.current_event() { + self.push_event_detail(&row, &mut lines); + } + } + } + lines + .into_iter() + .take(10) + .map(|line| pad_line(&line, self.width as usize)) + .collect::>() + .join("\n") + } + + fn push_event_detail(&self, row: &EventRow, lines: &mut Vec) { + lines.push(Style::new().fg(row.risk.color()).bold().render(&format!( + " event {} · {} · risk {}", + row.source, + row.kind, + row.risk.label() + ))); + lines.push(format!(" time {} · {}", row.ts, event_scope_label(row))); + lines.push(format!( + " source {} · session {} · task {}", + row.source, + row.session.as_deref().unwrap_or("-"), + row.task.as_deref().unwrap_or("-") + )); + if row.pid.is_some() || row.ppid.is_some() { + lines.push(format!( + " process pid {} · ppid {}", + row.pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "-".to_string()), + row.ppid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "-".to_string()) + )); + } + lines.push(format!(" message {}", row.message)); + for (key, value) in row.details.iter().take(4) { + let budget = (self.width as usize) + .saturating_sub(key.chars().count() + " detail ".len()) + .max(12); + lines.push(format!(" detail {key} {}", truncate(value, budget))); + } + if session_key_for_event(row).is_some() { + lines.push(" actions o session focus · / filter · ! risk · g kind".to_string()); + } else { + lines.push(" actions / filter · ! risk · g kind".to_string()); + } + } + + fn confirm_view(&self) -> String { + let Some(action) = &self.confirm else { + return String::new(); + }; + let (title, target) = match action { + Action::KillProcess(pid, label) => { + ("Terminate process?", format!("PID {pid} · {label}")) + } + Action::StartContainer(_, _, name) => ("Start container?", name.clone()), + Action::StopContainer(_, _, name) => ("Stop container?", name.clone()), + Action::RestartContainer(_, _, name) => ("Restart container?", name.clone()), + Action::PauseContainer(_, _, name) => ("Pause container?", name.clone()), + Action::UnpauseContainer(_, _, name) => ("Unpause container?", name.clone()), + Action::RemoveContainer(_, _, name) => ("Remove container?", name.clone()), + }; + let width = self.width as usize; + let inner = 58.min(width.saturating_sub(4)).max(24); + let line = "─".repeat(inner); + let target = truncate(&target, inner.saturating_sub(4)); + let rows = [ + format!("┌{line}┐"), + format!("│{}│", center(title, inner)), + format!("│{}│", center("", inner)), + format!("│{}│", center(&target, inner)), + format!( + "│{}│", + center("[ y / Enter ] confirm [ n / Esc ] cancel", inner) + ), + format!("└{line}┘"), + ]; + let styled = rows + .iter() + .map(|r| Style::new().fg(Color::BrightWhite).bg(RED).bold().render(r)) + .collect::>() + .join("\n"); + let pad = width.saturating_sub(inner + 2) / 2; + styled + .lines() + .map(|line| format!("{}{}", " ".repeat(pad), line)) + .collect::>() + .join("\n") + } +} + +impl Model for TopApp { + type Msg = Msg; + + fn init(&mut self) -> Option> { + Some(refresh_cmd( + self.connector, + self.show_all_containers, + self.focused_container.clone(), + self.observer.clone(), + )) + } + + fn update(&mut self, msg: Msg) -> Option> { + match msg { + Msg::Snapshot { + connector, + snapshot, + observer, + } => { + self.apply_snapshot(snapshot, observer, connector); + let mut cmds = vec![cmd::tick(self.interval, Msg::Tick)]; + if let Some(cmd) = self.focused_container_process_refresh_cmd() { + cmds.push(cmd); + } + if let Some(cmd) = self.open_log_refresh_cmd() { + cmds.push(cmd); + } + Some(single_or_batch(cmds)) + } + Msg::Tick => { + if self.paused { + Some(cmd::tick(self.interval, Msg::Tick)) + } else { + Some(refresh_cmd( + self.connector, + self.show_all_containers, + self.focused_container.clone(), + self.observer.clone(), + )) + } + } + Msg::ActionDone(note) => { + self.note = Some(note); + Some(refresh_cmd( + self.connector, + self.show_all_containers, + self.focused_container.clone(), + self.observer.clone(), + )) + } + Msg::ContainerLogs { + connector, + id, + name, + timestamps, + result, + } => { + if self.log.as_ref().map(|log| log.container_id.as_str()) != Some(id.as_str()) { + return None; + } + let follow = self.log.as_ref().map(|log| log.follow).unwrap_or(true); + let previous_scroll = self.log.as_ref().map(|log| log.scroll).unwrap_or_default(); + let text = match result { + Ok(text) => text, + Err(err) => format!("{} logs failed: {err}", connector.label()), + }; + let max_scroll = text + .lines() + .count() + .saturating_sub(self.log_visible_height()); + self.log = Some(LogPanel { + connector, + container_id: id, + container_name: name, + text, + scroll: if follow { + max_scroll + } else { + previous_scroll.min(max_scroll) + }, + timestamps, + loading: false, + refreshing: false, + follow, + }); + None + } + Msg::ContainerProcesses { id, name, result } => { + if self.focused_container.as_deref() != Some(id.as_str()) { + return None; + } + let (rows, error) = match result { + Ok(rows) => (rows, None), + Err(err) => (Vec::new(), Some(err)), + }; + let scroll = self + .container_processes + .as_ref() + .filter(|panel| panel.container_id == id) + .map(|panel| panel.scroll) + .unwrap_or_default(); + self.container_processes = Some(ContainerProcessPanel { + container_id: id, + container_name: name, + rows, + scroll, + error, + loading: false, + }); + None + } + Msg::ConfigSaved(result) => { + self.note = Some(match result { + Ok(path) => format!("saved top config to {}", path.display()), + Err(err) => format!("failed to save top config: {err}"), + }); + None + } + Msg::Term(Event::Resize { width, height }) => { + self.width = width; + self.height = height; + self.clamp_selection(); + None + } + Msg::Term(Event::Key(key)) => self.handle_key(key), + Msg::Term(_) => None, + } + } + + fn view(&self) -> String { + let mut body = Vec::new(); + if self.show_header { + body.push(self.header()); + } + body.push(self.tabs()); + body.push(self.table()); + let details = if self.help + || self.column_panel.is_some() + || self.sort_panel.is_some() + || self.connector_panel.is_some() + || self.log.is_some() + || self.container_menu.is_some() + || (self.tab == Tab::Containers && self.focused_container.is_some()) + || (self.tab == Tab::Agents && self.focused_agent_pid.is_some()) + { + String::new() + } else { + self.details() + }; + if !details.is_empty() { + body.push(details); + } + if let Some(note) = &self.note { + body.push(Style::new().fg(YELLOW).render(&format!(" {note}"))); + } else if !self.snapshot.errors.is_empty() { + body.push( + Style::new() + .fg(YELLOW) + .render(&format!(" {}", self.snapshot.errors.join(" · "))), + ); + } + + let main = body.join("\n"); + let help = self.footer_help_text(); + let sort_dir = if self.reverse_sort { "↑" } else { "↓" }; + let scope = if self.show_all_containers { + format!("{}:all", self.connector.label()) + } else { + format!("{}:running", self.connector.label()) + }; + let observer = observer_status_label(&self.observer); + let status = StatusBar::new() + .left(format!( + " {} · sort:{}{} · risk:{} · kind:{} · {} · {} · {} rows", + self.tab.label(), + self.sort_by.label(), + sort_dir, + self.risk_filter.label(), + self.kind_filter.label(), + scope, + observer, + self.visible_len() + )) + .center(help) + .right(if self.paused { "paused" } else { "live" }) + .fg(Color::BrightWhite) + .bg(Color::Rgb(35, 40, 60)) + .view(self.width); + + let mut screen = Layout::vertical() + .item(&main, Constraint::Fill) + .item(&status, Constraint::Fixed(1)) + .render(self.height); + + let confirm = self.confirm_view(); + if !confirm.is_empty() { + screen.push('\n'); + screen.push_str(&confirm); + } + if self.invert_colors { + invert_screen(&screen) + } else { + screen + } + } +} + +impl TopApp { + fn footer_help_text(&self) -> &'static str { + if self.column_panel.is_some() { + "Space toggle · Enter apply · d compact · Esc cancel" + } else if self.sort_panel.is_some() { + "Enter apply sort · ↑/↓ select · Esc cancel" + } else if self.connector_panel.is_some() { + "Enter switch connector · ↑/↓ select · Esc cancel" + } else if self.log.is_some() { + "Esc close logs · ↑/↓ scroll · r refresh · t timestamps · f follow" + } else if self.container_menu.is_some() { + "Enter run action · ↑/↓ select · Esc close menu" + } else if self.help { + "h/Esc close help" + } else if self.tab == Tab::Containers && self.focused_container.is_some() { + "Esc list · ↑/↓ proc · Enter actions(start/stop/restart/pause) · l logs · e shell · w browser · K stop" + } else if self.tab == Tab::Sessions && self.focused_session.is_some() { + "Esc close session · ↑/↓ event · / filter · q quit" + } else if self.editing_filter { + "type filter · Enter apply · Esc cancel · Ctrl+U clear · Ctrl+W word" + } else if self.tab == Tab::Agents { + "↑/↓ agent · o focus process/session · x details · / filter · Tab containers · q quit" + } else { + "Tab switch · / filter · ! risk · g kind · C connector · h help · Enter menu · c columns · S save · o focus · l logs · q quit" + } + } + + fn handle_key(&mut self, key: KeyEvent) -> Option> { + if self.editing_filter { + return self.handle_filter_key(key); + } + if self.confirm.is_some() { + return self.handle_confirm_key(key); + } + if self.help { + return self.handle_help_key(key); + } + if self.container_menu.is_some() { + return self.handle_container_menu_key(key); + } + if self.sort_panel.is_some() { + return self.handle_sort_key(key); + } + if self.connector_panel.is_some() { + return self.handle_connector_key(key); + } + if self.column_panel.is_some() { + return self.handle_column_key(key); + } + if self.log.is_some() { + return self.handle_log_key(key); + } + if key.code == KeyCode::Esc { + if self.focused_container.take().is_some() { + self.container_processes = None; + self.reset_position(); + return None; + } + if self.focused_agent_pid.take().is_some() { + self.reset_position(); + return None; + } + if self.focused_session.take().is_some() { + self.reset_position(); + return None; + } + if self.detail { + self.detail = false; + return None; + } + } + if key.modifiers.is_empty() && self.tab == Tab::Containers { + match key.code { + KeyCode::Right => return self.toggle_container_focus(), + KeyCode::Left => return self.open_container_logs(), + _ => {} + } + } + if key.modifiers.is_empty() + && self.tab == Tab::Containers + && self.focused_container.is_some() + && self.handle_focused_container_scroll_key(&key) + { + return None; + } + + let action = self.keymap.resolve(&key); + match action { + Some(TopKey::Quit) => Some(cmd::quit()), + Some(TopKey::Up) => { + self.selected = self.selected.saturating_sub(1); + self.clamp_selection(); + None + } + Some(TopKey::Down) => { + self.selected = (self.selected + 1).min(self.visible_len().saturating_sub(1)); + self.clamp_selection(); + None + } + Some(TopKey::Home) => { + self.selected = 0; + self.clamp_selection(); + None + } + Some(TopKey::End) => { + self.selected = self.visible_len().saturating_sub(1); + self.clamp_selection(); + None + } + Some(TopKey::PageUp) => { + self.selected = self.selected.saturating_sub(10); + self.clamp_selection(); + None + } + Some(TopKey::PageDown) => { + self.selected = (self.selected + 10).min(self.visible_len().saturating_sub(1)); + self.clamp_selection(); + None + } + Some(TopKey::NextTab) => { + self.tab = self.tab.next(); + self.reset_position(); + None + } + Some(TopKey::PrevTab) => { + self.tab = self.tab.prev(); + self.reset_position(); + None + } + Some(TopKey::Filter) => { + self.filter_before_edit = Some(self.filter.clone()); + self.editing_filter = true; + None + } + Some(TopKey::Sort) => { + self.open_sort_panel(); + None + } + Some(TopKey::TogglePause) => { + self.paused = !self.paused; + None + } + Some(TopKey::ToggleAll) => { + self.show_all_containers = !self.show_all_containers; + self.reset_position(); + Some(refresh_cmd( + self.connector, + self.show_all_containers, + self.focused_container.clone(), + self.observer.clone(), + )) + } + Some(TopKey::ToggleHeader) => { + self.show_header = !self.show_header; + None + } + Some(TopKey::ToggleReverse) => { + self.reverse_sort = !self.reverse_sort; + self.clamp_selection(); + None + } + Some(TopKey::ToggleRiskFilter) => { + self.risk_filter = self.risk_filter.next(); + self.reset_position(); + None + } + Some(TopKey::ToggleKindFilter) => { + self.kind_filter = self.kind_filter.next(); + self.reset_position(); + None + } + Some(TopKey::Detail) => { + if self.tab == Tab::Containers && key.code == KeyCode::Enter { + self.open_container_menu(); + } else { + self.detail = !self.detail; + } + None + } + Some(TopKey::Open) => { + match self.tab { + Tab::Containers => return self.toggle_container_focus(), + Tab::Agents | Tab::Processes => self.toggle_agent_focus(), + Tab::Sessions => self.toggle_session_focus(), + Tab::Events => self.open_event_focus(), + } + None + } + Some(TopKey::Logs) => self.open_container_logs(), + Some(TopKey::OpenBrowser) => self.open_container_browser(), + Some(TopKey::Columns) => { + self.open_column_panel(); + None + } + Some(TopKey::Connector) => { + self.open_connector_panel(); + None + } + Some(TopKey::ExecShell) => self.open_container_shell(), + Some(TopKey::SaveConfig) => Some(save_config_cmd(self.current_config())), + Some(TopKey::Help) => { + self.help = true; + None + } + Some(TopKey::Kill) => { + self.confirm = self.selected_action(); + None + } + None => None, + } + } + + fn handle_focused_container_scroll_key(&mut self, key: &KeyEvent) -> bool { + let step = self.visible_height().saturating_div(2).max(1); + let Some(panel) = &mut self.container_processes else { + return false; + }; + let max_scroll = panel.rows.len(); + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + panel.scroll = panel.scroll.saturating_sub(1); + true + } + KeyCode::Down | KeyCode::Char('j') => { + panel.scroll = (panel.scroll + 1).min(max_scroll); + true + } + KeyCode::PageUp => { + panel.scroll = panel.scroll.saturating_sub(step); + true + } + KeyCode::PageDown => { + panel.scroll = (panel.scroll + step).min(max_scroll); + true + } + KeyCode::Home => { + panel.scroll = 0; + true + } + KeyCode::End => { + panel.scroll = max_scroll; + true + } + _ => false, + } + } + + fn handle_help_key(&mut self, key: KeyEvent) -> Option> { + if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) { + return Some(cmd::quit()); + } + match key.code { + KeyCode::Esc | KeyCode::Char('h') => { + self.help = false; + None + } + _ => None, + } + } + + fn handle_container_menu_key(&mut self, key: KeyEvent) -> Option> { + if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) { + return Some(cmd::quit()); + } + if key.code == KeyCode::Esc { + self.container_menu = None; + return None; + } + + let Some(menu) = &mut self.container_menu else { + return None; + }; + if let KeyCode::Char(ch) = key.code { + let ch = ch.to_ascii_lowercase(); + if let Some(item) = menu.items.iter().find(|item| item.key == ch).cloned() { + let container = menu.container.clone(); + self.container_menu = None; + return self.run_container_menu_action(container, item.action); + } + } + match menu.select.handle_key(&key) { + Some(SelectMsg::Selected(idx, _)) => { + let item = menu.items.get(idx).cloned()?; + let container = menu.container.clone(); + self.container_menu = None; + self.run_container_menu_action(container, item.action) + } + None => None, + } + } + + fn handle_column_key(&mut self, key: KeyEvent) -> Option> { + if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) { + return Some(cmd::quit()); + } + if key.code == KeyCode::Esc { + self.column_panel = None; + return None; + } + if matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) { + if let Some(tab) = self.column_panel.as_ref().map(|panel| panel.tab) { + self.reset_tab_columns_to_default(tab); + self.column_panel = None; + } + return None; + } + + let Some(panel) = &mut self.column_panel else { + return None; + }; + match panel.select.handle_key(&key) { + Some(MultiSelectMsg::Submit(indices)) => { + if indices.is_empty() { + self.note = Some("at least one column must stay visible".to_string()); + return None; + } + + let visible: HashSet = indices.into_iter().collect(); + for (idx, choice) in panel.choices.iter().enumerate() { + if visible.contains(&idx) { + self.hidden_columns.remove(choice.id); + } else { + self.hidden_columns.insert(choice.id.to_string()); + } + } + self.column_panel = None; + None + } + Some(MultiSelectMsg::Toggle(_)) | None => None, + } + } + + fn handle_sort_key(&mut self, key: KeyEvent) -> Option> { + if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) { + return Some(cmd::quit()); + } + if key.code == KeyCode::Esc { + self.sort_panel = None; + return None; + } + + let selected = { + let Some(panel) = &mut self.sort_panel else { + return None; + }; + match panel.select.handle_key(&key) { + Some(SelectMsg::Selected(idx, _)) => panel.choices.get(idx).copied(), + None => return None, + } + }; + + if let Some(sort_by) = selected { + self.sort_by = sort_by; + self.clamp_selection(); + } + self.sort_panel = None; + None + } + + fn handle_connector_key(&mut self, key: KeyEvent) -> Option> { + if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) { + return Some(cmd::quit()); + } + if key.code == KeyCode::Esc { + self.connector_panel = None; + return None; + } + + let selected = { + let Some(panel) = &mut self.connector_panel else { + return None; + }; + match panel.select.handle_key(&key) { + Some(SelectMsg::Selected(idx, _)) => panel.choices.get(idx).copied(), + None => return None, + } + }; + + self.connector_panel = None; + if let Some(connector) = selected { + self.connector = connector; + self.focused_container = None; + self.container_processes = None; + self.container_menu = None; + self.log = None; + self.reset_position(); + return Some(refresh_cmd( + self.connector, + self.show_all_containers, + self.focused_container.clone(), + self.observer.clone(), + )); + } + None + } + + fn handle_filter_key(&mut self, key: KeyEvent) -> Option> { + match key.code { + KeyCode::Esc => { + self.filter_before_edit = None; + self.filter.clear(); + self.editing_filter = false; + self.reset_position(); + } + KeyCode::Enter => { + self.filter_before_edit = None; + self.editing_filter = false; + self.reset_position(); + } + KeyCode::Backspace => { + self.filter.pop(); + self.reset_position(); + } + KeyCode::Char('u') | KeyCode::Char('U') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.filter.clear(); + self.reset_position(); + } + KeyCode::Char('w') | KeyCode::Char('W') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + delete_filter_word(&mut self.filter); + self.reset_position(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.filter.push(c); + self.reset_position(); + } + _ => {} + } + None + } + + fn handle_log_key(&mut self, key: KeyEvent) -> Option> { + if matches!(self.keymap.resolve(&key), Some(TopKey::Quit)) { + return Some(cmd::quit()); + } + + let max_scroll = self + .log + .as_ref() + .map(|log| { + log.text + .lines() + .count() + .saturating_sub(self.log_visible_height()) + }) + .unwrap_or_default(); + + match key.code { + KeyCode::Esc => { + self.log = None; + None + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(log) = &mut self.log { + log.scroll = log.scroll.saturating_sub(1); + log.follow = false; + } + None + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(log) = &mut self.log { + log.scroll = (log.scroll + 1).min(max_scroll); + log.follow = log.scroll >= max_scroll; + } + None + } + KeyCode::PageUp => { + let step = self.log_visible_height(); + if let Some(log) = &mut self.log { + log.scroll = log.scroll.saturating_sub(step); + log.follow = false; + } + None + } + KeyCode::PageDown => { + let step = self.log_visible_height(); + if let Some(log) = &mut self.log { + log.scroll = (log.scroll + step).min(max_scroll); + log.follow = log.scroll >= max_scroll; + } + None + } + KeyCode::Home => { + if let Some(log) = &mut self.log { + log.scroll = 0; + log.follow = false; + } + None + } + KeyCode::End => { + if let Some(log) = &mut self.log { + log.scroll = max_scroll; + log.follow = true; + } + None + } + KeyCode::Char('r') | KeyCode::Char('R') => self.open_log_refresh_cmd(), + KeyCode::Char('f') | KeyCode::Char('F') => { + if let Some(log) = &mut self.log { + if log.follow { + log.follow = false; + } else { + log.scroll = max_scroll; + log.follow = true; + } + } + None + } + KeyCode::Char('t') => { + let Some(log) = &mut self.log else { + return None; + }; + log.timestamps = !log.timestamps; + log.loading = true; + log.refreshing = true; + log.follow = true; + log.text.clear(); + Some(container_logs_cmd( + log.connector, + log.container_id.clone(), + log.container_name.clone(), + log.timestamps, + )) + } + _ => None, + } + } + + fn handle_confirm_key(&mut self, key: KeyEvent) -> Option> { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.confirm = None; + None + } + KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { + let action = self.confirm.take()?; + Some(cmd::cmd(move || async move { + Msg::ActionDone(run_action(action).await) + })) + } + _ => None, + } + } + + fn toggle_container_focus(&mut self) -> Option> { + let Some(container) = self.current_container() else { + self.note = Some("select a container before opening focus view".to_string()); + return None; + }; + if self.focused_container.as_deref() == Some(container.id.as_str()) { + self.focused_container = None; + self.container_processes = None; + self.reset_position(); + None + } else { + self.focused_container = Some(container.id.clone()); + self.reset_position(); + self.detail = false; + self.start_container_process_refresh(container) + } + } + + fn start_container_process_refresh(&mut self, container: ContainerRow) -> Option> { + let (rows, scroll) = self + .container_processes + .as_ref() + .filter(|panel| panel.container_id == container.id) + .map(|panel| (panel.rows.clone(), panel.scroll)) + .unwrap_or_default(); + self.container_processes = Some(ContainerProcessPanel { + container_id: container.id.clone(), + container_name: container.name.clone(), + rows, + scroll, + error: None, + loading: true, + }); + Some(container_processes_cmd(container)) + } + + fn focused_container_process_refresh_cmd(&mut self) -> Option> { + let container = self.current_container()?; + if self + .container_processes + .as_ref() + .is_some_and(|panel| panel.container_id == container.id && panel.loading) + { + return None; + } + self.start_container_process_refresh(container) + } + + fn toggle_agent_focus(&mut self) { + if self.tab == Tab::Agents && self.focused_agent_pid.is_none() { + let Some(group) = self.current_agent_group() else { + self.note = Some("select an agent before opening focus view".to_string()); + return; + }; + if let Some(process) = group.processes.first() { + self.focused_agent_pid = Some(process.pid); + self.reset_position(); + self.detail = false; + return; + } + if let Some(session) = group.sessions.first() { + self.focused_session = Some(SessionFocus::from_row(session)); + self.tab = Tab::Sessions; + self.reset_position(); + self.detail = false; + return; + } + self.note = Some(format!( + "{} has no live process or session to focus", + group.agent.label() + )); + return; + } + + let Some(process) = self.current_process() else { + self.note = Some("select an agent before opening focus view".to_string()); + return; + }; + if process.agent.is_none() { + self.note = Some("focus view is available for coding-agent processes".to_string()); + return; + } + if self.focused_agent_pid == Some(process.pid) { + self.focused_agent_pid = None; + self.reset_position(); + } else { + self.focused_agent_pid = Some(process.pid); + self.tab = Tab::Agents; + self.reset_position(); + self.detail = false; + } + } + + fn toggle_session_focus(&mut self) { + if self.focused_session.take().is_some() { + self.reset_position(); + return; + } + + let Some(row) = self.current_session() else { + self.note = Some("select a session before opening focus view".to_string()); + return; + }; + self.focused_session = Some(SessionFocus::from_row(&row)); + self.reset_position(); + self.detail = false; + } + + fn open_event_focus(&mut self) { + let Some(event) = self.current_event() else { + self.note = Some("select an event before opening focus view".to_string()); + return; + }; + let Some((source, session)) = session_key_for_event(&event) else { + self.note = Some("event focus is available for coding-agent events".to_string()); + return; + }; + self.focused_session = Some(SessionFocus { source, session }); + self.tab = Tab::Sessions; + self.reset_position(); + self.detail = false; + } + + fn open_container_menu(&mut self) { + if self.tab != Tab::Containers { + return; + } + let Some(container) = self.current_container() else { + self.note = Some("select a container before opening the container menu".to_string()); + return; + }; + let items = container_menu_items(&container); + let labels = items + .iter() + .map(|item| format!("{} {}", item.key, item.label)) + .collect::>(); + self.container_menu = Some(ContainerMenu { + container, + items, + select: Select::new(labels), + }); + } + + fn run_container_menu_action( + &mut self, + container: ContainerRow, + action: ContainerMenuAction, + ) -> Option> { + match action { + ContainerMenuAction::Focus => { + self.focused_container = Some(container.id.clone()); + self.reset_position(); + self.detail = false; + self.start_container_process_refresh(container) + } + ContainerMenuAction::Logs => self.open_container_logs_for(container), + ContainerMenuAction::ExecShell => self.open_container_shell_for(container), + ContainerMenuAction::OpenBrowser => self.open_container_browser_for(container), + ContainerMenuAction::Start + | ContainerMenuAction::Stop + | ContainerMenuAction::Restart + | ContainerMenuAction::Pause + | ContainerMenuAction::Unpause + | ContainerMenuAction::Remove => { + self.confirm = Some(container_action(container, action)); + None + } + } + } + + fn open_container_browser(&mut self) -> Option> { + if self.tab != Tab::Containers { + self.note = Some("browser open is available on the Containers tab".to_string()); + return None; + } + let Some(container) = self.current_container() else { + self.note = Some("select a container before opening a browser".to_string()); + return None; + }; + self.open_container_browser_for(container) + } + + fn open_container_browser_for(&mut self, container: ContainerRow) -> Option> { + let Some(url) = container_web_url(&container) else { + self.note = Some(format!( + "{} has no published web port to open", + container.name + )); + return None; + }; + if let Ok(mut slot) = self.external_action.lock() { + *slot = Some(ExternalAction::OpenBrowser { + url, + name: container.name, + }); + Some(cmd::quit()) + } else { + self.note = Some("failed to prepare browser open".to_string()); + None + } + } + + fn open_container_logs(&mut self) -> Option> { + if self.tab != Tab::Containers { + self.note = Some("logs are available on the Containers tab".to_string()); + return None; + } + let Some(container) = self.current_container() else { + self.note = Some("select a container before opening logs".to_string()); + return None; + }; + self.open_container_logs_for(container) + } + + fn open_container_logs_for(&mut self, container: ContainerRow) -> Option> { + if !matches!( + container.connector, + ContainerConnector::A3sBox | ContainerConnector::Docker + ) { + self.note = + Some("logs are currently available for a3s-box and Docker containers".to_string()); + return None; + } + let timestamps = self.log.as_ref().map(|log| log.timestamps).unwrap_or(false); + self.log = Some(LogPanel { + connector: container.connector, + container_id: container.id.clone(), + container_name: container.name.clone(), + text: String::new(), + scroll: 0, + timestamps, + loading: true, + refreshing: true, + follow: true, + }); + Some(container_logs_cmd( + container.connector, + container.id, + container.name, + timestamps, + )) + } + + fn open_log_refresh_cmd(&mut self) -> Option> { + let log = self.log.as_mut()?; + if log.loading || log.refreshing { + return None; + } + log.refreshing = true; + Some(container_logs_cmd( + log.connector, + log.container_id.clone(), + log.container_name.clone(), + log.timestamps, + )) + } + + fn open_container_shell(&mut self) -> Option> { + if self.tab != Tab::Containers { + self.note = Some("exec shell is available on the Containers tab".to_string()); + return None; + } + let Some(container) = self.current_container() else { + self.note = Some("select a container before opening a shell".to_string()); + return None; + }; + self.open_container_shell_for(container) + } + + fn open_container_shell_for(&mut self, container: ContainerRow) -> Option> { + if !matches!( + container.connector, + ContainerConnector::A3sBox | ContainerConnector::Docker + ) { + self.note = Some( + "exec shell is currently available for a3s-box and Docker containers".to_string(), + ); + return None; + } + if let Ok(mut slot) = self.external_action.lock() { + *slot = Some(ExternalAction::ContainerShell { + connector: container.connector, + id: container.id, + name: container.name, + }); + Some(cmd::quit()) + } else { + self.note = Some("failed to prepare container shell".to_string()); + None + } + } + + fn open_sort_panel(&mut self) { + let choices = sort_choices_for_tab(self.tab); + if choices.is_empty() { + self.note = Some("events stay newest-first; use /, !, or g to narrow them".to_string()); + return; + } + let labels = choices + .iter() + .map(|sort_by| sort_choice_label(*sort_by)) + .collect::>(); + let selected = choices + .iter() + .position(|sort_by| *sort_by == self.sort_by) + .unwrap_or(0); + self.sort_panel = Some(SortPanel { + choices, + select: Select::new(labels) + .with_selected(selected) + .with_number_shortcuts(), + }); + } + + fn open_connector_panel(&mut self) { + let choices = vec![ + ContainerConnector::A3sBox, + ContainerConnector::Docker, + ContainerConnector::RunC, + ]; + let labels = choices + .iter() + .map(|connector| connector_choice_label(*connector)) + .collect::>(); + let selected = choices + .iter() + .position(|connector| *connector == self.connector) + .unwrap_or(0); + self.connector_panel = Some(ConnectorPanel { + choices, + select: Select::new(labels) + .with_selected(selected) + .with_number_shortcuts(), + }); + } + + fn open_column_panel(&mut self) { + let choices = self.column_choices(self.tab); + let labels = choices.iter().map(|c| c.label).collect::>(); + let checked = choices + .iter() + .map(|c| self.column_visible(c.id)) + .collect::>(); + self.column_panel = Some(ColumnPanel { + tab: self.tab, + choices, + select: MultiSelect::new(labels) + .with_checked(checked) + .with_number_shortcuts(), + }); + } + + fn reset_tab_columns_to_default(&mut self, tab: Tab) { + let default_hidden = default_hidden_columns(); + for choice in self.column_choices(tab) { + if default_hidden.contains(choice.id) { + self.hidden_columns.insert(choice.id.to_string()); + } else { + self.hidden_columns.remove(choice.id); + } + } + self.ensure_visible_columns(); + } + + fn current_config(&self) -> TopConfig { + TopConfig { + show_all_containers: self.show_all_containers, + show_header: self.show_header, + reverse_sort: self.reverse_sort, + sort_by: self.sort_by, + risk_filter: self.risk_filter, + kind_filter: self.kind_filter, + connector: self.connector, + filter: self.filter.clone(), + hidden_columns: self.hidden_columns.clone(), + } + } +} + +#[derive(Debug, Clone)] +struct TopOptions { + tab: Tab, + interval: Duration, + force_all_containers: bool, + force_active_containers: bool, + container_query: Option, + filter: Option, + sort_by: Option, + risk_filter: Option, + kind_filter: Option, + connector: Option, + reverse_sort: Option, + show_header: Option, + compact_columns: bool, + json: bool, + watch: bool, + json_count: Option, + invert_colors: bool, + start_help: bool, + config: TopConfig, + external_action: Arc>>, +} + +impl Default for TopOptions { + fn default() -> Self { + Self { + tab: Tab::Agents, + interval: Duration::from_millis(1500), + force_all_containers: false, + force_active_containers: false, + container_query: None, + filter: None, + sort_by: None, + risk_filter: None, + kind_filter: None, + connector: None, + reverse_sort: None, + show_header: None, + compact_columns: false, + json: false, + watch: false, + json_count: None, + invert_colors: false, + start_help: false, + config: TopConfig::default(), + external_action: Arc::new(Mutex::new(None)), + } + } +} + +pub async fn run(args: Vec) -> anyhow::Result<()> { + let mut base_options = parse_options(args)?; + let force_all_containers = base_options.force_all_containers; + let force_active_containers = base_options.force_active_containers; + base_options.config = load_top_config().unwrap_or_default(); + apply_cli_overrides( + &mut base_options, + force_all_containers, + force_active_containers, + ); + if base_options.json { + run_json_snapshot(base_options).await?; + return Ok(()); + } + loop { + let external_action = Arc::new(Mutex::new(None)); + let mut options = base_options.clone(); + options.external_action = external_action.clone(); + ProgramBuilder::new(TopApp::new(options)) + .with_alt_screen() + .with_fps(30) + .run() + .await?; + + let action = external_action.lock().ok().and_then(|mut slot| slot.take()); + let Some(action) = action else { + break; + }; + run_external_action(action).await?; + base_options.config = load_top_config().unwrap_or_default(); + apply_cli_overrides( + &mut base_options, + force_all_containers, + force_active_containers, + ); + } + Ok(()) +} + +fn apply_cli_overrides( + options: &mut TopOptions, + force_all_containers: bool, + force_active_containers: bool, +) { + if options.container_query.is_some() { + options.tab = Tab::Containers; + if !force_all_containers && !force_active_containers { + options.config.show_all_containers = true; + } + } + if force_all_containers { + options.config.show_all_containers = true; + } + if force_active_containers { + options.config.show_all_containers = false; + } + if let Some(filter) = &options.filter { + options.config.filter = filter.clone(); + } + if let Some(sort_by) = options.sort_by { + options.config.sort_by = sort_by; + } + if let Some(risk_filter) = options.risk_filter { + options.config.risk_filter = risk_filter; + } + if let Some(kind_filter) = options.kind_filter { + options.config.kind_filter = kind_filter; + } + if let Some(connector) = options.connector.or_else(env_connector) { + options.config.connector = connector; + } + if let Some(reverse_sort) = options.reverse_sort { + options.config.reverse_sort = reverse_sort; + } + if let Some(show_header) = options.show_header { + options.config.show_header = show_header; + } + if options.compact_columns { + options.config.hidden_columns = default_hidden_columns(); + } +} + +async fn run_json_snapshot(options: TopOptions) -> anyhow::Result<()> { + let connector = options.config.connector; + let show_all_containers = options.config.show_all_containers; + let interval = options.interval; + let container_target = options.container_query.clone(); + let max_snapshots = options + .json_count + .unwrap_or(if options.watch { usize::MAX } else { 1 }); + let streaming = options.watch || max_snapshots > 1; + let mut app = TopApp::new(options); + let mut observer = ObserverState::default(); + let mut emitted = 0usize; + + while emitted < max_snapshots { + let (snapshot, next_observer) = collect_snapshot( + connector, + show_all_containers, + container_target.clone(), + observer, + ) + .await; + observer = next_observer.clone(); + app.apply_snapshot(snapshot, next_observer, connector); + + let json = top_snapshot_json(&app, unix_millis()); + if streaming { + println!("{}", serde_json::to_string(&json)?); + } else { + println!("{}", serde_json::to_string_pretty(&json)?); + } + emitted += 1; + + if emitted >= max_snapshots { + break; + } + tokio::time::sleep(interval).await; + } + Ok(()) +} + +fn unix_millis() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +fn top_snapshot_json(app: &TopApp, collected_at_unix_ms: u128) -> serde_json::Value { + let agents = app.filtered_agents(); + let sessions = app.filtered_sessions(); + let containers = app.filtered_containers(); + let container_states = container_state_summary(&containers); + let raw_container_states = container_state_summary(&app.snapshot.containers); + let processes = app.filtered_processes(); + let events = app.filtered_events(); + let high_events = app + .snapshot + .events + .iter() + .filter(|event| event.risk == Risk::High) + .count(); + let total_tokens = app + .snapshot + .events + .iter() + .filter_map(event_token_usage) + .map(|tokens| tokens.total) + .sum::(); + + serde_json::json!({ + "schema": "a3s.top.snapshot.v1", + "collected_at_unix_ms": collected_at_unix_ms, + "config": { + "tab": app.tab.label(), + "sort_by": app.sort_by.label(), + "reverse_sort": app.reverse_sort, + "risk_filter": app.risk_filter.label(), + "kind_filter": app.kind_filter.label(), + "connector": app.connector.label(), + "show_all_containers": app.show_all_containers, + "filter": &app.filter, + "container": app.focused_container.as_deref(), + }, + "observer": { + "status": observer_status_label(&app.observer), + "paths": app.observer.paths.iter().map(|path| path.display().to_string()).collect::>(), + }, + "summary": { + "agents": agents.len(), + "sessions": sessions.len(), + "containers": containers.len(), + "container_states": container_state_summary_json(container_states), + "processes": processes.len(), + "events": events.len(), + "raw_processes": app.snapshot.processes.len(), + "raw_containers": app.snapshot.containers.len(), + "raw_container_states": container_state_summary_json(raw_container_states), + "raw_events": app.snapshot.events.len(), + "high_events": high_events, + "total_tokens": total_tokens, + "errors": app.snapshot.errors.len(), + }, + "agents": agents.iter().map(|row| agent_json(app, row)).collect::>(), + "sessions": sessions.iter().map(session_json).collect::>(), + "containers": containers + .iter() + .map(|row| container_json(app, row)) + .collect::>(), + "processes": processes + .iter() + .map(|row| process_json(app, row)) + .collect::>(), + "events": events.iter().map(event_json).collect::>(), + "errors": &app.snapshot.errors, + }) +} + +fn agent_json(app: &TopApp, row: &ProcessRow) -> serde_json::Value { + let usage = app.process_tree_usage(row.pid); + let activity = app.agent_activity_for_process(row); + let history = app.metric_history(&agent_tree_history_key(row.pid)); + let sessions = agent_session_rows(app, row); + let top_session = sessions.first(); + serde_json::json!({ + "pid": row.pid, + "ppid": row.ppid, + "agent": row.agent.map(|agent| agent.label()), + "cpu_pct": row.cpu_pct, + "mem_pct": row.mem_pct, + "subtree": { + "cpu_pct": usage.cpu_pct, + "mem_pct": usage.mem_pct, + "descendants": usage.descendants, + }, + "process_tree": process_tree_json(app, row.pid), + "history": metric_history_json(&history), + "elapsed": &row.elapsed, + "cwd": row.cwd.as_deref(), + "risk": row.risk.label(), + "command": &row.command, + "activity": activity_json(&activity), + "top_session": top_session.map(|row| row.session.as_str()), + "top_task": top_session.and_then(|row| empty_dash_to_null(&row.task)), + "sessions": sessions.iter().map(session_json).collect::>(), + "recent_events": app + .recent_agent_events_for_process(row) + .iter() + .map(event_json) + .collect::>(), + }) +} + +fn agent_session_rows(app: &TopApp, row: &ProcessRow) -> Vec { + if row.agent.is_none() { + return Vec::new(); + } + let events = app + .snapshot + .events + .iter() + .filter(|event| app.event_matches_agent_process(row, event)) + .cloned() + .collect::>(); + session_rows(&events) +} + +fn process_tree_json(app: &TopApp, root_pid: u32) -> serde_json::Value { + let mut visited = HashSet::new(); + process_tree_node_json(app, root_pid, &mut visited).unwrap_or(serde_json::Value::Null) +} + +fn process_tree_node_json( + app: &TopApp, + pid: u32, + visited: &mut HashSet, +) -> Option { + if !visited.insert(pid) { + return None; + } + let process = app + .snapshot + .processes + .iter() + .find(|process| process.pid == pid)?; + let mut children = app + .snapshot + .processes + .iter() + .filter(|candidate| candidate.ppid == pid) + .map(|candidate| candidate.pid) + .collect::>(); + children.sort_unstable(); + let children = children + .into_iter() + .filter_map(|child_pid| process_tree_node_json(app, child_pid, visited)) + .collect::>(); + + Some(serde_json::json!({ + "pid": process.pid, + "ppid": process.ppid, + "cpu_pct": process.cpu_pct, + "mem_pct": process.mem_pct, + "elapsed": &process.elapsed, + "cwd": process.cwd.as_deref(), + "agent": process.agent.map(|agent| agent.label()), + "risk": process.risk.label(), + "command": &process.command, + "children": children, + })) +} + +fn activity_json(activity: &AgentActivity) -> serde_json::Value { + serde_json::json!({ + "events": activity.events, + "sessions": activity.sessions, + "tools": activity.tools, + "security": activity.security, + "files": activity.files, + "egress": activity.egress, + "llm": activity.llm, + "tokens": { + "prompt": activity.prompt_tokens, + "completion": activity.completion_tokens, + "total": activity.total_tokens, + }, + "model": empty_dash_to_null(&activity.model), + "provider": empty_dash_to_null(&activity.provider), + "latency_ms_avg": average_u64(activity.latency_ms, activity.latency_samples), + "ttft_ms_avg": average_u64(activity.ttft_ms, activity.ttft_samples), + "wire": { + "request_bytes": activity.req_bytes, + "response_bytes": activity.resp_bytes, + }, + "high_risk": activity.high_risk, + }) +} + +fn session_json(row: &SessionRow) -> serde_json::Value { + serde_json::json!({ + "source": &row.source, + "session": &row.session, + "task": empty_dash_to_null(&row.task), + "workspace": empty_dash_to_null(&row.workspace), + "events": row.events, + "tools": row.tools, + "security": row.security, + "files": row.files, + "egress": row.egress, + "llm": row.llm, + "tokens": { + "prompt": row.prompt_tokens, + "completion": row.completion_tokens, + "total": row.total_tokens, + }, + "model": empty_dash_to_null(&row.model), + "provider": empty_dash_to_null(&row.provider), + "latency_ms_avg": average_u64(row.latency_ms, row.latency_samples), + "ttft_ms_avg": average_u64(row.ttft_ms, row.ttft_samples), + "wire": { + "request_bytes": row.req_bytes, + "response_bytes": row.resp_bytes, + }, + "high_risk": row.high_risk, + "risk": row.risk.label(), + "last_kind": &row.last_kind, + "last_message": &row.last_message, + }) +} + +fn container_json(app: &TopApp, row: &ContainerRow) -> serde_json::Value { + let history = app.metric_history(&container_history_key(&row.id)); + serde_json::json!({ + "connector": row.connector.label(), + "id": &row.id, + "cid": short_id(&row.id), + "short_id": short_id(&row.id), + "name": &row.name, + "image": &row.image, + "status": &row.status, + "cpu_pct": row.cpu_pct, + "cpu_count": row.cpu_count, + "cpu_usage_total_ns": row.cpu_usage_total_ns, + "mem_pct": row.mem_pct, + "mem_usage": &row.mem_usage, + "net_io": &row.net_io, + "block_io": &row.block_io, + "pids": &row.pids, + "ports": &row.ports, + "history": metric_history_json(&history), + "inspect": { + "health": empty_dash_to_null(&row.inspect.health), + "restarts": empty_dash_to_null(&row.inspect.restarts), + "restart_policy": empty_dash_to_null(&row.inspect.restart_policy), + "created": empty_dash_to_null(&row.inspect.created), + "started": empty_dash_to_null(&row.inspect.started), + "exit": empty_dash_to_null(&row.inspect.exit), + "mounts": empty_dash_to_null(&row.inspect.mounts), + "env": empty_dash_to_null(&row.inspect.env), + "labels": empty_dash_to_null(&row.inspect.labels), + "networks": empty_dash_to_null(&row.inspect.networks), + }, + }) +} + +fn container_matches_query(container: &ContainerRow, query: &str) -> bool { + let query = query.trim(); + if query.is_empty() { + return true; + } + container.name == query + || container.id == query + || short_id(&container.id) == query + || container.id.starts_with(query) + || short_id(&container.id).starts_with(query) +} + +fn container_target_filters(target: Option<&str>) -> Vec> { + let Some(target) = target.map(str::trim).filter(|target| !target.is_empty()) else { + return vec![None]; + }; + vec![Some(format!("name={target}")), Some(format!("id={target}"))] +} + +fn delete_filter_word(filter: &mut String) { + while filter.chars().last().is_some_and(|ch| ch.is_whitespace()) { + filter.pop(); + } + while filter.chars().last().is_some_and(|ch| !ch.is_whitespace()) { + filter.pop(); + } +} + +fn dedupe_container_rows(rows: &mut Vec) { + let mut seen = HashSet::new(); + rows.retain(|row| seen.insert(row.id.clone())); +} + +fn process_json(app: &TopApp, row: &ProcessRow) -> serde_json::Value { + let history = app.metric_history(&process_history_key(row.pid)); + serde_json::json!({ + "pid": row.pid, + "ppid": row.ppid, + "cpu_pct": row.cpu_pct, + "mem_pct": row.mem_pct, + "history": metric_history_json(&history), + "elapsed": &row.elapsed, + "cwd": row.cwd.as_deref(), + "agent": row.agent.map(|agent| agent.label()), + "risk": row.risk.label(), + "command": &row.command, + }) +} + +fn metric_history_json(history: &MetricHistory) -> serde_json::Value { + serde_json::json!({ + "cpu_pct": &history.cpu, + "mem_pct": &history.mem, + "net_io_bytes": &history.net_io_bytes, + "block_io_bytes": &history.block_io_bytes, + }) +} + +fn event_json(row: &EventRow) -> serde_json::Value { + serde_json::json!({ + "ts": &row.ts, + "source": &row.source, + "session": row.session.as_deref(), + "task": row.task.as_deref(), + "pid": row.pid, + "ppid": row.ppid, + "kind": &row.kind, + "message": &row.message, + "risk": row.risk.label(), + "details": row + .details + .iter() + .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) + .collect::>(), + }) +} + +fn average_u64(total: u64, samples: u64) -> Option { + total.checked_div(samples) +} + +fn empty_dash_to_null(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty() && value != "-").then_some(value) +} + +fn parse_options(args: Vec) -> anyhow::Result { + let mut options = TopOptions::default(); + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--agents" => options.tab = Tab::Agents, + "--sessions" => options.tab = Tab::Sessions, + "--containers" => options.tab = Tab::Containers, + "--processes" => options.tab = Tab::Processes, + "--events" => options.tab = Tab::Events, + "-a" | "--active" | "--active-only" => { + options.force_active_containers = true; + options.force_all_containers = false; + } + "--all" => { + options.force_all_containers = true; + options.force_active_containers = false; + } + "-f" | "--filter" => { + options.filter = Some( + it.next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?, + ); + } + "-s" | "--sort" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + options.sort_by = Some(SortBy::from_label(&value).ok_or_else(|| { + anyhow::anyhow!( + "unknown top sort field '{value}' (expected cpu, mem, net, block, pids, state, id, uptime, name, or tokens)" + ) + })?); + } + "--risk" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + options.risk_filter = Some(RiskFilter::from_label(&value).ok_or_else(|| { + anyhow::anyhow!( + "unknown top risk filter '{value}' (expected all, medium, or high)" + ) + })?); + } + "--kind" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + options.kind_filter = Some(KindFilter::from_label(&value).ok_or_else(|| { + anyhow::anyhow!( + "unknown top event kind filter '{value}' (expected all, tool, security, file, egress, llm, or other)" + ) + })?); + } + "--connector" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + options.connector = + Some(ContainerConnector::from_label(&value).ok_or_else(|| { + anyhow::anyhow!( + "unknown top connector '{value}' (expected a3s-box, docker, or runc)" + ) + })?); + } + "--container" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + options.container_query = Some(value); + options.tab = Tab::Containers; + } + "-r" | "--reverse" => options.reverse_sort = Some(true), + "-i" | "--invert" => options.invert_colors = true, + "--compact" | "--compact-columns" => options.compact_columns = true, + "--json" => options.json = true, + "--count" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + let count = value.parse::()?; + if count == 0 { + return Err(anyhow::anyhow!("--count must be greater than zero")); + } + options.json_count = Some(count); + } + "--no-header" => options.show_header = Some(false), + "--watch" | "--interval" => { + let value = it + .next() + .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; + options.interval = parse_duration(&value)?; + options.watch = true; + } + "-h" => options.start_help = true, + "--help" => { + print_help(); + std::process::exit(0); + } + "-v" | "-V" | "--version" => { + print_version(); + std::process::exit(0); + } + other if other.starts_with('-') => { + return Err(anyhow::anyhow!("unknown a3s top option '{other}'")); + } + other => { + if options.container_query.is_some() { + return Err(anyhow::anyhow!("a3s top accepts only one container target")); + } + options.container_query = Some(other.to_string()); + options.tab = Tab::Containers; + } + } + } + Ok(options) +} + +fn env_connector() -> Option { + std::env::var("A3S_TOP_CONNECTOR") + .ok() + .and_then(|value| ContainerConnector::from_label(&value)) +} + +fn print_help() { + println!( + "a3s top — live monitor for a3s-box containers, coding agents, and diagnostics\n\n\ + usage:\n \ + a3s top [container] [--agents|--sessions|--containers|--processes|--events] [--connector a3s-box|docker|runc] [-a|--active] [--all] [-f|--filter a3s-box] [-s|--sort cpu|mem|net|block|pids|state|id|uptime|name|tokens] [--risk all|medium|high] [--kind all|tool|security|file|egress|llm|other] [-r|--reverse] [--compact] [--watch 1500ms] [--json] [--count 10] [-h]\n\n\ + options:\n \ + --container ID open a ctop-style single-container view by name, CID, short CID, or ID prefix\n \ + --processes open the advanced raw process diagnostics view\n \ + -a, --active show active/running containers only, matching ctop\n \ + --all include stopped, exited, and dead containers\n \ + -f, --filter TEXT filter visible rows, matching ctop\n \ + -s, --sort FIELD sort by cpu, mem, net, block, pids, state, id, uptime, name, or tokens\n \ + -r, --reverse reverse sort order\n \ + -h open the interactive help dialog at startup; --help prints this help\n \ + --risk all|medium|high filter agent/process/session/event risk\n \ + --kind all|tool|security|file|egress|llm|other filter observer event kind\n \ + --compact restore the default compact column set, overriding saved columns\n \ + --json print one machine-readable snapshot and exit; combine with --watch for NDJSON\n \ + --count N limit JSON snapshots, useful with --json --watch\n \ + -i, --invert reverse terminal colors · -v, --version show version\n\n\ + keys:\n \ + Tab/Shift+Tab switch Agents/Containers · ↑/↓ select · Home/End jump · / or f filter · h help · s select sort · r reverse · Space/p pause\n \ + ! risk filter · Enter container menu · ← logs · → container view · x detail · a all containers · H header · o focus agent/container/session · l logs · e shell · w browser\n \ + g event kind filter · C connector · c columns · S save config · K terminate/stop · Esc clear filter/panel · q quit\n\n\ + observer:\n \ + auto-discovers Claude, Codex, and A3S Code logs; use A3S_TOP_OBSERVER_LOG(S) to add explicit NDJSON/JSON files\n \ + set A3S_TOP_OBSERVER_AUTO=0 to disable auto-discovery\n \ + set A3S_TOP_CONNECTOR=a3s-box|docker|runc to change the default container connector\n \ + runC connector honors RUNC_ROOT and RUNC_SYSTEMD_CGROUP" + ); +} + +fn print_version() { + println!("a3s top {}", env!("CARGO_PKG_VERSION")); +} + +fn top_keymap() -> Keymap { + let mut km = Keymap::new(); + km.register(KeyBinding::new(KeyCode::Char('q')), TopKey::Quit, "quit"); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('c'), KeyModifiers::CONTROL), + TopKey::Quit, + "quit", + ); + km.register(KeyBinding::new(KeyCode::Up), TopKey::Up, "select up"); + km.register(KeyBinding::new(KeyCode::Char('k')), TopKey::Up, "select up"); + km.register(KeyBinding::new(KeyCode::Down), TopKey::Down, "select down"); + km.register( + KeyBinding::new(KeyCode::Char('j')), + TopKey::Down, + "select down", + ); + km.register(KeyBinding::new(KeyCode::Home), TopKey::Home, "select first"); + km.register(KeyBinding::new(KeyCode::End), TopKey::End, "select last"); + km.register(KeyBinding::new(KeyCode::PageUp), TopKey::PageUp, "page up"); + km.register( + KeyBinding::new(KeyCode::PageDown), + TopKey::PageDown, + "page down", + ); + km.register(KeyBinding::new(KeyCode::Tab), TopKey::NextTab, "next tab"); + km.register(KeyBinding::new(KeyCode::Right), TopKey::NextTab, "next tab"); + km.register( + KeyBinding::new(KeyCode::BackTab), + TopKey::PrevTab, + "previous tab", + ); + km.register( + KeyBinding::new(KeyCode::Left), + TopKey::PrevTab, + "previous tab", + ); + km.register( + KeyBinding::new(KeyCode::Char('/')), + TopKey::Filter, + "filter", + ); + km.register( + KeyBinding::new(KeyCode::Char('f')), + TopKey::Filter, + "filter", + ); + km.register(KeyBinding::new(KeyCode::Char('h')), TopKey::Help, "help"); + km.register( + KeyBinding::new(KeyCode::Char('c')), + TopKey::Columns, + "columns", + ); + km.register( + KeyBinding::new(KeyCode::Char('C')), + TopKey::Connector, + "connector", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('C'), KeyModifiers::SHIFT), + TopKey::Connector, + "connector", + ); + km.register( + KeyBinding::new(KeyCode::Char('S')), + TopKey::SaveConfig, + "save config", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('S'), KeyModifiers::SHIFT), + TopKey::SaveConfig, + "save config", + ); + km.register(KeyBinding::new(KeyCode::Char('s')), TopKey::Sort, "sort"); + km.register( + KeyBinding::new(KeyCode::Char(' ')), + TopKey::TogglePause, + "pause", + ); + km.register( + KeyBinding::new(KeyCode::Char('p')), + TopKey::TogglePause, + "pause", + ); + km.register( + KeyBinding::new(KeyCode::Char('a')), + TopKey::ToggleAll, + "toggle all containers", + ); + km.register( + KeyBinding::new(KeyCode::Char('H')), + TopKey::ToggleHeader, + "toggle header", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('H'), KeyModifiers::SHIFT), + TopKey::ToggleHeader, + "toggle header", + ); + km.register( + KeyBinding::new(KeyCode::Char('r')), + TopKey::ToggleReverse, + "reverse sort", + ); + km.register( + KeyBinding::new(KeyCode::Char('R')), + TopKey::ToggleReverse, + "reverse sort", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('R'), KeyModifiers::SHIFT), + TopKey::ToggleReverse, + "reverse sort", + ); + km.register( + KeyBinding::new(KeyCode::Char('!')), + TopKey::ToggleRiskFilter, + "risk filter", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('!'), KeyModifiers::SHIFT), + TopKey::ToggleRiskFilter, + "risk filter", + ); + km.register( + KeyBinding::new(KeyCode::Char('g')), + TopKey::ToggleKindFilter, + "event kind filter", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('G'), KeyModifiers::SHIFT), + TopKey::ToggleKindFilter, + "event kind filter", + ); + km.register(KeyBinding::new(KeyCode::Enter), TopKey::Detail, "detail"); + km.register( + KeyBinding::new(KeyCode::Char('x')), + TopKey::Detail, + "detail", + ); + km.register( + KeyBinding::new(KeyCode::Char('o')), + TopKey::Open, + "focus container", + ); + km.register(KeyBinding::new(KeyCode::Char('l')), TopKey::Logs, "logs"); + km.register( + KeyBinding::new(KeyCode::Char('w')), + TopKey::OpenBrowser, + "open browser", + ); + km.register( + KeyBinding::new(KeyCode::Char('e')), + TopKey::ExecShell, + "exec shell", + ); + km.register( + KeyBinding::new(KeyCode::Char('K')), + TopKey::Kill, + "terminate", + ); + km.register( + KeyBinding::with_modifiers(KeyCode::Char('K'), KeyModifiers::SHIFT), + TopKey::Kill, + "terminate", + ); + km +} + +fn refresh_cmd( + connector: ContainerConnector, + show_all_containers: bool, + container_target: Option, + observer: ObserverState, +) -> Cmd { + cmd::cmd(move || async move { + let (snapshot, observer) = + collect_snapshot(connector, show_all_containers, container_target, observer).await; + Msg::Snapshot { + connector, + snapshot, + observer, + } + }) +} + +fn single_or_batch(mut cmds: Vec>) -> Cmd { + if cmds.len() == 1 { + cmds.remove(0) + } else { + cmd::batch(cmds) + } +} + +fn container_logs_cmd( + connector: ContainerConnector, + id: String, + name: String, + timestamps: bool, +) -> Cmd { + cmd::cmd(move || async move { + let result = collect_container_logs(connector, &id, timestamps).await; + Msg::ContainerLogs { + connector, + id, + name, + timestamps, + result, + } + }) +} + +fn container_processes_cmd(container: ContainerRow) -> Cmd { + cmd::cmd(move || async move { + let id = container.id.clone(); + let name = container.name.clone(); + let result = collect_container_processes(container.connector, &container.id).await; + Msg::ContainerProcesses { id, name, result } + }) +} + +fn save_config_cmd(config: TopConfig) -> Cmd { + cmd::cmd(move || async move { Msg::ConfigSaved(save_top_config(config).await) }) +} + +fn top_config_path() -> PathBuf { + if let Some(path) = std::env::var_os("A3S_TOP_CONFIG") { + return PathBuf::from(path); + } + if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(config_home).join("a3s").join("top.json"); + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home) + .join(".config") + .join("a3s") + .join("top.json"); + } + PathBuf::from(".a3s-top.json") +} + +fn load_top_config() -> Result { + let path = top_config_path(); + if !path.exists() { + return Ok(TopConfig::default()); + } + let text = + std::fs::read_to_string(&path).map_err(|err| format!("read {}: {err}", path.display()))?; + parse_top_config(&text) +} + +fn parse_top_config(text: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(text).map_err(|err| err.to_string())?; + let mut config = TopConfig::default(); + + if let Some(v) = value.get("show_all_containers").and_then(|v| v.as_bool()) { + config.show_all_containers = v; + } + if let Some(v) = value.get("show_header").and_then(|v| v.as_bool()) { + config.show_header = v; + } + if let Some(v) = value.get("reverse_sort").and_then(|v| v.as_bool()) { + config.reverse_sort = v; + } + if let Some(sort) = value + .get("sort_by") + .and_then(|v| v.as_str()) + .and_then(SortBy::from_label) + { + config.sort_by = sort; + } + if let Some(risk_filter) = value + .get("risk_filter") + .and_then(|v| v.as_str()) + .and_then(RiskFilter::from_label) + { + config.risk_filter = risk_filter; + } + if let Some(kind_filter) = value + .get("kind_filter") + .and_then(|v| v.as_str()) + .and_then(KindFilter::from_label) + { + config.kind_filter = kind_filter; + } + if let Some(connector) = value + .get("connector") + .and_then(|v| v.as_str()) + .and_then(ContainerConnector::from_label) + { + config.connector = connector; + } + if let Some(filter) = value.get("filter").and_then(|v| v.as_str()) { + config.filter = filter.to_string(); + } + if let Some(columns) = value.get("hidden_columns").and_then(|v| v.as_array()) { + config.hidden_columns = columns + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect(); + } + + Ok(config) +} + +fn top_config_json(config: &TopConfig) -> String { + let mut hidden_columns = config.hidden_columns.iter().cloned().collect::>(); + hidden_columns.sort(); + serde_json::to_string_pretty(&serde_json::json!({ + "show_all_containers": config.show_all_containers, + "show_header": config.show_header, + "reverse_sort": config.reverse_sort, + "sort_by": config.sort_by.label(), + "risk_filter": config.risk_filter.label(), + "kind_filter": config.kind_filter.label(), + "connector": config.connector.label(), + "filter": &config.filter, + "hidden_columns": hidden_columns, + })) + .unwrap_or_else(|_| "{}".to_string()) +} + +async fn save_top_config(config: TopConfig) -> Result { + let path = top_config_path(); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|err| format!("create {}: {err}", parent.display()))?; + } + tokio::fs::write(&path, top_config_json(&config)) + .await + .map_err(|err| format!("write {}: {err}", path.display()))?; + Ok(path) +} + +async fn run_external_action(action: ExternalAction) -> anyhow::Result<()> { + match action { + ExternalAction::ContainerShell { + connector, + id, + name, + } => { + println!( + "opening shell in {} container {name} ({})", + connector.label(), + short_id(&id) + ); + match connector { + ContainerConnector::A3sBox => { + let a3s_box = crate::box_cmd::ensure_a3s_box()?; + let status = std::process::Command::new(a3s_box) + .args(["shell", &id]) + .status()?; + if !status.success() { + eprintln!("a3s-box shell exited with status {status}"); + } + } + ContainerConnector::Docker => { + let shell = detect_container_shell(&id); + let status = std::process::Command::new("docker") + .args(["exec", "-it", &id, &shell]) + .status()?; + if !status.success() { + eprintln!("docker exec exited with status {status}"); + } + } + ContainerConnector::RunC => { + eprintln!("runc shell is not supported from a3s top yet"); + } + } + } + ExternalAction::OpenBrowser { url, name } => { + println!("opening browser for container {name}: {url}"); + if let Err(err) = open_url(&url) { + eprintln!("failed to open browser: {err}"); + } + } + } + Ok(()) +} + +fn open_url(url: &str) -> anyhow::Result<()> { + let status = { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(url).status() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", url]) + .status() + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + std::process::Command::new("xdg-open").arg(url).status() + } + }?; + + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "browser opener exited with status {status}" + )) + } +} + +fn detect_container_shell(id: &str) -> String { + let output = std::process::Command::new("docker") + .args([ + "exec", + id, + "sh", + "-c", + "command -v bash 2>/dev/null || command -v sh 2>/dev/null", + ]) + .output(); + let Ok(output) = output else { + return "sh".to_string(); + }; + if !output.status.success() { + return "sh".to_string(); + } + let shell = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("sh") + .trim() + .to_string(); + if shell.is_empty() { + "sh".to_string() + } else { + shell + } +} + +fn runc_command() -> Command { + let mut command = Command::new("runc"); + for arg in runc_global_args() { + command.arg(arg); + } + command +} + +fn runc_global_args() -> Vec { + let root = std::env::var("RUNC_ROOT").unwrap_or_else(|_| "/run/runc".to_string()); + let mut args = vec!["--root".to_string(), root]; + if env_flag("RUNC_SYSTEMD_CGROUP") { + args.push("--systemd-cgroup".to_string()); + } + args +} + +fn env_flag(key: &str) -> bool { + std::env::var(key) + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +async fn collect_snapshot( + connector: ContainerConnector, + show_all_containers: bool, + container_target: Option, + observer: ObserverState, +) -> (TopSnapshot, ObserverState) { + let (processes, containers, observer) = tokio::join!( + collect_processes(), + collect_containers(connector, show_all_containers, container_target.as_deref()), + collect_observer_events(observer), + ); + let (processes, process_error) = match processes { + Ok(rows) => (rows, None), + Err(err) => (Vec::new(), Some(format!("process collector: {err}"))), + }; + let (containers, container_error) = match containers { + Ok(rows) => (rows, None), + Err(err) => (Vec::new(), Some(format!("container collector: {err}"))), + }; + + let mut errors = Vec::new(); + errors.extend(process_error); + errors.extend(container_error); + let mut events = observer.events.clone(); + if events.is_empty() { + events.extend(errors.iter().map(|err| EventRow { + ts: "now".into(), + source: "collector".into(), + session: None, + task: None, + pid: None, + ppid: None, + kind: "warning".into(), + message: err.clone(), + details: vec![("error".into(), err.clone())], + risk: Risk::Medium, + })); + } + + ( + TopSnapshot { + processes, + containers, + events, + errors, + }, + observer, + ) +} + +async fn collect_containers( + connector: ContainerConnector, + show_all: bool, + target: Option<&str>, +) -> anyhow::Result> { + match connector { + ContainerConnector::A3sBox => collect_a3s_box_containers(show_all, target).await, + ContainerConnector::Docker => collect_docker_containers(show_all, target).await, + ContainerConnector::RunC => collect_runc_containers(show_all, target).await, + } +} + +async fn collect_a3s_box_containers( + show_all: bool, + target: Option<&str>, +) -> anyhow::Result> { + let a3s_box = ensure_a3s_box_binary().await?; + let mut containers = Vec::new(); + for filter in container_target_filters(target) { + containers.extend(a3s_box_ps_rows(&a3s_box, show_all, filter.as_deref()).await?); + } + dedupe_container_rows(&mut containers); + if containers.is_empty() { + return Ok(containers); + } + + if let Some(by_id) = a3s_box_stats_rows(&a3s_box).await { + for container in &mut containers { + if let Some(stats) = by_id + .get(&container.id) + .or_else(|| by_id.get(short_id(&container.id))) + .or_else(|| by_id.get(&container.name)) + { + container.cpu_pct = stats.cpu_pct; + container.cpu_count = stats.cpu_count; + container.mem_pct = stats.mem_pct; + container.mem_usage = stats.mem_usage.clone(); + container.net_io = stats.net_io.clone(); + container.block_io = stats.block_io.clone(); + if let Some(pids) = stats.pids_current { + container.pids = pids.to_string(); + } + } + } + } + enrich_a3s_box_process_counts(&mut containers, &a3s_box).await; + enrich_a3s_box_inspect(&mut containers, &a3s_box).await; + Ok(containers) +} + +async fn enrich_a3s_box_process_counts(containers: &mut [ContainerRow], a3s_box: &Path) { + let targets = containers + .iter() + .filter(|container| container_is_running(&container.status)) + .filter(|container| container.pids == "-") + .map(|container| container.id.clone()) + .collect::>(); + if targets.is_empty() { + return; + } + + let counts = stream::iter(targets.into_iter().map(|id| { + let a3s_box = a3s_box.to_path_buf(); + async move { + let count = a3s_box_container_process_count(&a3s_box, &id).await; + (id, count) + } + })) + .buffer_unordered(A3S_BOX_PIDS_CONCURRENCY) + .collect::>() + .await + .into_iter() + .filter_map(|(id, count)| count.map(|count| (id, count))) + .collect::>(); + + for container in containers { + if let Some(count) = counts.get(&container.id) { + container.pids = count.to_string(); + } + } +} + +async fn a3s_box_container_process_count(a3s_box: &Path, id: &str) -> Option { + let rows = tokio::time::timeout( + A3S_BOX_PIDS_TIMEOUT, + collect_a3s_box_container_processes_with_binary(a3s_box, id), + ) + .await + .ok()? + .ok()?; + Some(a3s_box_process_count_from_rows(&rows)) +} + +async fn a3s_box_ps_rows( + a3s_box: &Path, + show_all: bool, + filter: Option<&str>, +) -> anyhow::Result> { + let mut ps_args = vec!["ps".to_string(), "--format".to_string(), "json".to_string()]; + if show_all { + ps_args.insert(1, "-a".to_string()); + } + if let Some(filter) = filter { + ps_args.push("--filter".to_string()); + ps_args.push(filter.to_string()); + } + let json_command_label = format!("a3s-box {}", ps_args.join(" ")); + + let json_error = match Command::new(a3s_box).args(ps_args).output().await { + Ok(ps) if ps.status.success() => { + let text = String::from_utf8_lossy(&ps.stdout); + let rows = parse_a3s_box_ps_json(&text); + if !rows.is_empty() || text.trim() == "[]" { + return Ok(rows); + } + Some(format!("{json_command_label} returned unparseable JSON")) + } + Ok(ps) => Some(command_output_error(&json_command_label, &ps)), + Err(err) => Some(format!("failed to run {json_command_label}: {err}")), + }; + + match a3s_box_ps_output(a3s_box, show_all, filter).await { + Ok(output) => Ok(parse_a3s_box_ps(&output)), + Err(err) => Err(anyhow::anyhow!( + "{}; fallback table failed: {err}", + json_error.unwrap_or_else(|| "a3s-box ps JSON output was unavailable".to_string()) + )), + } +} + +async fn a3s_box_ps_output( + a3s_box: &Path, + show_all: bool, + filter: Option<&str>, +) -> anyhow::Result { + let mut ps_args = vec![ + "ps".to_string(), + "--format".to_string(), + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.Created}}\t{{.Command}}" + .to_string(), + ]; + if show_all { + ps_args.insert(1, "-a".to_string()); + } + if let Some(filter) = filter { + ps_args.push("--filter".to_string()); + ps_args.push(filter.to_string()); + } + let command_label = format!("a3s-box {}", ps_args.join(" ")); + + let ps = Command::new(a3s_box).args(ps_args).output().await; + let ps = ps.map_err(|err| anyhow::anyhow!("failed to run {command_label}: {err}"))?; + if !ps.status.success() { + return Err(anyhow::anyhow!(command_output_error(&command_label, &ps))); + } + Ok(String::from_utf8_lossy(&ps.stdout).into_owned()) +} + +fn command_output_error(command: &str, output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "no output".to_string() + }; + format!("{command} exited with status {}: {detail}", output.status) +} + +async fn a3s_box_stats_rows(a3s_box: &Path) -> Option> { + let json = Command::new(a3s_box) + .args(["stats", "--no-stream", "--format", "json"]) + .output() + .await + .ok(); + if let Some(json) = json.filter(|output| output.status.success()) { + let text = String::from_utf8_lossy(&json.stdout); + let rows = parse_a3s_box_stats_json(&text); + if !rows.is_empty() || text.trim() == "[]" { + return Some(rows); + } + } + + let table = Command::new(a3s_box) + .args(["stats", "--no-stream"]) + .output() + .await + .ok()?; + if !table.status.success() { + return None; + } + Some(parse_a3s_box_stats(&String::from_utf8_lossy(&table.stdout))) +} + +async fn ensure_a3s_box_binary() -> anyhow::Result { + // Resolving the binary scans $PATH + stats several candidate paths; the + // location is stable for the session, so memoize the first success (errors + // are not cached, so a missing binary is retried next refresh). + static A3S_BOX_BIN: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); + A3S_BOX_BIN + .get_or_try_init(|| async { + tokio::task::spawn_blocking(crate::box_cmd::ensure_a3s_box) + .await + .map_err(|err| anyhow::anyhow!("a3s-box installer task failed: {err}"))? + }) + .await + .cloned() +} + +#[derive(Debug, Clone, PartialEq)] +struct A3sBoxStatsRow { + cpu_pct: Option, + cpu_count: Option, + mem_pct: Option, + mem_usage: String, + net_io: String, + block_io: String, + pid: Option, + pids_current: Option, +} + +fn parse_a3s_box_ps(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let mut parts = line.splitn(7, '\t'); + let id = parts.next()?.trim(); + if id.is_empty() { + return None; + } + let name = parts.next().unwrap_or(id).trim(); + let image = parts.next().unwrap_or("-").trim(); + let status = parts.next().unwrap_or("-").trim(); + let ports = parts.next().unwrap_or("").trim(); + let created = parts.next().unwrap_or("-").trim(); + let command = parts.next().unwrap_or("").trim(); + let mut inspect = ContainerInspect { + created: created.to_string(), + started: created.to_string(), + ..ContainerInspect::default() + }; + if !command.is_empty() { + inspect.labels = format!("command={}", truncate(command, 80)); + } + Some(ContainerRow { + connector: ContainerConnector::A3sBox, + id: id.to_string(), + name: if name.is_empty() { + id.to_string() + } else { + name.to_string() + }, + image: if image.is_empty() { + "-".into() + } else { + image.to_string() + }, + status: if status.is_empty() { + "-".into() + } else { + status.to_string() + }, + inspect, + ports: normalize_ports(ports), + cpu_pct: None, + cpu_count: None, + cpu_usage_total_ns: None, + mem_pct: None, + mem_usage: "-".into(), + net_io: "-".into(), + block_io: "-".into(), + pids: "-".into(), + }) + }) + .collect() +} + +fn parse_a3s_box_ps_json(text: &str) -> Vec { + let Ok(value) = serde_json::from_str::(text) else { + return Vec::new(); + }; + let items = match value { + serde_json::Value::Array(items) => items, + serde_json::Value::Object(mut object) => ["containers", "boxes", "items"] + .iter() + .find_map(|key| object.remove(*key)) + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(), + _ => return Vec::new(), + }; + + items.iter().filter_map(parse_a3s_box_ps_json_row).collect() +} + +fn parse_a3s_box_ps_json_row(item: &serde_json::Value) -> Option { + let id = json_string(item, &["id", "Id", "ID"]) + .or_else(|| json_string(item, &["short_id", "shortId", "cid", "CID"]))?; + let name = json_string(item, &["name", "names", "Names"]).unwrap_or_else(|| id.clone()); + let image = json_string(item, &["image", "Image"]).unwrap_or_else(|| "-".into()); + let status = json_string(item, &["raw_status", "rawStatus", "state", "State"]) + .or_else(|| json_string(item, &["status", "Status"])) + .unwrap_or_else(|| "-".into()); + let ports = json_ports_text(item); + let created_at = json_string(item, &["created_at", "createdAt"]); + let started_at = json_string(item, &["started_at", "startedAt"]); + let created = json_string(item, &["created", "Created"]) + .or_else(|| { + created_at + .as_deref() + .map(|value| compact_timestamp(Some(value))) + }) + .unwrap_or_else(|| "-".into()); + let started = started_at + .as_deref() + .map(|value| compact_timestamp(Some(value))) + .unwrap_or_else(|| created.clone()); + let command = json_string(item, &["command", "Command"]).unwrap_or_default(); + let health = json_string(item, &["health", "Health"]) + .filter(|value| !value.eq_ignore_ascii_case("none")) + .unwrap_or_else(|| "-".into()); + let labels = a3s_box_ps_labels_summary(item.get("labels"), &command); + + Some(ContainerRow { + connector: ContainerConnector::A3sBox, + id, + name, + image, + status, + inspect: ContainerInspect { + health, + created, + started, + labels, + ..ContainerInspect::default() + }, + ports: normalize_ports(&ports), + cpu_pct: None, + cpu_count: None, + cpu_usage_total_ns: None, + mem_pct: None, + mem_usage: "-".into(), + net_io: "-".into(), + block_io: "-".into(), + pids: "-".into(), + }) +} + +fn json_ports_text(value: &serde_json::Value) -> String { + if let Some(ports) = json_string(value, &["ports_text", "portsText"]) { + return ports; + } + if let Some(ports) = value + .get("ports") + .or_else(|| value.get("Ports")) + .and_then(serde_json::Value::as_array) + { + return ports + .iter() + .filter_map(|port| match port { + serde_json::Value::String(value) => Some(value.trim().to_string()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + }) + .filter(|value| !value.is_empty()) + .collect::>() + .join(", "); + } + json_string(value, &["ports", "Ports"]).unwrap_or_default() +} + +fn a3s_box_ps_labels_summary(labels: Option<&serde_json::Value>, command: &str) -> String { + let mut parts = Vec::new(); + if !command.trim().is_empty() { + parts.push(format!("command={}", truncate(command, 80))); + } + let labels = docker_label_summary(labels); + if labels != "-" { + parts.push(labels); + } + if parts.is_empty() { + "-".into() + } else { + parts.join(", ") + } +} + +fn parse_a3s_box_stats(text: &str) -> HashMap { + let mut rows = HashMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() + || line.starts_with("BOX ID") + || line.starts_with("No active boxes") + || line.starts_with("NAME ") + { + continue; + } + let Some((id, name, stats)) = parse_a3s_box_stats_line(line) else { + continue; + }; + rows.insert(id.clone(), stats.clone()); + rows.insert(name, stats); + } + rows +} + +fn parse_a3s_box_stats_json(text: &str) -> HashMap { + let mut rows = HashMap::new(); + let Ok(value) = serde_json::from_str::(text) else { + return rows; + }; + let items = match value { + serde_json::Value::Array(items) => items, + serde_json::Value::Object(mut object) => match object.remove("containers") { + Some(serde_json::Value::Array(items)) => items, + _ => return rows, + }, + _ => return rows, + }; + + for item in items { + let Some((id, name, stats)) = parse_a3s_box_stats_json_row(&item) else { + continue; + }; + rows.insert(id.clone(), stats.clone()); + if let Some(short) = json_string(&item, &["short_id", "shortId"]).filter(|s| s != &id) { + rows.insert(short, stats.clone()); + } + rows.insert(name, stats); + } + + rows +} + +fn parse_a3s_box_stats_json_row( + item: &serde_json::Value, +) -> Option<(String, String, A3sBoxStatsRow)> { + let id = json_string(item, &["id", "short_id", "shortId"])?; + let name = json_string(item, &["name", "names"]).unwrap_or_else(|| id.clone()); + let memory_bytes = json_u64_field(item, &["memory_bytes", "memoryBytes"])?; + let memory_limit_bytes = json_u64_field(item, &["memory_limit_bytes", "memoryLimitBytes"])?; + let network_rx = json_u64_field(item, &["network_rx_bytes", "networkRxBytes"]).unwrap_or(0); + let network_tx = json_u64_field(item, &["network_tx_bytes", "networkTxBytes"]).unwrap_or(0); + let block_read = json_u64_field(item, &["block_read_bytes", "blockReadBytes"]).unwrap_or(0); + let block_write = json_u64_field(item, &["block_write_bytes", "blockWriteBytes"]).unwrap_or(0); + let mem_pct = json_f64_field(item, &["memory_percent", "memoryPercent"]).map(|v| v as f32); + let cpu_count = json_u64_field(item, &["cpus", "cpu_count", "cpuCount"]) + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0); + let pids_current = json_u64_field(item, &["pids_current", "pidsCurrent"]) + .or_else(|| item.pointer("/pids/current").and_then(json_u64)); + + Some(( + id, + name, + A3sBoxStatsRow { + cpu_pct: json_f64_field(item, &["cpu_percent", "cpuPercent"]).map(|v| v as f32), + cpu_count, + mem_pct, + mem_usage: format_byte_pair_decimal(memory_bytes, memory_limit_bytes), + net_io: format_byte_pair_decimal(network_rx, network_tx), + block_io: format_byte_pair_decimal(block_read, block_write), + pid: json_u64_field(item, &["pid"]).map(|pid| pid.to_string()), + pids_current, + }, + )) +} + +fn json_string(value: &serde_json::Value, fields: &[&str]) -> Option { + fields.iter().find_map(|field| { + value + .get(*field) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) +} + +fn json_u64_field(value: &serde_json::Value, fields: &[&str]) -> Option { + fields.iter().find_map(|field| { + let value = value.get(*field)?; + value + .as_u64() + .or_else(|| value.as_str()?.trim().parse::().ok()) + }) +} + +fn json_f64_field(value: &serde_json::Value, fields: &[&str]) -> Option { + fields.iter().find_map(|field| { + let value = value.get(*field)?; + value.as_f64().or_else(|| { + value + .as_str()? + .trim() + .trim_end_matches('%') + .parse::() + .ok() + }) + }) +} + +fn format_byte_pair_decimal(first: u64, second: u64) -> String { + format!( + "{} / {}", + format_bytes_decimal(first), + format_bytes_decimal(second) + ) +} + +fn format_bytes_decimal(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + const GB: u64 = 1024 * MB; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} + +fn parse_a3s_box_stats_line(line: &str) -> Option<(String, String, A3sBoxStatsRow)> { + let mut parts = line.split_whitespace(); + let id = parts.next()?.to_string(); + let name = parts.next()?.to_string(); + let _status = parts.next()?; + let cpu_pct = parts.next().and_then(parse_percent); + let rest = parts.collect::>(); + let mem_pct_idx = rest.iter().rposition(|part| part.ends_with('%'))?; + let mem_pct = parse_percent(rest[mem_pct_idx]); + let pid = rest.get(mem_pct_idx + 1).map(|value| (*value).to_string()); + let (net_io, block_io) = split_a3s_box_stats_io(rest.get(mem_pct_idx + 2..).unwrap_or(&[])); + let mem_usage = rest[..mem_pct_idx].join(" "); + Some(( + id, + name, + A3sBoxStatsRow { + cpu_pct, + cpu_count: None, + mem_pct, + mem_usage: if mem_usage.is_empty() { + "-".into() + } else { + mem_usage + }, + net_io, + block_io, + pid, + pids_current: None, + }, + )) +} + +fn split_a3s_box_stats_io(parts: &[&str]) -> (String, String) { + if parts.is_empty() { + return ("-".into(), "-".into()); + } + + let slash_positions = parts + .iter() + .enumerate() + .filter_map(|(idx, part)| (*part == "/").then_some(idx)) + .collect::>(); + + if slash_positions.len() >= 2 && slash_positions[1] >= 2 { + let second_pair_start = slash_positions[1] - 2; + let net_io = parts[..second_pair_start].join(" "); + let block_io = parts[second_pair_start..].join(" "); + return (non_empty_or_dash(net_io), non_empty_or_dash(block_io)); + } + + ("-".into(), parts.join(" ")) +} + +fn non_empty_or_dash(value: String) -> String { + if value.is_empty() { + "-".into() + } else { + value + } +} + +// ponytail: process-global inspect cache. `a3s-box inspect` output is +// near-static (created/started/labels/health), so a short TTL eliminates the +// per-tick fan-out (was up to A3S_BOX_INSPECT_LIMIT subprocess spawns every +// refresh). Keyed by the requested container id; pruned to live ids each pass. +static INSPECT_CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); +const INSPECT_TTL: Duration = Duration::from_secs(10); + +async fn enrich_a3s_box_inspect(containers: &mut [ContainerRow], a3s_box: &Path) { + if containers.is_empty() { + return; + } + let cache = INSPECT_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let now = Instant::now(); + + // Only inspect ids whose cached entry is missing or past its TTL. + let stale_ids = { + let map = cache.lock().unwrap(); + containers + .iter() + .take(A3S_BOX_INSPECT_LIMIT) + .filter(|container| match map.get(&container.id) { + Some((at, _)) => now.duration_since(*at) >= INSPECT_TTL, + None => true, + }) + .map(|container| container.id.clone()) + .collect::>() + }; + + if !stale_ids.is_empty() { + let fresh: Vec<(String, Option)> = stream::iter(stale_ids) + .map(|id| { + let a3s_box = a3s_box.to_path_buf(); + async move { + let inspect = collect_a3s_box_inspect(&a3s_box, &id).await; + (id, inspect) + } + }) + .buffer_unordered(8) + .collect() + .await; + let mut map = cache.lock().unwrap(); + for (id, inspect) in fresh { + if let Some(inspect) = inspect { + map.insert(id, (now, inspect)); + } + } + } + + let live_ids: HashSet = containers.iter().map(|c| c.id.clone()).collect(); + let mut map = cache.lock().unwrap(); + map.retain(|id, _| live_ids.contains(id)); + for container in containers.iter_mut() { + if let Some((_, inspect)) = map.get(&container.id) { + container.inspect = inspect.clone(); + } + } +} + +async fn collect_a3s_box_inspect(a3s_box: &Path, id: &str) -> Option { + let output = tokio::time::timeout( + Duration::from_millis(1200), + Command::new(a3s_box).args(["inspect", id]).output(), + ) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + parse_a3s_box_inspect(&String::from_utf8_lossy(&output.stdout)) + .into_values() + .next() +} + +fn parse_a3s_box_inspect(text: &str) -> HashMap { + let mut by_id = HashMap::new(); + if let Ok(value) = serde_json::from_str::(text) { + match value { + serde_json::Value::Array(items) => { + for item in items { + if let Some((id, inspect)) = parse_a3s_box_inspect_record(&item) { + by_id.insert(short_id(&id).to_string(), inspect.clone()); + by_id.insert(id, inspect); + } + } + return by_id; + } + value => { + if let Some((id, inspect)) = parse_a3s_box_inspect_record(&value) { + by_id.insert(short_id(&id).to_string(), inspect.clone()); + by_id.insert(id, inspect); + return by_id; + } + } + } + } + + for line in text.lines() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if let Some((id, inspect)) = parse_a3s_box_inspect_record(&value) { + by_id.insert(short_id(&id).to_string(), inspect.clone()); + by_id.insert(id, inspect); + } + } + by_id +} + +fn parse_a3s_box_inspect_record(value: &serde_json::Value) -> Option<(String, ContainerInspect)> { + let id = value + .get("id") + .or_else(|| value.get("Id")) + .and_then(|v| v.as_str())? + .to_string(); + let status = value + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let status_detail = value + .get("status_detail") + .unwrap_or(&serde_json::Value::Null); + let state = value.get("State").unwrap_or(&serde_json::Value::Null); + + Some(( + id, + ContainerInspect { + health: a3s_box_health_label(value, status_detail), + restarts: value + .get("restart_count") + .and_then(json_u64) + .map(|count| count.to_string()) + .unwrap_or_else(|| "-".into()), + restart_policy: a3s_box_restart_policy(value), + created: compact_timestamp(value.get("created_at").and_then(|v| v.as_str())), + started: compact_timestamp(value.get("started_at").and_then(|v| v.as_str())), + exit: a3s_box_exit_label(value, state, status), + mounts: a3s_box_mounts_summary(value), + env: a3s_box_env_summary(value.get("env")), + labels: docker_label_summary(value.get("labels")), + networks: a3s_box_network_summary(value), + }, + )) +} + +fn a3s_box_health_label(value: &serde_json::Value, status_detail: &serde_json::Value) -> String { + status_detail + .get("health") + .or_else(|| value.get("health_status")) + .and_then(|v| v.as_str()) + .filter(|health| !health.is_empty() && *health != "none") + .map(ToString::to_string) + .unwrap_or_else(|| "-".into()) +} + +fn a3s_box_restart_policy(value: &serde_json::Value) -> String { + let policy = value + .get("restart_policy") + .and_then(|v| v.as_str()) + .unwrap_or("no"); + let max = value + .get("max_restart_count") + .and_then(json_u64) + .unwrap_or(0); + if policy == "on-failure" && max > 0 { + format!("{policy}:{max}") + } else if policy.is_empty() { + "-".into() + } else { + policy.to_string() + } +} + +fn a3s_box_exit_label( + value: &serde_json::Value, + state: &serde_json::Value, + status: &str, +) -> String { + let running = state + .get("Running") + .and_then(|v| v.as_bool()) + .unwrap_or(matches!(status, "running" | "paused")); + if running { + return "-".into(); + } + value + .get("exit_code") + .or_else(|| state.get("ExitCode")) + .and_then(|v| v.as_i64()) + .map(|code| code.to_string()) + .unwrap_or_else(|| "-".into()) +} + +fn a3s_box_mounts_summary(value: &serde_json::Value) -> String { + let mut labels = Vec::new(); + collect_json_string_array(value.get("volumes"), &mut labels); + collect_json_string_array(value.get("volume_names"), &mut labels); + collect_json_string_array(value.get("tmpfs"), &mut labels); + collect_json_string_array(value.get("anonymous_volumes"), &mut labels); + if labels.is_empty() { + return "-".into(); + } + let total = labels.len(); + summarize_named_count( + total, + "mount", + labels + .into_iter() + .take(3) + .map(|label| truncate(&label, 32)) + .collect(), + ) +} + +fn a3s_box_env_summary(value: Option<&serde_json::Value>) -> String { + let Some(env) = value.and_then(|v| v.as_object()) else { + return "-".into(); + }; + if env.is_empty() { + "-".into() + } else if env.len() == 1 { + "1 var".into() + } else { + format!("{} vars", env.len()) + } +} + +fn a3s_box_network_summary(value: &serde_json::Value) -> String { + let mut labels = Vec::new(); + if let Some(name) = value.get("network_name").and_then(|v| v.as_str()) { + if !name.is_empty() { + labels.push(name.to_string()); + } + } + if labels.is_empty() { + if let Some(mode) = value.get("network_mode") { + if !mode.is_null() { + labels.push(compact_json_value(mode)); + } + } + } + collect_json_string_array(value.get("add_host"), &mut labels); + if labels.is_empty() { + return "-".into(); + } + let total = labels.len(); + summarize_named_count( + total, + "net", + labels + .into_iter() + .take(3) + .map(|label| truncate(&label, 36)) + .collect(), + ) +} + +fn collect_json_string_array(value: Option<&serde_json::Value>, out: &mut Vec) { + let Some(items) = value.and_then(|v| v.as_array()) else { + return; + }; + out.extend( + items + .iter() + .filter_map(|item| item.as_str()) + .filter(|item| !item.is_empty()) + .map(ToString::to_string), + ); +} + +async fn collect_docker_containers( + show_all: bool, + target: Option<&str>, +) -> anyhow::Result> { + let mut containers = Vec::new(); + for filter in container_target_filters(target) { + containers.extend(parse_docker_container_list( + &docker_ps_output(show_all, filter.as_deref()).await, + )); + } + dedupe_container_rows(&mut containers); + if containers.is_empty() { + return Ok(containers); + } + + let stats = Command::new("docker") + .args([ + "stats", + "--no-stream", + "--format", + "{{.ID}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}", + ]) + .output() + .await; + if let Ok(stats) = stats { + if stats.status.success() { + let stats_text = String::from_utf8_lossy(&stats.stdout); + type DockerStatsRow = (Option, Option, String, String, String, String); + let mut by_id: HashMap = HashMap::new(); + for line in stats_text.lines() { + let mut parts = line.split('\t'); + let Some(id) = parts.next() else { continue }; + let cpu = parts.next().and_then(parse_percent); + let mem_pct = parts.next().and_then(parse_percent); + let mem = parts.next().unwrap_or("-").to_string(); + let net = parts.next().unwrap_or("-").to_string(); + let block = parts.next().unwrap_or("-").to_string(); + let pids = parts.next().unwrap_or("-").to_string(); + by_id.insert(id.to_string(), (cpu, mem_pct, mem, net, block, pids)); + } + for c in &mut containers { + let short = short_id(&c.id); + let stats = by_id.get(&c.id).or_else(|| by_id.get(short)).cloned(); + if let Some((cpu, mem_pct, mem, net, block, pids)) = stats { + c.cpu_pct = cpu; + c.mem_pct = mem_pct; + c.mem_usage = mem; + c.net_io = net; + c.block_io = block; + c.pids = pids; + } + } + } + } + enrich_docker_inspect(&mut containers).await; + Ok(containers) +} + +async fn docker_ps_output(show_all: bool, filter: Option<&str>) -> String { + let mut ps_args = vec![ + "ps".to_string(), + "--no-trunc".to_string(), + "--format".to_string(), + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}".to_string(), + ]; + if show_all { + ps_args.insert(1, "-a".to_string()); + } + if let Some(filter) = filter { + ps_args.push("--filter".to_string()); + ps_args.push(filter.to_string()); + } + + let ps = Command::new("docker").args(ps_args).output().await; + let Ok(ps) = ps else { + return String::new(); + }; + if !ps.status.success() { + return String::new(); + } + String::from_utf8_lossy(&ps.stdout).into_owned() +} + +fn parse_docker_container_list(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let mut parts = line.splitn(5, '\t'); + Some(ContainerRow { + connector: ContainerConnector::Docker, + id: parts.next()?.to_string(), + name: parts.next()?.to_string(), + image: parts.next()?.to_string(), + status: parts.next().unwrap_or_default().to_string(), + inspect: ContainerInspect::default(), + ports: normalize_ports(parts.next().unwrap_or_default()), + cpu_pct: None, + cpu_count: None, + cpu_usage_total_ns: None, + mem_pct: None, + mem_usage: "-".into(), + net_io: "-".into(), + block_io: "-".into(), + pids: "-".into(), + }) + }) + .collect() +} + +async fn enrich_docker_inspect(containers: &mut [ContainerRow]) { + if containers.is_empty() { + return; + } + + let mut command = Command::new("docker"); + command.args(["inspect", "--type", "container"]); + for container in containers.iter() { + command.arg(&container.id); + } + + let output = tokio::time::timeout(Duration::from_millis(1200), command.output()) + .await + .ok() + .and_then(Result::ok); + let Some(output) = output else { + return; + }; + if !output.status.success() { + return; + } + + let inspect_by_id = parse_docker_inspect(&String::from_utf8_lossy(&output.stdout)); + for container in containers { + let short = short_id(&container.id); + if let Some(inspect) = inspect_by_id + .get(&container.id) + .or_else(|| inspect_by_id.get(short)) + { + container.inspect = inspect.clone(); + } + } +} + +fn parse_docker_inspect(text: &str) -> HashMap { + let mut by_id = HashMap::new(); + if let Ok(value) = serde_json::from_str::(text) { + match value { + serde_json::Value::Array(items) => { + for item in items { + if let Some((id, inspect)) = parse_docker_inspect_container(&item) { + by_id.insert(short_id(&id).to_string(), inspect.clone()); + by_id.insert(id, inspect); + } + } + return by_id; + } + value => { + if let Some((id, inspect)) = parse_docker_inspect_container(&value) { + by_id.insert(short_id(&id).to_string(), inspect.clone()); + by_id.insert(id, inspect); + return by_id; + } + } + } + } + + for line in text.lines() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if let Some((id, inspect)) = parse_docker_inspect_container(&value) { + by_id.insert(short_id(&id).to_string(), inspect.clone()); + by_id.insert(id, inspect); + } + } + by_id +} + +fn parse_docker_inspect_container(value: &serde_json::Value) -> Option<(String, ContainerInspect)> { + let id = value.get("Id").and_then(|v| v.as_str())?.to_string(); + let state = value.get("State").unwrap_or(&serde_json::Value::Null); + let config = value.get("Config").unwrap_or(&serde_json::Value::Null); + let host_config = value.get("HostConfig").unwrap_or(&serde_json::Value::Null); + let network_settings = value + .get("NetworkSettings") + .unwrap_or(&serde_json::Value::Null); + + Some(( + id, + ContainerInspect { + health: docker_health_label(state), + restarts: value + .get("RestartCount") + .and_then(json_u64) + .map(|count| count.to_string()) + .unwrap_or_else(|| "-".into()), + restart_policy: docker_restart_policy(host_config.get("RestartPolicy")), + created: compact_timestamp(value.get("Created").and_then(|v| v.as_str())), + started: compact_timestamp(state.get("StartedAt").and_then(|v| v.as_str())), + exit: docker_exit_label(state), + mounts: docker_mounts_summary(value.get("Mounts")), + env: docker_env_summary(config.get("Env")), + labels: docker_label_summary(config.get("Labels")), + networks: docker_network_summary(network_settings.get("Networks")), + }, + )) +} + +fn docker_health_label(state: &serde_json::Value) -> String { + if let Some(status) = state.pointer("/Health/Status").and_then(|v| v.as_str()) { + return status.to_string(); + } + if state + .get("OOMKilled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return "oom-killed".into(); + } + if state.get("Dead").and_then(|v| v.as_bool()).unwrap_or(false) { + return "dead".into(); + } + "-".into() +} + +fn docker_restart_policy(value: Option<&serde_json::Value>) -> String { + let Some(value) = value else { + return "-".into(); + }; + let name = value.get("Name").and_then(|v| v.as_str()).unwrap_or("-"); + if name.is_empty() || name == "no" { + return name.to_string(); + } + let max_retry = value + .get("MaximumRetryCount") + .and_then(json_u64) + .unwrap_or(0); + if max_retry > 0 { + format!("{name}:{max_retry}") + } else { + name.to_string() + } +} + +fn docker_exit_label(state: &serde_json::Value) -> String { + let status = state.get("Status").and_then(|v| v.as_str()).unwrap_or(""); + if matches!(status, "running" | "restarting" | "paused") { + return "-".into(); + } + + let code = state + .get("ExitCode") + .and_then(|v| v.as_i64()) + .map(|code| code.to_string()) + .unwrap_or_else(|| "-".into()); + let error = state + .get("Error") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + if !error.is_empty() { + return truncate(&format!("{code} {error}"), 80); + } + if state + .get("OOMKilled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return format!("{code} oom-killed"); + } + code +} + +fn docker_mounts_summary(value: Option<&serde_json::Value>) -> String { + let Some(items) = value.and_then(|v| v.as_array()) else { + return "-".into(); + }; + if items.is_empty() { + return "-".into(); + } + let labels = items + .iter() + .take(3) + .filter_map(|item| { + let target = item + .get("Destination") + .or_else(|| item.get("Target")) + .and_then(|v| v.as_str()) + .or_else(|| item.get("Name").and_then(|v| v.as_str())) + .or_else(|| item.get("Source").and_then(|v| v.as_str()))?; + let mode = if item.get("RW").and_then(|v| v.as_bool()).unwrap_or(true) { + "rw" + } else { + "ro" + }; + Some(format!("{}({mode})", truncate(target, 28))) + }) + .collect::>(); + summarize_named_count(items.len(), "mount", labels) +} + +fn docker_env_summary(value: Option<&serde_json::Value>) -> String { + let Some(items) = value.and_then(|v| v.as_array()) else { + return "-".into(); + }; + if items.is_empty() { + "-".into() + } else if items.len() == 1 { + "1 var".into() + } else { + format!("{} vars", items.len()) + } +} + +fn docker_label_summary(value: Option<&serde_json::Value>) -> String { + let Some(labels) = value.and_then(|v| v.as_object()) else { + return "-".into(); + }; + if labels.is_empty() { + return "-".into(); + } + let mut keys = labels.keys().cloned().collect::>(); + keys.sort(); + summarize_named_count( + keys.len(), + "label", + keys.into_iter() + .take(3) + .map(|key| truncate(&key, 32)) + .collect(), + ) +} + +fn docker_network_summary(value: Option<&serde_json::Value>) -> String { + let Some(networks) = value.and_then(|v| v.as_object()) else { + return "-".into(); + }; + if networks.is_empty() { + return "-".into(); + } + let mut labels = networks + .iter() + .map(|(name, detail)| { + let ip = detail + .get("IPAddress") + .and_then(|v| v.as_str()) + .filter(|ip| !ip.is_empty()); + match ip { + Some(ip) => format!("{name} {ip}"), + None => name.clone(), + } + }) + .collect::>(); + labels.sort(); + summarize_named_count( + labels.len(), + "net", + labels + .into_iter() + .take(3) + .map(|label| truncate(&label, 36)) + .collect(), + ) +} + +fn summarize_named_count(total: usize, singular: &str, labels: Vec) -> String { + let plural = if total == 1 { + singular.to_string() + } else { + format!("{singular}s") + }; + if labels.is_empty() { + return format!("{total} {plural}"); + } + let suffix = if total > labels.len() { + format!(", +{}", total - labels.len()) + } else { + String::new() + }; + format!("{total} {plural}: {}{suffix}", labels.join(", ")) +} + +fn compact_timestamp(value: Option<&str>) -> String { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return "-".into(); + }; + if value.starts_with("0001-") { + return "-".into(); + } + let value = value.trim_end_matches('Z'); + let value = value.split('.').next().unwrap_or(value).replace('T', " "); + truncate(&value, 32) +} + +async fn collect_runc_containers( + show_all: bool, + target: Option<&str>, +) -> anyhow::Result> { + let output = runc_command() + .args(["list", "--format", "json"]) + .output() + .await; + let Ok(output) = output else { + return Ok(Vec::new()); + }; + if !output.status.success() { + return Ok(Vec::new()); + } + let text = String::from_utf8_lossy(&output.stdout); + let mut containers = parse_runc_list(&text)?; + filter_runc_container_rows(&mut containers, show_all, target); + let ids = containers + .iter() + .filter(|container| container_is_running(&container.status)) + .map(|container| container.id.clone()) + .collect::>(); + let stats = futures::future::join_all(ids.iter().map(|id| collect_runc_stats(id))).await; + let stats_by_id = ids + .into_iter() + .zip(stats) + .filter_map(|(id, stats)| stats.map(|stats| (id, stats))) + .collect::>(); + for container in &mut containers { + if let Some(stats) = stats_by_id.get(&container.id) { + apply_runc_stats(container, stats); + } + } + Ok(containers) +} + +fn filter_runc_container_rows(rows: &mut Vec, show_all: bool, target: Option<&str>) { + let target = target.map(str::trim).filter(|target| !target.is_empty()); + rows.retain(|row| { + (show_all || container_is_running(&row.status)) + && target.is_none_or(|query| container_matches_query(row, query)) + }); +} + +fn parse_runc_list(text: &str) -> anyhow::Result> { + let value: serde_json::Value = serde_json::from_str(text)?; + let Some(items) = value.as_array() else { + return Ok(Vec::new()); + }; + Ok(items.iter().filter_map(parse_runc_container).collect()) +} + +fn parse_runc_container(value: &serde_json::Value) -> Option { + let id = value.get("id").and_then(|v| v.as_str())?.to_string(); + let status = value + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let bundle = value.get("bundle").and_then(|v| v.as_str()).unwrap_or("-"); + let rootfs = value.get("rootfs").and_then(|v| v.as_str()).unwrap_or("-"); + let pid = value + .get("pid") + .and_then(|v| v.as_i64()) + .filter(|pid| *pid > 0) + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "-".to_string()); + Some(ContainerRow { + connector: ContainerConnector::RunC, + id: id.clone(), + name: id, + image: if rootfs == "-" { + format!("bundle:{bundle}") + } else { + format!("rootfs:{rootfs}") + }, + status, + inspect: ContainerInspect::default(), + cpu_pct: None, + cpu_count: None, + cpu_usage_total_ns: None, + mem_pct: None, + mem_usage: "-".into(), + net_io: "-".into(), + block_io: "-".into(), + pids: pid, + ports: "-".into(), + }) +} + +async fn collect_runc_stats(id: &str) -> Option { + let mut command = runc_command(); + command.args(["events", "--stats", id]); + let output = tokio::time::timeout(Duration::from_millis(800), command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + parse_runc_stats_event(&String::from_utf8_lossy(&output.stdout)) +} + +fn apply_runc_stats(container: &mut ContainerRow, stats: &RuncStats) { + container.cpu_usage_total_ns = stats.cpu_usage_total_ns; + if let Some(usage) = stats.memory_usage { + if let Some(limit) = stats.memory_limit.filter(|limit| *limit > 0) { + container.mem_pct = Some((usage as f32 / limit as f32) * 100.0); + container.mem_usage = format!("{} / {}", format_bytes(usage), format_bytes(limit)); + } else { + container.mem_usage = format_bytes(usage); + } + } + if stats.net_rx > 0 || stats.net_tx > 0 { + container.net_io = format_byte_pair(stats.net_rx, stats.net_tx); + } + if stats.block_read > 0 || stats.block_write > 0 { + container.block_io = format_byte_pair(stats.block_read, stats.block_write); + } + if let Some(pids) = stats.pids_current { + container.pids = pids.to_string(); + } +} + +fn parse_runc_stats_event(text: &str) -> Option { + let value = serde_json::from_str::(text) + .ok() + .or_else(|| { + text.lines() + .find_map(|line| serde_json::from_str::(line).ok()) + })?; + let data = value.get("data").unwrap_or(&value); + let mut stats = RuncStats { + cpu_usage_total_ns: data.pointer("/cpu/usage/total").and_then(json_u64), + memory_usage: data + .pointer("/memory/usage/usage") + .and_then(json_u64) + .or_else(|| data.pointer("/memory/raw/usage").and_then(json_u64)), + memory_limit: data + .pointer("/memory/usage/limit") + .and_then(json_u64) + .or_else(|| data.pointer("/memory/raw/limit").and_then(json_u64)), + pids_current: data.pointer("/pids/current").and_then(json_u64), + ..RuncStats::default() + }; + + if let Some(interfaces) = data + .get("network_interfaces") + .or_else(|| data.get("networkInterfaces")) + .and_then(|v| v.as_array()) + { + for interface in interfaces { + stats.net_rx += json_field_u64(interface, &["RxBytes", "rxBytes", "rx_bytes"]); + stats.net_tx += json_field_u64(interface, &["TxBytes", "txBytes", "tx_bytes"]); + } + } + + if let Some(entries) = data + .pointer("/blkio/ioServiceBytesRecursive") + .or_else(|| data.pointer("/blkio/io_service_bytes_recursive")) + .and_then(|v| v.as_array()) + { + for entry in entries { + let op = entry + .get("op") + .or_else(|| entry.get("Op")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let value = json_field_u64(entry, &["value", "Value"]); + if op == "read" { + stats.block_read += value; + } else if op == "write" { + stats.block_write += value; + } + } + } + + Some(stats) +} + +fn container_lifecycle_events( + connector: ContainerConnector, + previous: &[ContainerRow], + current: &[ContainerRow], +) -> Vec { + let previous_by_id = previous + .iter() + .map(|row| (row.id.as_str(), row)) + .collect::>(); + let current_by_id = current + .iter() + .map(|row| (row.id.as_str(), row)) + .collect::>(); + let mut events = Vec::new(); + + for row in current { + let previous = previous_by_id.get(row.id.as_str()).copied(); + let current_state = container_state_label(&row.status); + let previous_state = previous.map(|row| container_state_label(&row.status)); + if previous_state.as_deref() != Some(current_state.as_str()) { + let action = container_transition_action(previous_state.as_deref(), ¤t_state); + events.push(container_event_row( + connector, + action, + previous_state.as_deref(), + row, + )); + continue; + } + + if let Some(previous) = previous { + let previous_health = previous.inspect.health.as_str(); + let current_health = row.inspect.health.as_str(); + if current_health != "-" && previous_health != current_health && previous_health != "-" + { + events.push(container_event_row( + connector, + "health_status", + Some(previous_health), + row, + )); + } + } + } + + for row in previous { + if !current_by_id.contains_key(row.id.as_str()) { + let previous_state = container_state_label(&row.status); + events.push(container_event_row( + connector, + "destroy", + Some(&previous_state), + row, + )); + } + } + + events.sort_by(|a, b| a.message.cmp(&b.message)); + events +} + +fn container_transition_action(previous: Option<&str>, current: &str) -> &'static str { + match (previous, current) { + (None, "created") => "create", + (None, "running") => "start", + (None, "paused") => "pause", + (None, "exited" | "dead") => "die", + (Some("created"), "running") => "start", + (Some("running"), "paused") => "pause", + (Some("paused"), "running") => "unpause", + (Some("exited" | "dead"), "running") => "restart", + (Some(old), "exited" | "dead") if old != current => "die", + (Some(old), _) if old != current => "update", + _ => "update", + } +} + +fn container_event_row( + connector: ContainerConnector, + action: &'static str, + previous_status: Option<&str>, + row: &ContainerRow, +) -> EventRow { + let status = container_state_label(&row.status); + let mut details = vec![ + ("connector".into(), connector.label().into()), + ("action".into(), action.into()), + ("id".into(), row.id.clone()), + ("cid".into(), short_id(&row.id).to_string()), + ("name".into(), row.name.clone()), + ("image".into(), row.image.clone()), + ("status".into(), status.clone()), + ]; + if let Some(previous_status) = previous_status { + details.push(("previous_status".into(), previous_status.to_string())); + } + if row.inspect.health != "-" { + details.push(("health".into(), row.inspect.health.clone())); + } + if row.pids != "-" { + details.push(("pids".into(), row.pids.clone())); + } + + EventRow { + ts: "now".into(), + source: connector.label().into(), + session: None, + task: None, + pid: None, + ppid: None, + kind: "Container".into(), + message: format!( + "{action} {} ({}) · status {status}", + row.name, + short_id(&row.id) + ), + details, + risk: match action { + "die" | "destroy" | "pause" | "restart" | "update" => Risk::Medium, + _ => Risk::Low, + }, + } +} + +fn push_runtime_events(current: &mut Vec, mut rows: Vec) { + if rows.is_empty() { + return; + } + rows.append(current); + rows.truncate(200); + *current = rows; +} + +fn prepend_runtime_events(events: &mut Vec, runtime_events: &[EventRow]) { + if runtime_events.is_empty() { + return; + } + let mut merged = runtime_events.to_vec(); + merged.append(events); + merged.truncate(200); + *events = merged; +} + +async fn collect_observer_events(mut state: ObserverState) -> ObserverState { + let sources = observer_paths(); + if sources.paths.is_empty() { + return ObserverState::default(); + } + + if state.paths != sources.paths || state.auto_paths != sources.auto_paths { + state.paths = sources.paths.clone(); + state.auto_paths = sources.auto_paths.clone(); + state.files.retain(|path, _| sources.paths.contains(path)); + } + + for path in sources.paths { + collect_observer_path(&mut state, path).await; + } + trim_observer_events(&mut state.events); + state +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ObserverPaths { + paths: Vec, + auto_paths: HashSet, +} + +fn observer_paths() -> ObserverPaths { + let explicit = observer_paths_from_env(); + let mut paths = Vec::new(); + let mut auto_paths = HashSet::new(); + + for path in explicit { + push_unique_path(&mut paths, path); + } + + if observer_auto_enabled() { + for path in observer_paths_from_auto() { + let inserted = push_unique_path(&mut paths, path.clone()); + if inserted { + auto_paths.insert(path); + } + } + } + + ObserverPaths { paths, auto_paths } +} + +fn observer_paths_from_env() -> Vec { + let Some(value) = std::env::var_os("A3S_TOP_OBSERVER_LOGS") + .or_else(|| std::env::var_os("A3S_TOP_OBSERVER_LOG")) + else { + return Vec::new(); + }; + let text = value.to_string_lossy(); + let raw_paths = if text.contains(',') { + text.split(',') + .map(|part| PathBuf::from(part.trim())) + .collect::>() + } else { + std::env::split_paths(&value).collect::>() + }; + raw_paths + .into_iter() + .filter(|path| !path.as_os_str().is_empty()) + .collect() +} + +fn observer_auto_enabled() -> bool { + std::env::var("A3S_TOP_OBSERVER_AUTO") + .map(|value| { + let value = value.trim().to_ascii_lowercase(); + !matches!(value.as_str(), "0" | "false" | "off" | "no") + }) + .unwrap_or(true) +} + +fn observer_paths_from_auto() -> Vec { + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return Vec::new(); + }; + + let mut paths = Vec::new(); + for path in [ + home.join(".a3s").join("observer").join("events.ndjson"), + home.join(".a3s").join("observer").join("events.jsonl"), + home.join(".a3s").join("top").join("events.ndjson"), + home.join(".a3s").join("top").join("events.jsonl"), + home.join(".a3s").join("events.ndjson"), + home.join(".a3s").join("events.jsonl"), + home.join(".codex").join("history.jsonl"), + ] { + if path.is_file() { + push_unique_path(&mut paths, path); + } + } + + for path in recent_files_with_extension( + &home.join(".claude").join("projects"), + "jsonl", + 3, + OBSERVER_AUTO_MAX_FILES_PER_AGENT, + ) { + push_unique_path(&mut paths, path); + } + + for path in recent_files_with_extension( + &home.join(".codex").join("sessions"), + "jsonl", + 5, + OBSERVER_AUTO_MAX_FILES_PER_AGENT, + ) { + push_unique_path(&mut paths, path); + } + + for path in recent_files_with_extension( + &home.join(".a3s").join("tui-sessions").join("runs"), + "json", + 1, + OBSERVER_AUTO_MAX_FILES_PER_AGENT, + ) { + push_unique_path(&mut paths, path); + } + + for path in recent_files_with_extension( + &home.join(".a3s").join("runtime-workspaces"), + "json", + 4, + OBSERVER_AUTO_MAX_FILES_PER_AGENT, + ) + .into_iter() + .filter(|path| path_contains_component(path, "runs")) + { + push_unique_path(&mut paths, path); + } + + for path in recent_files_with_extension( + &home.join(".a3s").join("workspace"), + "json", + 6, + OBSERVER_AUTO_MAX_FILES_PER_AGENT, + ) + .into_iter() + .filter(|path| path_contains_component(path, "runs")) + { + push_unique_path(&mut paths, path); + } + + paths +} + +fn recent_files_with_extension( + root: &Path, + extension: &str, + max_depth: usize, + limit: usize, +) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + + let mut found = Vec::new(); + let mut visited = 0usize; + collect_recent_files(root, extension, max_depth, &mut visited, &mut found); + found.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + found + .into_iter() + .take(limit) + .map(|(_, path)| path) + .collect() +} + +fn collect_recent_files( + dir: &Path, + extension: &str, + depth: usize, + visited: &mut usize, + found: &mut Vec<(u128, PathBuf)>, +) { + if *visited >= OBSERVER_AUTO_MAX_SCAN_FILES { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + if *visited >= OBSERVER_AUTO_MAX_SCAN_FILES { + return; + } + *visited += 1; + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + if depth > 0 && !observer_scan_dir_is_ignored(&path) { + collect_recent_files(&path, extension, depth - 1, visited, found); + } + continue; + } + if !file_type.is_file() + || path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| !ext.eq_ignore_ascii_case(extension)) + .unwrap_or(true) + { + continue; + } + let modified = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + found.push((modified, path)); + } +} + +fn observer_scan_dir_is_ignored(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| { + matches!( + name, + ".git" | "cache" | "memory" | "buildcache" | "artifacts" + ) + }) + .unwrap_or(false) +} + +fn path_contains_component(path: &Path, component: &str) -> bool { + path.components().any(|part| { + part.as_os_str() + .to_str() + .map(|value| value == component) + .unwrap_or(false) + }) +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) -> bool { + if paths.iter().any(|existing| existing == &path) { + false + } else { + paths.push(path); + true + } +} + +fn observer_status_label(state: &ObserverState) -> String { + match state.paths.as_slice() { + [] => "obs:off".to_string(), + [path] if state.auto_paths.contains(path) => format!( + "obs:auto:{}", + path.file_name().and_then(|s| s.to_str()).unwrap_or("log") + ), + [path] => format!( + "obs:{}", + path.file_name().and_then(|s| s.to_str()).unwrap_or("log") + ), + paths if paths.iter().all(|path| state.auto_paths.contains(path)) => { + format!("obs:auto:{}", paths.len()) + } + paths => format!("obs:{} files", paths.len()), + } +} + +async fn collect_observer_path(state: &mut ObserverState, path: PathBuf) { + let mut file_state = state.files.remove(&path).unwrap_or_default(); + let is_auto = state.auto_paths.contains(&path); + + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(err) => { + push_observer_event(state, observer_error_event(&path, "stat", err.to_string())); + state.files.insert(path, file_state); + return; + } + }; + if is_observer_json_document(&path) { + collect_observer_json_document_path(state, path, file_state, &metadata).await; + return; + } + if metadata.len() < file_state.offset { + file_state.offset = 0; + file_state.pending.clear(); + } + if is_auto && file_state.offset == 0 && metadata.len() > OBSERVER_AUTO_INITIAL_TAIL_BYTES { + file_state.offset = metadata + .len() + .saturating_sub(OBSERVER_AUTO_INITIAL_TAIL_BYTES); + file_state.pending.clear(); + } + + let mut file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(err) => { + push_observer_event(state, observer_error_event(&path, "read", err.to_string())); + state.files.insert(path, file_state); + return; + } + }; + if let Err(err) = file.seek(SeekFrom::Start(file_state.offset)).await { + push_observer_event(state, observer_error_event(&path, "seek", err.to_string())); + state.files.insert(path, file_state); + return; + } + + let mut chunk = String::new(); + match file.read_to_string(&mut chunk).await { + Ok(_) => { + file_state.offset = file_state.offset.saturating_add(chunk.len() as u64); + let rows = append_observer_chunk(&mut file_state, &chunk); + push_observer_rows(state, rows); + } + Err(err) => { + push_observer_event(state, observer_error_event(&path, "read", err.to_string())) + } + } + state.files.insert(path, file_state); +} + +async fn collect_observer_json_document_path( + state: &mut ObserverState, + path: PathBuf, + mut file_state: ObserverFileState, + metadata: &std::fs::Metadata, +) { + let text = match tokio::fs::read_to_string(&path).await { + Ok(text) => text, + Err(err) => { + push_observer_event(state, observer_error_event(&path, "read", err.to_string())); + state.files.insert(path, file_state); + return; + } + }; + let mut rows = parse_observer_json_document(&text); + if metadata.len() < file_state.offset || rows.len() < file_state.json_events_seen { + file_state.json_events_seen = 0; + } + let seen = file_state.json_events_seen.min(rows.len()); + file_state.json_events_seen = rows.len(); + file_state.offset = metadata.len(); + if seen < rows.len() { + rows.drain(..seen); + rows.reverse(); + push_observer_rows(state, rows); + } + state.files.insert(path, file_state); +} + +fn is_observer_json_document(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("json")) + .unwrap_or(false) +} + +fn observer_error_event(path: &std::path::Path, action: &str, error: String) -> EventRow { + EventRow { + ts: "now".into(), + source: "observer".into(), + session: None, + task: None, + pid: None, + ppid: None, + kind: "error".into(), + message: format!( + "failed to {action} observer log {}: {error}", + path.display() + ), + details: vec![ + ("path".into(), path.display().to_string()), + ("error".into(), error), + ], + risk: Risk::Medium, + } +} + +fn append_observer_chunk(file: &mut ObserverFileState, chunk: &str) -> Vec { + if chunk.is_empty() && file.pending.is_empty() { + return Vec::new(); + } + let mut text = String::new(); + text.push_str(&file.pending); + text.push_str(chunk); + + let complete = if text.ends_with('\n') { + file.pending.clear(); + text.as_str() + } else if let Some((head, tail)) = text.rsplit_once('\n') { + file.pending = tail.to_string(); + head + } else { + file.pending = text; + "" + }; + + let mut rows = complete + .lines() + .filter_map(parse_observer_line) + .collect::>(); + rows.reverse(); + rows +} + +fn push_observer_rows(state: &mut ObserverState, mut rows: Vec) { + if rows.is_empty() { + return; + } + rows.append(&mut state.events); + state.events = rows; +} + +fn push_observer_event(state: &mut ObserverState, event: EventRow) { + state.events.insert(0, event); +} + +fn trim_observer_events(events: &mut Vec) { + if events.len() <= OBSERVER_EVENT_LIMIT { + events.sort_by_key(|event| std::cmp::Reverse(observer_event_sort_key(event))); + return; + } + events.sort_by_key(|event| std::cmp::Reverse(observer_event_sort_key(event))); + events.truncate(OBSERVER_EVENT_LIMIT); +} + +fn observer_event_sort_key(event: &EventRow) -> u128 { + observer_timestamp_sort_key(&event.ts).unwrap_or(0) +} + +fn observer_timestamp_sort_key(ts: &str) -> Option { + let ts = ts.trim(); + if ts.is_empty() || matches!(ts, "recent" | "now") { + return Some(u128::MAX); + } + if ts.chars().all(|c| c.is_ascii_digit()) { + let value = ts.parse::().ok()?; + return Some(match ts.len() { + 0..=10 => value.saturating_mul(1000), + 11..=13 => value, + 14..=16 => value / 1000, + _ => value / 1_000_000, + }); + } + parse_iso_timestamp_millis(ts) +} + +fn parse_iso_timestamp_millis(ts: &str) -> Option { + let mut digits = ts + .chars() + .filter(|c| c.is_ascii_digit()) + .map(|c| (c as u8 - b'0') as i32); + let year = take_digits(&mut digits, 4)?; + let month = take_digits(&mut digits, 2)?; + let day = take_digits(&mut digits, 2)?; + let hour = take_digits(&mut digits, 2).unwrap_or(0); + let minute = take_digits(&mut digits, 2).unwrap_or(0); + let second = take_digits(&mut digits, 2).unwrap_or(0); + let millis = take_digits(&mut digits, 3).unwrap_or(0); + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=60).contains(&second) + { + return None; + } + let days = days_from_civil(year, month, day)?; + Some( + (days as u128) + .saturating_mul(86_400_000) + .saturating_add((hour as u128).saturating_mul(3_600_000)) + .saturating_add((minute as u128).saturating_mul(60_000)) + .saturating_add((second as u128).saturating_mul(1000)) + .saturating_add(millis as u128), + ) +} + +fn take_digits(digits: &mut impl Iterator, count: usize) -> Option { + let mut value = 0; + for _ in 0..count { + value = value * 10 + digits.next()?; + } + Some(value) +} + +fn days_from_civil(year: i32, month: i32, day: i32) -> Option { + let year = year - (month <= 2) as i32; + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = year - era * 400; + let month = month + if month > 2 { -3 } else { 9 }; + let doy = (153 * month + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era as i64 * 146_097 + doe as i64 - 719_468; + (days >= 0).then_some(days) +} + +async fn collect_container_logs( + connector: ContainerConnector, + id: &str, + timestamps: bool, +) -> Result { + let mut command = match connector { + ContainerConnector::A3sBox => { + let a3s_box = ensure_a3s_box_binary() + .await + .map_err(|err| err.to_string())?; + let mut command = Command::new(a3s_box); + command.args(["logs", "--tail", "200"]); + command + } + ContainerConnector::Docker => { + let mut command = Command::new("docker"); + command.args(["logs", "--tail", "200"]); + command + } + ContainerConnector::RunC => { + return Err("runc logs are not supported".to_string()); + } + }; + if timestamps { + command.arg("--timestamps"); + } + command.arg(id); + + let output = command.output().await.map_err(|err| err.to_string())?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() { + let mut text = String::new(); + text.push_str(stdout.trim_end()); + if !stderr.trim().is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(stderr.trim_end()); + } + Ok(text) + } else { + let reason = if stderr.trim().is_empty() { + output.status.to_string() + } else { + stderr.trim().to_string() + }; + Err(reason) + } +} + +async fn collect_container_processes( + connector: ContainerConnector, + id: &str, +) -> Result, String> { + match connector { + ContainerConnector::A3sBox => collect_a3s_box_container_processes(id).await, + ContainerConnector::Docker => collect_docker_container_processes(id).await, + ContainerConnector::RunC => collect_runc_container_processes(id).await, + } +} + +async fn collect_a3s_box_container_processes(id: &str) -> Result, String> { + let a3s_box = ensure_a3s_box_binary() + .await + .map_err(|err| err.to_string())?; + collect_a3s_box_container_processes_with_binary(&a3s_box, id).await +} + +async fn collect_a3s_box_container_processes_with_binary( + a3s_box: &Path, + id: &str, +) -> Result, String> { + let json = Command::new(a3s_box) + .args(["top", id, "--format", "json"]) + .output() + .await + .map_err(|err| err.to_string())?; + if json.status.success() { + let stdout = String::from_utf8_lossy(&json.stdout); + if let Some(rows) = parse_a3s_box_top_json(&stdout) { + return Ok(filter_a3s_box_probe_processes(rows)); + } + } + + let output = Command::new(a3s_box) + .args(["top", id, "--", "-eo", "pid,ppid,pcpu,pmem,etime,args"]) + .output() + .await + .map_err(|err| err.to_string())?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() { + Ok(filter_a3s_box_probe_processes( + parse_container_process_table(&stdout), + )) + } else if stderr.trim().is_empty() { + Err(output.status.to_string()) + } else { + Err(stderr.trim().to_string()) + } +} + +fn parse_a3s_box_top_json(text: &str) -> Option> { + let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?; + let items = value.as_array()?; + Some( + items + .iter() + .filter_map(parse_a3s_box_top_json_row) + .collect(), + ) +} + +fn parse_a3s_box_top_json_row(item: &serde_json::Value) -> Option { + Some(ContainerProcessRow { + pid: json_value_string(item, &["pid", "PID"])?, + ppid: json_value_string(item, &["ppid", "parent_pid", "PPID"]) + .unwrap_or_else(|| "-".to_string()), + cpu_pct: json_f64_field(item, &["cpu_percent", "cpu_pct", "pcpu", "%cpu", "CPU"]) + .map(|value| value as f32), + mem_pct: json_f64_field( + item, + &[ + "memory_percent", + "mem_percent", + "mem_pct", + "pmem", + "%mem", + "MEM", + ], + ) + .map(|value| value as f32), + elapsed: json_value_string(item, &["elapsed", "etime", "time", "ELAPSED"]) + .unwrap_or_else(|| "-".to_string()), + command: json_value_string(item, &["command", "cmd", "args", "COMMAND"]) + .unwrap_or_else(|| "-".to_string()), + }) +} + +fn json_value_string(value: &serde_json::Value, fields: &[&str]) -> Option { + fields.iter().find_map(|field| { + let value = value.get(*field)?; + if let Some(text) = value.as_str() { + return Some(text.trim().to_string()).filter(|text| !text.is_empty()); + } + if let Some(number) = value.as_u64() { + return Some(number.to_string()); + } + if let Some(number) = value.as_i64() { + return Some(number.to_string()); + } + if let Some(number) = value.as_f64() { + return Some(number.to_string()); + } + None + }) +} + +fn filter_a3s_box_probe_processes(mut rows: Vec) -> Vec { + if let Some(idx) = rows + .iter() + .position(|row| is_a3s_box_process_probe(&row.command)) + { + rows.remove(idx); + } + rows +} + +fn a3s_box_process_count_from_rows(rows: &[ContainerProcessRow]) -> usize { + rows.iter() + .filter(|row| !is_a3s_box_process_probe(&row.command)) + .count() +} + +fn is_a3s_box_process_probe(command: &str) -> bool { + let command = command.trim(); + command == "ps" + || command.starts_with("ps -eo pid,ppid,pcpu,pmem,etime,args") + || command.starts_with("/bin/ps -eo pid,ppid,pcpu,pmem,etime,args") + || command.starts_with("/usr/bin/ps -eo pid,ppid,pcpu,pmem,etime,args") +} + +async fn collect_docker_container_processes(id: &str) -> Result, String> { + let output = Command::new("docker") + .args(["top", id, "-eo", "pid,ppid,pcpu,pmem,etime,args"]) + .output() + .await + .map_err(|err| err.to_string())?; + if output.status.success() { + let rows = parse_container_process_table(&String::from_utf8_lossy(&output.stdout)); + if !rows.is_empty() { + return Ok(rows); + } + } + + let fallback = Command::new("docker") + .args(["top", id]) + .output() + .await + .map_err(|err| err.to_string())?; + let stdout = String::from_utf8_lossy(&fallback.stdout); + let stderr = String::from_utf8_lossy(&fallback.stderr); + if fallback.status.success() { + Ok(parse_container_process_table(&stdout)) + } else if stderr.trim().is_empty() { + Err(fallback.status.to_string()) + } else { + Err(stderr.trim().to_string()) + } +} + +async fn collect_runc_container_processes(id: &str) -> Result, String> { + let mut command = runc_command(); + let output = command + .args(["ps", id]) + .output() + .await + .map_err(|err| err.to_string())?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() { + Ok(parse_runc_processes(&stdout)) + } else if stderr.trim().is_empty() { + Err(output.status.to_string()) + } else { + Err(stderr.trim().to_string()) + } +} + +fn parse_container_process_table(text: &str) -> Vec { + let mut lines = text.lines().filter(|line| !line.trim().is_empty()); + let Some(header) = lines.next() else { + return Vec::new(); + }; + let headers = header + .split_whitespace() + .map(|part| part.trim().to_ascii_uppercase()) + .collect::>(); + let pid_idx = headers.iter().position(|part| part == "PID"); + let ppid_idx = headers.iter().position(|part| part == "PPID"); + let cpu_idx = headers + .iter() + .position(|part| matches!(part.as_str(), "%CPU" | "PCPU" | "CPU%")); + let mem_idx = headers + .iter() + .position(|part| matches!(part.as_str(), "%MEM" | "PMEM" | "MEM%")); + let elapsed_idx = headers + .iter() + .position(|part| matches!(part.as_str(), "ELAPSED" | "ETIME" | "TIME")); + let command_idx = headers + .iter() + .position(|part| matches!(part.as_str(), "COMMAND" | "CMD" | "ARGS")); + + lines + .filter_map(|line| { + parse_container_process_line( + line, + pid_idx?, + ppid_idx, + cpu_idx, + mem_idx, + elapsed_idx, + command_idx, + ) + }) + .collect() +} + +fn parse_container_process_line( + line: &str, + pid_idx: usize, + ppid_idx: Option, + cpu_idx: Option, + mem_idx: Option, + elapsed_idx: Option, + command_idx: Option, +) -> Option { + let parts = line.split_whitespace().collect::>(); + let pid = parts.get(pid_idx)?.to_string(); + let ppid = ppid_idx + .and_then(|idx| parts.get(idx)) + .map(|part| (*part).to_string()) + .unwrap_or_else(|| "-".to_string()); + let command = command_idx + .and_then(|idx| (idx < parts.len()).then(|| parts[idx..].join(" "))) + .unwrap_or_else(|| "-".to_string()); + Some(ContainerProcessRow { + pid, + ppid, + cpu_pct: cpu_idx + .and_then(|idx| parts.get(idx)) + .and_then(|value| parse_percent(value)), + mem_pct: mem_idx + .and_then(|idx| parts.get(idx)) + .and_then(|value| parse_percent(value)), + elapsed: elapsed_idx + .and_then(|idx| parts.get(idx)) + .map(|part| (*part).to_string()) + .unwrap_or_else(|| "-".to_string()), + command, + }) +} + +fn parse_runc_processes(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let pid = line.split_whitespace().next()?.trim(); + if pid.parse::().is_err() { + return None; + } + Some(ContainerProcessRow { + pid: pid.to_string(), + ppid: "-".to_string(), + cpu_pct: None, + mem_pct: None, + elapsed: "-".to_string(), + command: "runc process".to_string(), + }) + }) + .collect() +} + +fn container_menu_items(container: &ContainerRow) -> Vec { + let running = container_is_running(&container.status); + let paused = container_is_paused(&container.status); + let mut items = vec![ContainerMenuItem { + action: ContainerMenuAction::Focus, + key: 'o', + label: "Open single view".to_string(), + }]; + + if matches!( + container.connector, + ContainerConnector::A3sBox | ContainerConnector::Docker + ) { + items.push(ContainerMenuItem { + action: ContainerMenuAction::Logs, + key: 'l', + label: "View logs".to_string(), + }); + } + if container_web_url(container).is_some() { + items.push(ContainerMenuItem { + action: ContainerMenuAction::OpenBrowser, + key: 'w', + label: "Open browser".to_string(), + }); + } + + if running && !paused { + if matches!( + container.connector, + ContainerConnector::A3sBox | ContainerConnector::Docker + ) { + items.push(ContainerMenuItem { + action: ContainerMenuAction::ExecShell, + key: 'e', + label: "Exec shell".to_string(), + }); + } + items.push(ContainerMenuItem { + action: ContainerMenuAction::Pause, + key: 'p', + label: "Pause container".to_string(), + }); + items.push(ContainerMenuItem { + action: ContainerMenuAction::Stop, + key: 's', + label: "Stop container".to_string(), + }); + if matches!( + container.connector, + ContainerConnector::A3sBox | ContainerConnector::Docker + ) { + items.push(ContainerMenuItem { + action: ContainerMenuAction::Restart, + key: 'r', + label: "Restart container".to_string(), + }); + } + } else if paused { + items.push(ContainerMenuItem { + action: ContainerMenuAction::Unpause, + key: 'u', + label: "Unpause container".to_string(), + }); + items.push(ContainerMenuItem { + action: ContainerMenuAction::Stop, + key: 's', + label: "Stop container".to_string(), + }); + if matches!( + container.connector, + ContainerConnector::A3sBox | ContainerConnector::Docker + ) { + items.push(ContainerMenuItem { + action: ContainerMenuAction::Restart, + key: 'r', + label: "Restart container".to_string(), + }); + } + } else { + items.push(ContainerMenuItem { + action: ContainerMenuAction::Start, + key: 's', + label: "Start container".to_string(), + }); + items.push(ContainerMenuItem { + action: ContainerMenuAction::Remove, + key: 'd', + label: "Remove container".to_string(), + }); + } + + items +} + +fn container_action(container: ContainerRow, action: ContainerMenuAction) -> Action { + let name = format!("{} ({})", container.name, short_id(&container.id)); + match action { + ContainerMenuAction::Start => { + Action::StartContainer(container.connector, container.id, name) + } + ContainerMenuAction::Stop => Action::StopContainer(container.connector, container.id, name), + ContainerMenuAction::Restart => { + Action::RestartContainer(container.connector, container.id, name) + } + ContainerMenuAction::Pause => { + Action::PauseContainer(container.connector, container.id, name) + } + ContainerMenuAction::Unpause => { + Action::UnpauseContainer(container.connector, container.id, name) + } + ContainerMenuAction::Remove => { + Action::RemoveContainer(container.connector, container.id, name) + } + ContainerMenuAction::Focus + | ContainerMenuAction::Logs + | ContainerMenuAction::ExecShell + | ContainerMenuAction::OpenBrowser => { + unreachable!("non-confirmable menu actions are handled before confirmation") + } + } +} + +fn container_is_running(status: &str) -> bool { + let lower = status.to_lowercase(); + lower.starts_with("up") || lower.contains("running") || lower.contains("paused") +} + +fn container_is_paused(status: &str) -> bool { + status.to_lowercase().contains("paused") +} + +fn parse_observer_line(line: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(line).ok()?; + parse_a3s_observer_value(&value) + .or_else(|| parse_claude_jsonl_value(&value)) + .or_else(|| parse_codex_jsonl_value(&value)) +} + +fn parse_observer_json_document(text: &str) -> Vec { + let Ok(value) = serde_json::from_str::(text) else { + return Vec::new(); + }; + + match value { + serde_json::Value::Array(items) => items + .iter() + .flat_map(parse_observer_json_document_item) + .collect(), + item => parse_observer_json_document_item(&item), + } +} + +fn parse_observer_json_document_item(value: &serde_json::Value) -> Vec { + if let Some(rows) = parse_a3s_run_record_value(value) { + return rows; + } + if let Some(row) = parse_a3s_trace_value(value) { + return vec![row]; + } + parse_a3s_observer_value(value) + .or_else(|| parse_claude_jsonl_value(value)) + .or_else(|| parse_codex_jsonl_value(value)) + .into_iter() + .collect() +} + +fn parse_a3s_observer_value(value: &serde_json::Value) -> Option { + let identity = value.get("identity")?; + let ts = observer_event_timestamp(value).unwrap_or_else(|| "recent".into()); + let source = identity + .get("agent") + .and_then(|v| v.as_str()) + .unwrap_or("agent") + .to_string(); + let session = identity.get("session").and_then(compact_identity_value); + let task = identity.get("task").and_then(compact_identity_value); + let event = value.get("event")?.as_object()?; + let (kind, payload) = event.iter().next()?; + let mut details = event_payload_details(payload); + if let Some(provider) = value.get("provider").and_then(compact_provider_value) { + details.insert(0, ("provider".into(), provider)); + } + let pid = event_detail_u32(&details, &["pid"]); + let ppid = event_detail_u32(&details, &["ppid", "parent_pid", "parentPid"]); + let message = event_payload_message(payload, &details); + let risk = match kind.as_str() { + "SecurityAction" | "FileDelete" => Risk::High, + "Egress" | "FileAccess" | "ToolExec" => Risk::Medium, + _ => Risk::Low, + }; + Some(EventRow { + ts, + source, + session, + task, + pid, + ppid, + kind: kind.clone(), + message, + details, + risk, + }) +} + +fn parse_claude_jsonl_value(value: &serde_json::Value) -> Option { + let type_name = value.get("type").and_then(|v| v.as_str())?; + if value.get("message").is_none() + && !matches!( + type_name, + "mode" | "permission-mode" | "pr-link" | "last-prompt" | "ai-title" | "system" + ) + { + return None; + } + + let mut details = Vec::new(); + push_json_detail(&mut details, "type", value.get("type")); + push_json_detail(&mut details, "cwd", value.get("cwd")); + push_json_detail(&mut details, "git_branch", value.get("gitBranch")); + push_json_detail(&mut details, "version", value.get("version")); + let session = value + .get("sessionId") + .and_then(compact_identity_value) + .or_else(|| value.get("session_id").and_then(compact_identity_value)); + let task = value.get("uuid").and_then(compact_identity_value); + let ts = observer_event_timestamp(value).unwrap_or_else(|| "recent".into()); + + let (kind, message, risk) = match type_name { + "assistant" => { + let message_value = value.get("message")?; + push_json_detail(&mut details, "model", message_value.get("model")); + if let Some(usage) = message_value.get("usage") { + push_usage_details(&mut details, usage); + } + if let Some(tool) = claude_tool_use(message_value) { + push_json_detail(&mut details, "tool", tool.get("name")); + if let Some(input) = tool.get("input") { + push_json_detail(&mut details, "command", input.get("command")); + push_json_detail(&mut details, "description", input.get("description")); + } + ( + "ToolExec".to_string(), + claude_tool_message(tool), + Risk::Medium, + ) + } else { + ( + "LlmCall".to_string(), + claude_message_text(message_value) + .unwrap_or_else(|| "assistant message".into()), + Risk::Low, + ) + } + } + "user" => ( + "UserPrompt".to_string(), + value + .get("message") + .and_then(claude_message_text) + .unwrap_or_else(|| "user prompt".into()), + Risk::Low, + ), + "permission-mode" => ( + "SecurityAction".to_string(), + format!( + "permission mode {}", + value + .get("permissionMode") + .and_then(|v| v.as_str()) + .unwrap_or("changed") + ), + Risk::Medium, + ), + "pr-link" => ( + "ToolExec".to_string(), + format!( + "opened PR {}", + value + .get("prUrl") + .and_then(|v| v.as_str()) + .unwrap_or("link") + ), + Risk::Low, + ), + "mode" | "last-prompt" | "ai-title" | "system" => ( + "AgentEvent".to_string(), + claude_generic_message(value, type_name), + Risk::Low, + ), + other => ( + "AgentEvent".to_string(), + format!("claude {other}"), + Risk::Low, + ), + }; + + Some(EventRow { + ts, + source: "claude".into(), + session, + task, + pid: None, + ppid: None, + kind, + message, + details, + risk, + }) +} + +fn parse_codex_jsonl_value(value: &serde_json::Value) -> Option { + let type_name = value.get("type").and_then(|v| v.as_str())?; + let looks_like_codex = value.get("payload").is_some() + || value.get("session_id").is_some() + || matches!(type_name, "event_msg" | "response_item"); + if !looks_like_codex { + return None; + } + + let payload = value.get("payload").unwrap_or(value); + let payload_type = payload + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or(type_name); + let mut details = vec![("type".into(), payload_type.to_string())]; + let session = value + .get("session_id") + .and_then(compact_identity_value) + .or_else(|| payload.get("session_id").and_then(compact_identity_value)); + let task = payload + .get("turn_id") + .and_then(compact_identity_value) + .or_else(|| { + payload + .get("internal_chat_message_metadata_passthrough") + .and_then(|v| v.get("turn_id")) + .and_then(compact_identity_value) + }); + let ts = observer_event_timestamp(value) + .or_else(|| observer_event_timestamp(payload)) + .unwrap_or_else(|| "recent".into()); + + let (kind, message, risk) = match payload_type { + "token_count" => { + if let Some(info) = payload.get("info") { + push_codex_token_usage(&mut details, info); + } + ("LlmCall".into(), "token usage update".into(), Risk::Low) + } + "message" => { + push_json_detail(&mut details, "role", payload.get("role")); + let phase = payload + .get("phase") + .and_then(|v| v.as_str()) + .unwrap_or("message"); + ( + "LlmCall".into(), + codex_message_text(payload).unwrap_or_else(|| format!("codex {phase}")), + Risk::Low, + ) + } + "task_complete" => { + push_json_detail(&mut details, "duration_ms", payload.get("duration_ms")); + push_json_detail( + &mut details, + "time_to_first_token_ms", + payload.get("time_to_first_token_ms"), + ); + ( + "AgentEvent".into(), + payload + .get("last_agent_message") + .and_then(|v| v.as_str()) + .map(|value| truncate(value, 120)) + .unwrap_or_else(|| "task complete".into()), + Risk::Low, + ) + } + "exec_command_begin" | "exec_command_end" | "tool_call" => { + push_json_detail(&mut details, "cmd", payload.get("cmd")); + push_json_detail(&mut details, "command", payload.get("command")); + ( + "ToolExec".into(), + format!("codex {payload_type}"), + Risk::Medium, + ) + } + other => ("AgentEvent".into(), format!("codex {other}"), Risk::Low), + }; + + Some(EventRow { + ts, + source: "codex".into(), + session, + task, + pid: None, + ppid: None, + kind, + message, + details, + risk, + }) +} + +fn parse_a3s_run_record_value(value: &serde_json::Value) -> Option> { + let snapshot = value.get("snapshot")?; + let events = value.get("events")?.as_array()?; + let session = snapshot + .get("session_id") + .and_then(compact_identity_value) + .or_else(|| snapshot.get("id").and_then(compact_identity_value)); + let task = snapshot.get("id").and_then(compact_identity_value); + let workspace = snapshot + .get("workspace") + .or_else(|| snapshot.get("cwd")) + .and_then(|v| v.as_str()) + .map(str::to_string); + + Some( + events + .iter() + .filter_map(|entry| { + parse_a3s_run_event(entry, session.clone(), task.clone(), &workspace) + }) + .collect(), + ) +} + +fn parse_a3s_run_event( + value: &serde_json::Value, + session: Option, + task: Option, + workspace: &Option, +) -> Option { + let event = value.get("event")?; + let event_type = event.get("type").and_then(|v| v.as_str())?; + let mut details = event_payload_details(event); + if let Some(workspace) = workspace { + details.push(("workspace".into(), workspace.clone())); + } + push_json_detail(&mut details, "sequence", value.get("sequence")); + let ts = value + .get("timestamp_ms") + .and_then(compact_timestamp_field) + .unwrap_or_else(|| "recent".into()); + let kind = a3s_event_kind(event_type); + let risk = match kind { + "ToolExec" => Risk::Medium, + "SecurityAction" => Risk::High, + _ => Risk::Low, + }; + + Some(EventRow { + ts, + source: "a3s-code".into(), + session, + task, + pid: None, + ppid: None, + kind: kind.into(), + message: a3s_event_message(event_type, event, &details), + details, + risk, + }) +} + +fn parse_a3s_trace_value(value: &serde_json::Value) -> Option { + if value.get("schema").and_then(|v| v.as_str()) != Some("a3s.trace_event.v1") { + return None; + } + let kind_value = value + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("trace"); + let mut details = event_payload_details(value); + Some(EventRow { + ts: "recent".into(), + source: "a3s-code".into(), + session: None, + task: None, + pid: None, + ppid: None, + kind: a3s_event_kind(kind_value).into(), + message: a3s_event_message(kind_value, value, &details), + details: { + details.truncate(8); + details + }, + risk: if a3s_event_kind(kind_value) == "ToolExec" { + Risk::Medium + } else { + Risk::Low + }, + }) +} + +fn observer_event_timestamp(value: &serde_json::Value) -> Option { + [ + "ts", + "time", + "timestamp", + "created_at", + "createdAt", + "observed_at", + "observedAt", + ] + .iter() + .find_map(|key| value.get(*key).and_then(compact_timestamp_field)) +} + +fn compact_timestamp_field(value: &serde_json::Value) -> Option { + let text = match value { + serde_json::Value::Null => return None, + serde_json::Value::String(s) => s.trim().to_string(), + serde_json::Value::Number(n) => n.to_string(), + _ => return None, + }; + (!text.is_empty()).then(|| truncate(&text, 40)) +} + +fn event_payload_message(payload: &serde_json::Value, details: &[(String, String)]) -> String { + if matches!(payload, serde_json::Value::Object(_)) { + details + .iter() + .take(4) + .map(|(k, v)| format!("{k}={}", truncate(v, 80))) + .collect::>() + .join(" ") + } else { + compact_json_value(payload) + } +} + +fn event_payload_details(payload: &serde_json::Value) -> Vec<(String, String)> { + match payload { + serde_json::Value::Object(map) => map + .iter() + .take(8) + .map(|(k, v)| (truncate(k, 32), compact_json_value(v))) + .collect(), + serde_json::Value::Null => Vec::new(), + other => vec![("value".into(), compact_json_value(other))], + } +} + +fn compact_provider_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => None, + serde_json::Value::String(s) => { + let s = s.trim(); + (!s.is_empty()).then(|| s.to_string()) + } + serde_json::Value::Object(map) => map + .get("Other") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + .or_else(|| Some(compact_json_value(value))), + other => Some(compact_json_value(other)), + } +} + +fn compact_identity_value(value: &serde_json::Value) -> Option { + let text = match value { + serde_json::Value::Null => return None, + serde_json::Value::String(s) => s.trim().to_string(), + other => other.to_string(), + }; + (!text.is_empty()).then(|| truncate(&text, 18)) +} + +fn push_json_detail( + details: &mut Vec<(String, String)>, + key: &str, + value: Option<&serde_json::Value>, +) { + let Some(value) = value else { + return; + }; + if value.is_null() { + return; + } + let compact = compact_json_value(value); + if compact.trim().is_empty() || compact == "null" { + return; + } + details.push((key.into(), compact)); +} + +fn push_usage_details(details: &mut Vec<(String, String)>, usage: &serde_json::Value) { + for (detail_key, json_keys) in [ + ( + "input_tokens", + &["input_tokens", "prompt_tokens", "cache_read_input_tokens"][..], + ), + ( + "output_tokens", + &[ + "output_tokens", + "completion_tokens", + "reasoning_output_tokens", + ][..], + ), + ("total_tokens", &["total_tokens"][..]), + ("latency_ms", &["latency_ms", "duration_ms"][..]), + ] { + if let Some(value) = json_keys.iter().find_map(|key| usage.get(*key)) { + push_json_detail(details, detail_key, Some(value)); + } + } +} + +fn push_codex_token_usage(details: &mut Vec<(String, String)>, info: &serde_json::Value) { + if let Some(last) = info.get("last_token_usage") { + for (detail_key, json_key) in [ + ("prompt_tokens", "input_tokens"), + ("completion_tokens", "output_tokens"), + ("total_tokens", "total_tokens"), + ] { + if let Some(value) = last.get(json_key) { + push_json_detail(details, detail_key, Some(value)); + } + } + } + if let Some(total) = info.get("total_token_usage") { + for (detail_key, json_key) in [ + ("lifetime_input_tokens", "input_tokens"), + ("lifetime_output_tokens", "output_tokens"), + ("lifetime_total_tokens", "total_tokens"), + ] { + if let Some(value) = total.get(json_key) { + push_json_detail(details, detail_key, Some(value)); + } + } + } + push_json_detail(details, "context_window", info.get("model_context_window")); +} + +fn claude_tool_use(message: &serde_json::Value) -> Option<&serde_json::Value> { + message + .get("content") + .and_then(|content| content.as_array()) + .and_then(|items| { + items.iter().find(|item| { + item.get("type") + .and_then(|value| value.as_str()) + .map(|value| value == "tool_use") + .unwrap_or(false) + }) + }) +} + +fn claude_tool_message(tool: &serde_json::Value) -> String { + let name = tool + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("tool"); + let input = tool.get("input"); + let detail = input + .and_then(|value| value.get("description")) + .or_else(|| input.and_then(|value| value.get("command"))) + .and_then(|value| value.as_str()) + .map(|value| truncate(value, 120)) + .unwrap_or_else(|| "invoked".to_string()); + format!("{name}: {detail}") +} + +fn claude_message_text(message: &serde_json::Value) -> Option { + message_content_text(message.get("content")?) +} + +fn codex_message_text(message: &serde_json::Value) -> Option { + message_content_text(message.get("content")?) +} + +fn message_content_text(content: &serde_json::Value) -> Option { + match content { + serde_json::Value::String(text) => non_empty_truncated(text, 120), + serde_json::Value::Array(items) => { + let text = items + .iter() + .filter_map(|item| { + item.get("text") + .or_else(|| item.get("content")) + .and_then(|value| value.as_str()) + }) + .collect::>() + .join(" "); + non_empty_truncated(&text, 120) + } + other => non_empty_truncated(&compact_json_value(other), 120), + } +} + +fn non_empty_truncated(value: &str, max: usize) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| truncate(value, max)) +} + +fn claude_generic_message(value: &serde_json::Value, type_name: &str) -> String { + for key in ["mode", "lastPrompt", "aiTitle", "subtype", "message"] { + if let Some(text) = value.get(key).and_then(|v| v.as_str()) { + return format!("{type_name}: {}", truncate(text, 120)); + } + } + format!("claude {type_name}") +} + +fn a3s_event_kind(event_type: &str) -> &'static str { + let lower = event_type.to_ascii_lowercase(); + if lower.contains("tool") + || lower.contains("bash") + || lower.contains("command") + || lower.contains("program_execution") + || lower.contains("tool_execution") + { + "ToolExec" + } else if lower.contains("llm") + || lower.contains("token") + || lower.contains("turn_end") + || lower.contains("generate") + { + "LlmCall" + } else if lower.contains("permission") + || lower.contains("security") + || lower.contains("deny") + || lower.contains("error") + { + "SecurityAction" + } else if lower.contains("file") { + "FileAccess" + } else { + "AgentEvent" + } +} + +fn a3s_event_message( + event_type: &str, + value: &serde_json::Value, + details: &[(String, String)], +) -> String { + value + .get("name") + .or_else(|| value.get("tool")) + .or_else(|| value.get("message")) + .or_else(|| value.get("prompt")) + .and_then(|value| value.as_str()) + .map(|value| format!("{event_type}: {}", truncate(value, 120))) + .unwrap_or_else(|| { + if details.is_empty() { + event_type.to_string() + } else { + format!( + "{event_type}: {}", + details + .iter() + .take(3) + .map(|(key, value)| format!("{key}={}", truncate(value, 60))) + .collect::>() + .join(" ") + ) + } + }) +} + +fn event_scope_label(event: &EventRow) -> String { + match (&event.session, &event.task) { + (Some(session), Some(task)) => format!("session {session} task {task}"), + (Some(session), None) => format!("session {session}"), + (None, Some(task)) => format!("task {task}"), + (None, None) => "no session".to_string(), + } +} + +fn event_workspace(event: &EventRow) -> Option { + event + .details + .iter() + .find(|(key, value)| { + let key = key.to_ascii_lowercase(); + matches!( + key.as_str(), + "cwd" | "workdir" | "working_dir" | "workingdirectory" | "workspace" + ) && !value.trim().is_empty() + }) + .map(|(_, value)| value.clone()) +} + +fn workspace_paths_overlap(a: &str, b: &str) -> bool { + let Some(a) = normalize_workspace_path(a) else { + return false; + }; + let Some(b) = normalize_workspace_path(b) else { + return false; + }; + if a == b { + return true; + } + + let a_path = Path::new(&a); + let b_path = Path::new(&b); + a_path.is_absolute() + && b_path.is_absolute() + && (a_path.starts_with(b_path) || b_path.starts_with(a_path)) +} + +fn normalize_workspace_path(path: &str) -> Option { + let path = path.trim().trim_matches('"').trim(); + if path.is_empty() || path == "-" { + None + } else { + Some(if path == "/" { + path.to_string() + } else { + path.trim_end_matches('/').to_string() + }) + } +} + +fn is_llm_event_kind(kind: &str) -> bool { + matches!(kind, "LlmCall" | "LlmApi" | "SslContent") +} + +fn event_model(event: &EventRow) -> Option { + event_detail_value(event, &["model", "model_name", "modelName"]) +} + +fn event_provider(event: &EventRow) -> Option { + event_detail_value(event, &["provider", "provider_name", "providerName"]) +} + +fn event_llm_network(event: &EventRow) -> Option { + if !is_llm_event_kind(&event.kind) { + return None; + } + Some(LlmNetworkSummary { + provider: event_provider(event), + latency_ms: event_detail_millis(event, &["latency", "latency_ms", "latencyMs"]), + ttft_ms: event_detail_millis(event, &["ttft", "ttft_ms", "ttftMs"]), + req_bytes: event_detail_u64(event, &["req_bytes", "request_bytes", "reqBytes"]) + .unwrap_or(0), + resp_bytes: event_detail_u64(event, &["resp_bytes", "response_bytes", "respBytes"]) + .unwrap_or(0), + }) +} + +fn event_token_usage(event: &EventRow) -> Option { + let prompt = event_detail_u64( + event, + &[ + "prompt_tokens", + "input_tokens", + "promptTokens", + "inputTokens", + ], + ) + .unwrap_or(0); + let completion = event_detail_u64( + event, + &[ + "completion_tokens", + "output_tokens", + "completionTokens", + "outputTokens", + ], + ) + .unwrap_or(0); + let total = event_detail_u64(event, &["total_tokens", "totalTokens"]) + .unwrap_or_else(|| prompt.saturating_add(completion)); + (prompt > 0 || completion > 0 || total > 0).then_some(TokenUsageSummary { + prompt, + completion, + total, + }) +} + +fn event_detail_value(event: &EventRow, keys: &[&str]) -> Option { + event + .details + .iter() + .find(|(key, value)| { + keys.iter().any(|wanted| key.eq_ignore_ascii_case(wanted)) && !value.trim().is_empty() + }) + .map(|(_, value)| value.trim().to_string()) +} + +fn event_detail_u64(event: &EventRow, keys: &[&str]) -> Option { + event_detail_value(event, keys) + .and_then(|value| value.trim_matches('"').replace('_', "").parse::().ok()) +} + +fn event_detail_u32(details: &[(String, String)], keys: &[&str]) -> Option { + details + .iter() + .find(|(key, value)| { + keys.iter().any(|wanted| key.eq_ignore_ascii_case(wanted)) && !value.trim().is_empty() + }) + .and_then(|(_, value)| value.trim_matches('"').replace('_', "").parse::().ok()) +} + +fn event_detail_millis(event: &EventRow, keys: &[&str]) -> Option { + event_detail_value(event, keys).and_then(|value| parse_millis_value(&value)) +} + +fn parse_millis_value(value: &str) -> Option { + let value = value.trim().trim_matches('"'); + if value.is_empty() { + return None; + } + if let Some(ms) = value.strip_suffix("ms") { + return ms + .trim() + .parse::() + .ok() + .map(|ms| ms.max(0.0).round() as u64); + } + if let Some(sec) = value.strip_suffix('s') { + return sec + .trim() + .parse::() + .ok() + .map(|sec| (sec.max(0.0) * 1000.0).round() as u64); + } + if let Ok(number) = value.parse::() { + return Some(number.max(0.0).round() as u64); + } + let parsed = serde_json::from_str::(value).ok()?; + let secs = parsed + .get("secs") + .or_else(|| parsed.get("seconds")) + .and_then(json_u64) + .unwrap_or(0); + let nanos = parsed + .get("nanos") + .or_else(|| parsed.get("nanoseconds")) + .and_then(json_u64) + .unwrap_or(0); + Some( + secs.saturating_mul(1000) + .saturating_add((nanos as f64 / 1_000_000.0).round() as u64), + ) +} + +async fn run_action(action: Action) -> String { + match action { + Action::KillProcess(pid, label) => { + let status = Command::new("kill") + .arg("-TERM") + .arg(pid.to_string()) + .status() + .await; + match status { + Ok(s) if s.success() => format!("sent SIGTERM to PID {pid} · {label}"), + Ok(s) => format!("kill failed for PID {pid}: {s}"), + Err(err) => format!("kill failed for PID {pid}: {err}"), + } + } + Action::StartContainer(connector, id, name) => { + run_container_runtime_action(connector, "start", &id, &name).await + } + Action::StopContainer(connector, id, name) => { + run_container_runtime_action(connector, "stop", &id, &name).await + } + Action::RestartContainer(connector, id, name) => { + run_container_runtime_action(connector, "restart", &id, &name).await + } + Action::PauseContainer(connector, id, name) => { + run_container_runtime_action(connector, "pause", &id, &name).await + } + Action::UnpauseContainer(connector, id, name) => { + run_container_runtime_action(connector, "unpause", &id, &name).await + } + Action::RemoveContainer(connector, id, name) => { + run_container_runtime_action(connector, "rm", &id, &name).await + } + } +} + +async fn run_container_runtime_action( + connector: ContainerConnector, + action: &str, + id: &str, + name: &str, +) -> String { + match connector { + ContainerConnector::A3sBox => run_a3s_box_container_action(action, id, name).await, + ContainerConnector::Docker => run_docker_container_action(action, id, name).await, + ContainerConnector::RunC => run_runc_container_action(action, id, name).await, + } +} + +async fn run_a3s_box_container_action(action: &str, id: &str, name: &str) -> String { + let command = match action { + "start" => "start", + "stop" => "stop", + "restart" => "restart", + "pause" => "pause", + "unpause" => "unpause", + "rm" => "rm", + _ => return format!("a3s-box {action} is not supported for {name}"), + }; + let a3s_box = match ensure_a3s_box_binary().await { + Ok(path) => path, + Err(err) => return format!("a3s-box {command} failed for {name}: {err}"), + }; + let status = Command::new(a3s_box).args([command, id]).status().await; + match status { + Ok(s) if s.success() => format!("a3s-box {command} succeeded for {name}"), + Ok(s) => format!("a3s-box {command} failed for {name}: {s}"), + Err(err) => format!("a3s-box {command} failed for {name}: {err}"), + } +} + +async fn run_docker_container_action(action: &str, id: &str, name: &str) -> String { + let status = Command::new("docker").args([action, id]).status().await; + match status { + Ok(s) if s.success() => format!("docker {action} succeeded for {name}"), + Ok(s) => format!("docker {action} failed for {name}: {s}"), + Err(err) => format!("docker {action} failed for {name}: {err}"), + } +} + +async fn run_runc_container_action(action: &str, id: &str, name: &str) -> String { + let mut command = runc_command(); + match action { + "start" => { + command.args(["start", id]); + } + "stop" => { + command.args(["kill", id, "TERM"]); + } + "pause" => { + command.args(["pause", id]); + } + "unpause" => { + command.args(["resume", id]); + } + "rm" => { + command.args(["delete", id]); + } + "restart" => { + return format!("runc restart is not supported for {name}"); + } + _ => return format!("runc {action} is not supported for {name}"), + } + + let status = command.status().await; + match status { + Ok(s) if s.success() => format!("runc {action} succeeded for {name}"), + Ok(s) => format!("runc {action} failed for {name}: {s}"), + Err(err) => format!("runc {action} failed for {name}: {err}"), + } +} + +fn agent_matches_source(agent: AgentKind, source: &str) -> bool { + let source = source.to_lowercase().replace(['_', ' '], "-"); + match agent { + AgentKind::A3sCode => source == "a3s" || source.contains("a3s-code"), + AgentKind::ClaudeCode => source.contains("claude"), + AgentKind::Codex => source.contains("codex"), + AgentKind::Cursor => source.contains("cursor"), + AgentKind::Gemini => source.contains("gemini"), + } +} + +fn agent_order(agent: AgentKind) -> usize { + AgentKind::ALL + .iter() + .position(|item| *item == agent) + .unwrap_or(usize::MAX) +} + +fn agent_tree_state_label(group: &AgentTreeGroup) -> &'static str { + if group.activity.high_risk > 0 || group.risk() == Risk::High { + "HIGH" + } else if !group.processes.is_empty() { + "RUN" + } else if !group.sessions.is_empty() || !group.events.is_empty() { + "RECENT" + } else { + "IDLE" + } +} + +fn agent_tree_label(agent: AgentKind, text: impl AsRef) -> String { + Style::new().fg(agent.color()).render(text.as_ref()) +} + +fn agent_kind_for_source(source: &str) -> Option { + AgentKind::ALL + .iter() + .copied() + .find(|agent| agent_matches_source(*agent, source)) +} + +fn sort_choices_for_tab(tab: Tab) -> Vec { + match tab { + Tab::Agents => vec![ + SortBy::Cpu, + SortBy::Mem, + SortBy::Net, + SortBy::Pids, + SortBy::Name, + SortBy::Tokens, + ], + Tab::Sessions => vec![ + SortBy::Cpu, + SortBy::Mem, + SortBy::Net, + SortBy::Name, + SortBy::Tokens, + ], + Tab::Containers => vec![ + SortBy::Cpu, + SortBy::Mem, + SortBy::Net, + SortBy::Block, + SortBy::Pids, + SortBy::State, + SortBy::Id, + SortBy::Uptime, + SortBy::Name, + ], + Tab::Processes => vec![ + SortBy::Cpu, + SortBy::Mem, + SortBy::Pids, + SortBy::Id, + SortBy::Name, + ], + Tab::Events => Vec::new(), + } +} + +fn sort_choice_label(sort_by: SortBy) -> &'static str { + match sort_by { + SortBy::Cpu => "cpu CPU usage", + SortBy::Mem => "mem memory usage", + SortBy::Net => "net network I/O", + SortBy::Block => "block block I/O", + SortBy::Pids => "pids process count", + SortBy::State => "state container state", + SortBy::Id => "id container id / pid", + SortBy::Uptime => "uptime running duration", + SortBy::Name => "name name", + SortBy::Tokens => "tokens LLM token usage", + } +} + +fn container_sort_column_id(sort_by: SortBy) -> Option<&'static str> { + match sort_by { + SortBy::Cpu => Some("containers.cpu"), + SortBy::Mem => Some("containers.mem"), + SortBy::Net => Some("containers.net"), + SortBy::Block => Some("containers.block"), + SortBy::Pids => Some("containers.pids"), + SortBy::State => Some("containers.status"), + SortBy::Id => Some("containers.id"), + SortBy::Uptime => Some("containers.uptime"), + SortBy::Name => Some("containers.name"), + SortBy::Tokens => None, + } +} + +fn connector_choice_label(connector: ContainerConnector) -> &'static str { + match connector { + ContainerConnector::A3sBox => "a3s-box A3S Box runtime", + ContainerConnector::Docker => "docker Docker Engine", + ContainerConnector::RunC => "runc runC containers", + } +} + +fn metric_color(value: f32) -> Color { + if value >= 80.0 { + RED + } else if value >= 50.0 { + YELLOW + } else { + GREEN + } +} + +fn scale_cpu_pct_to_system(value: f32) -> f32 { + scale_cpu_pct_by(value, system_cpu_count()) +} + +fn scale_cpu_pct_for_cpus(value: f32, cpus: Option) -> f32 { + cpus.and_then(|cpus| usize::try_from(cpus).ok()) + .map(|cpus| scale_cpu_pct_by(value, cpus)) + .unwrap_or_else(|| scale_cpu_pct_to_system(value)) +} + +fn scale_cpu_pct_by(value: f32, cpus: usize) -> f32 { + value / cpus.max(1) as f32 +} + +fn system_cpu_count() -> usize { + std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1) +} + +fn session_rows(events: &[EventRow]) -> Vec { + let mut rows: HashMap<(String, String), SessionRow> = HashMap::new(); + for event in events { + let Some((source, session)) = session_key_for_event(event) else { + continue; + }; + let task = event.task.clone().unwrap_or_else(|| "-".to_string()); + let workspace = event_workspace(event).unwrap_or_else(|| "-".to_string()); + let key = (source.clone(), session.clone()); + let row = rows.entry(key).or_insert_with(|| SessionRow { + source, + session, + task: task.clone(), + workspace: workspace.clone(), + events: 0, + tools: 0, + security: 0, + files: 0, + egress: 0, + llm: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + model: "-".to_string(), + provider: "-".to_string(), + latency_ms: 0, + latency_samples: 0, + ttft_ms: 0, + ttft_samples: 0, + req_bytes: 0, + resp_bytes: 0, + high_risk: 0, + risk: Risk::Low, + last_kind: event.kind.clone(), + last_message: event.message.clone(), + }); + row.events += 1; + if row.workspace == "-" && workspace != "-" { + row.workspace = workspace; + } + if event.kind == "ToolExec" { + row.tools += 1; + } + if event.kind == "SecurityAction" { + row.security += 1; + } + if matches!(event.kind.as_str(), "FileAccess" | "FileDelete") { + row.files += 1; + } + if event.kind == "Egress" { + row.egress += 1; + } + if is_llm_event_kind(&event.kind) { + row.llm += 1; + } + if let Some(tokens) = event_token_usage(event) { + row.prompt_tokens += tokens.prompt; + row.completion_tokens += tokens.completion; + row.total_tokens += tokens.total; + } + if row.model == "-" { + if let Some(model) = event_model(event) { + row.model = model; + } + } + if let Some(network) = event_llm_network(event) { + if row.provider == "-" { + if let Some(provider) = network.provider { + row.provider = provider; + } + } + if let Some(latency_ms) = network.latency_ms { + row.latency_ms += latency_ms; + row.latency_samples += 1; + } + if let Some(ttft_ms) = network.ttft_ms { + row.ttft_ms += ttft_ms; + row.ttft_samples += 1; + } + row.req_bytes += network.req_bytes; + row.resp_bytes += network.resp_bytes; + } + if event.risk == Risk::High { + row.high_risk += 1; + } + if risk_rank(event.risk) > risk_rank(row.risk) { + row.risk = event.risk; + } + if row.task == "-" && task != "-" { + row.task = task; + } + } + + let mut rows = rows.into_values().collect::>(); + rows.sort_by(|a, b| { + b.high_risk + .cmp(&a.high_risk) + .then(b.events.cmp(&a.events)) + .then(a.source.cmp(&b.source)) + .then(a.session.cmp(&b.session)) + }); + rows +} + +fn session_key_for_event(event: &EventRow) -> Option<(String, String)> { + let agent = agent_kind_for_source(&event.source)?; + let session = event + .session + .clone() + .or_else(|| event.task.clone()) + .unwrap_or_else(|| "no-session".to_string()); + Some((agent.label().to_string(), session)) +} + +fn session_focus_matches_event(focus: &SessionFocus, event: &EventRow) -> bool { + session_key_for_event(event) + .is_some_and(|(source, session)| source == focus.source && session == focus.session) +} + +fn sort_sessions(rows: &mut [SessionRow], sort_by: SortBy) { + match sort_by { + SortBy::Cpu => rows.sort_by(|a, b| { + b.high_risk + .cmp(&a.high_risk) + .then(b.events.cmp(&a.events)) + .then(a.source.cmp(&b.source)) + .then(a.session.cmp(&b.session)) + }), + SortBy::Mem => rows.sort_by(|a, b| { + b.tools + .cmp(&a.tools) + .then(b.events.cmp(&a.events)) + .then(a.source.cmp(&b.source)) + .then(a.session.cmp(&b.session)) + }), + SortBy::Net => rows.sort_by(|a, b| { + b.egress + .cmp(&a.egress) + .then(b.events.cmp(&a.events)) + .then(a.source.cmp(&b.source)) + .then(a.session.cmp(&b.session)) + }), + SortBy::Block | SortBy::Pids | SortBy::State | SortBy::Id | SortBy::Uptime => { + rows.sort_by(|a, b| { + b.events + .cmp(&a.events) + .then(a.source.cmp(&b.source)) + .then(a.session.cmp(&b.session)) + }) + } + SortBy::Name => rows.sort_by(|a, b| { + a.source + .cmp(&b.source) + .then(a.session.cmp(&b.session)) + .then(a.task.cmp(&b.task)) + }), + SortBy::Tokens => rows.sort_by(|a, b| { + b.total_tokens + .cmp(&a.total_tokens) + .then(b.llm.cmp(&a.llm)) + .then(b.events.cmp(&a.events)) + .then(a.source.cmp(&b.source)) + .then(a.session.cmp(&b.session)) + }), + } +} + +fn risk_rank(risk: Risk) -> u8 { + match risk { + Risk::Low => 0, + Risk::Medium => 1, + Risk::High => 2, + } +} + +fn process_history_key(pid: u32) -> String { + format!("process:{pid}") +} + +fn agent_tree_history_key(pid: u32) -> String { + format!("agent-tree:{pid}") +} + +fn container_history_key(id: &str) -> String { + format!("container:{id}") +} + +fn process_tree_usage(rows: &[ProcessRow], root_pid: u32) -> ProcessTreeUsage { + let mut usage = ProcessTreeUsage::default(); + let mut stack = vec![root_pid]; + let mut visited = HashSet::new(); + + while let Some(pid) = stack.pop() { + if !visited.insert(pid) { + continue; + } + let Some(process) = rows.iter().find(|process| process.pid == pid) else { + continue; + }; + usage.cpu_pct += process.cpu_pct; + usage.mem_pct += process.mem_pct; + if pid != root_pid { + usage.descendants += 1; + } + stack.extend( + rows.iter() + .filter(|candidate| candidate.ppid == pid) + .map(|child| child.pid), + ); + } + + usage +} + +fn push_history(history: &mut MetricHistory, cpu: f32, mem: f32) { + history.cpu.push(cpu.clamp(0.0, 100.0)); + history.mem.push(mem.clamp(0.0, 100.0)); + cap_f32_history(&mut history.cpu); + cap_f32_history(&mut history.mem); +} + +fn add_aligned_f32_history(total: &mut Vec, values: &[f32]) { + if values.is_empty() { + return; + } + if total.len() < values.len() { + let mut padded = vec![0.0; values.len() - total.len()]; + padded.extend(total.iter().copied()); + *total = padded; + } + let offset = total.len() - values.len(); + for (idx, value) in values.iter().enumerate() { + total[offset + idx] += value; + } + cap_f32_history(total); +} + +fn push_io_history(history: &mut MetricHistory, net_io_bytes: u64, block_io_bytes: u64) { + history.net_io_bytes.push(net_io_bytes as f64); + history.block_io_bytes.push(block_io_bytes as f64); + cap_f64_history(&mut history.net_io_bytes); + cap_f64_history(&mut history.block_io_bytes); +} + +fn cap_f32_history(values: &mut Vec) { + if values.len() > HISTORY_LIMIT { + let excess = values.len() - HISTORY_LIMIT; + values.drain(0..excess); + } +} + +fn cap_f64_history(values: &mut Vec) { + if values.len() > HISTORY_LIMIT { + let excess = values.len() - HISTORY_LIMIT; + values.drain(0..excess); + } +} + +fn observe_raw_cpu_pct( + history: &mut MetricHistory, + total_ns: Option, + now: Instant, +) -> Option { + let total_ns = total_ns?; + let pct = match (history.cpu_usage_total_ns, history.raw_sample_at) { + (Some(previous), Some(previous_at)) if total_ns >= previous => { + let elapsed = now.duration_since(previous_at).as_secs_f64(); + (elapsed > 0.0).then(|| { + ((total_ns - previous) as f64 / (elapsed * 1_000_000_000.0) * 100.0) as f32 + }) + } + _ => None, + }; + history.cpu_usage_total_ns = Some(total_ns); + history.raw_sample_at = Some(now); + pct.map(|value| value.clamp(0.0, 10_000.0)) +} + +fn parse_percent(s: &str) -> Option { + s.trim().trim_end_matches('%').parse().ok() +} + +fn json_u64(value: &serde_json::Value) -> Option { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|v| (v >= 0).then_some(v as u64))) + .or_else(|| value.as_f64().and_then(|v| (v >= 0.0).then_some(v as u64))) +} + +fn json_field_u64(value: &serde_json::Value, fields: &[&str]) -> u64 { + fields + .iter() + .find_map(|field| value.get(*field).and_then(json_u64)) + .unwrap_or(0) +} + +fn format_byte_pair(first: u64, second: u64) -> String { + format!("{} / {}", format_bytes(first), format_bytes(second)) +} + +fn container_net_total(container: &ContainerRow) -> u64 { + parse_byte_pair_total(&container.net_io) +} + +fn container_block_total(container: &ContainerRow) -> u64 { + parse_byte_pair_total(&container.block_io) +} + +fn container_pid_count(container: &ContainerRow) -> u64 { + container.pids.trim().parse().unwrap_or(0) +} + +fn container_state_label(status: &str) -> String { + let lower = status.trim().to_ascii_lowercase(); + if lower.contains("paused") { + "paused".into() + } else if lower.starts_with("up") || lower == "running" || lower.contains("running") { + "running".into() + } else if lower.contains("restarting") { + "restart".into() + } else if lower.contains("exited") || lower.contains("stopped") { + "exited".into() + } else if lower.contains("created") { + "created".into() + } else if lower.contains("dead") { + "dead".into() + } else { + status + .split_whitespace() + .next() + .filter(|value| !value.is_empty()) + .unwrap_or("-") + .to_ascii_lowercase() + } +} + +fn container_state_color(status: &str) -> Color { + match container_state_label(status).as_str() { + "running" => GREEN, + "restart" => ORANGE, + "paused" => YELLOW, + "exited" | "stopped" => Color::BrightBlack, + "created" => CYAN, + "dead" => RED, + _ => CYAN, + } +} + +fn container_state_summary(rows: &[ContainerRow]) -> ContainerStateSummary { + let mut summary = ContainerStateSummary { + total: rows.len(), + ..ContainerStateSummary::default() + }; + + for row in rows { + match container_state_label(&row.status).as_str() { + "running" => summary.running += 1, + "restart" => summary.restarting += 1, + "paused" => summary.paused += 1, + "exited" | "stopped" => summary.exited += 1, + "created" => summary.created += 1, + "dead" => summary.dead += 1, + _ => summary.other += 1, + } + } + + summary +} + +impl ContainerStateSummary { + fn header_label(self) -> String { + if self.total == 0 { + return "0".into(); + } + let mut parts = Vec::new(); + push_state_count(&mut parts, "run", self.running); + push_state_count(&mut parts, "restart", self.restarting); + push_state_count(&mut parts, "pause", self.paused); + push_state_count(&mut parts, "exit", self.exited); + push_state_count(&mut parts, "create", self.created); + push_state_count(&mut parts, "dead", self.dead); + push_state_count(&mut parts, "other", self.other); + if parts.is_empty() { + self.total.to_string() + } else { + format!("{} {}", self.total, parts.join(" ")) + } + } +} + +fn push_state_count(parts: &mut Vec, label: &str, count: usize) { + if count > 0 { + parts.push(format!("{label}:{count}")); + } +} + +fn container_state_summary_json(summary: ContainerStateSummary) -> serde_json::Value { + serde_json::json!({ + "total": summary.total, + "running": summary.running, + "restarting": summary.restarting, + "paused": summary.paused, + "exited": summary.exited, + "created": summary.created, + "dead": summary.dead, + "other": summary.other, + }) +} + +fn container_state_rank(container: &ContainerRow) -> u8 { + match container_state_label(&container.status).as_str() { + "running" => 5, + "restart" => 4, + "paused" => 3, + "exited" | "stopped" => 2, + "created" => 1, + _ => 0, + } +} + +fn container_uptime_label(container: &ContainerRow) -> String { + let status = container.status.trim(); + let lower = status.to_ascii_lowercase(); + if lower.starts_with("up ") { + let value = status + .trim_start_matches("Up ") + .split(" (") + .next() + .unwrap_or(status) + .trim(); + if !value.is_empty() { + return truncate(value, 14); + } + } + if lower == "running" && container.inspect.started != "-" { + return truncate(&container.inspect.started, 14); + } + "-".into() +} + +fn container_uptime_seconds(container: &ContainerRow) -> u64 { + parse_uptime_seconds(&container.status).unwrap_or(0) +} + +fn parse_uptime_seconds(status: &str) -> Option { + let lower = status.to_ascii_lowercase(); + let rest = lower.strip_prefix("up ")?; + let rest = rest.split(" (").next().unwrap_or(rest).trim(); + let mut parts = rest.split_whitespace(); + let first = parts.next()?; + let amount = match first { + "a" | "an" => 1, + "about" => match parts.next()? { + "a" | "an" => 1, + value => value.parse::().ok()?, + }, + value => value.parse::().ok()?, + }; + let unit = parts.next().unwrap_or("second"); + let seconds = if unit.starts_with("second") { + amount + } else if unit.starts_with("minute") { + amount.saturating_mul(60) + } else if unit.starts_with("hour") { + amount.saturating_mul(60 * 60) + } else if unit.starts_with("day") { + amount.saturating_mul(24 * 60 * 60) + } else if unit.starts_with("week") { + amount.saturating_mul(7 * 24 * 60 * 60) + } else if unit.starts_with("month") { + amount.saturating_mul(30 * 24 * 60 * 60) + } else if unit.starts_with("year") { + amount.saturating_mul(365 * 24 * 60 * 60) + } else { + return None; + }; + Some(seconds) +} + +fn parse_byte_pair_total(value: &str) -> u64 { + value.split('/').filter_map(parse_human_bytes).sum() +} + +fn parse_human_bytes(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value == "-" { + return None; + } + + let mut number = String::new(); + let mut unit = String::new(); + let mut seen_number = false; + for ch in value.chars() { + if ch.is_ascii_digit() || ch == '.' { + seen_number = true; + number.push(ch); + } else if seen_number && ch.is_ascii_alphabetic() { + unit.push(ch); + } else if seen_number && ch.is_whitespace() { + continue; + } else if seen_number { + break; + } + } + let amount = number.parse::().ok()?; + let multiplier = match unit.to_ascii_lowercase().as_str() { + "" | "b" => 1.0, + "k" | "kb" => 1_000.0, + "m" | "mb" => 1_000_000.0, + "g" | "gb" => 1_000_000_000.0, + "t" | "tb" => 1_000_000_000_000.0, + "kib" => 1024.0, + "mib" => 1024.0 * 1024.0, + "gib" => 1024.0 * 1024.0 * 1024.0, + "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + _ => return None, + }; + Some((amount.max(0.0) * multiplier).round() as u64) +} + +fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + + let mut value = bytes as f64; + let mut unit = 0; + while value >= KIB && unit < UNITS.len() - 1 { + value /= KIB; + unit += 1; + } + + if unit == 0 { + format!("{bytes}B") + } else if value >= 10.0 { + format!("{value:.1}{}", UNITS[unit]) + } else { + format!("{value:.2}{}", UNITS[unit]) + } +} + +fn parse_duration(s: &str) -> anyhow::Result { + if let Some(ms) = s.strip_suffix("ms") { + return Ok(Duration::from_millis(ms.parse()?)); + } + if let Some(sec) = s.strip_suffix('s') { + let seconds: f64 = sec.parse()?; + return Ok(Duration::from_secs_f64(seconds)); + } + Ok(Duration::from_millis(s.parse()?)) +} + +fn compact_json_value(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => truncate(s, 80), + _ => truncate(&v.to_string(), 80), + } +} + +fn display_workspace(path: Option<&str>) -> String { + let Some(path) = path.map(str::trim).filter(|path| !path.is_empty()) else { + return "-".to_string(); + }; + truncate(path, 72) +} + +fn display_model(model: &str) -> String { + let model = model.trim(); + if model.is_empty() || model == "-" { + "-".to_string() + } else { + truncate(model, 40) + } +} + +fn display_provider(provider: &str) -> String { + let provider = provider.trim(); + if provider.is_empty() || provider == "-" { + "-".to_string() + } else { + truncate(provider, 28) + } +} + +fn normalize_ports(ports: &str) -> String { + let ports = ports.trim(); + if ports.is_empty() { + "-".to_string() + } else { + ports.to_string() + } +} + +fn display_ports(ports: &str) -> String { + let ports = normalize_ports(ports); + if ports == "-" { + ports + } else { + truncate(&ports, 72) + } +} + +fn container_web_url(container: &ContainerRow) -> Option { + first_published_host_port(&container.ports).map(|port| format!("http://localhost:{port}/")) +} + +fn first_published_host_port(ports: &str) -> Option { + normalize_ports(ports) + .split(',') + .find_map(parse_published_host_port) +} + +fn parse_published_host_port(port: &str) -> Option { + let port = port.trim(); + if port.is_empty() || port == "-" { + return None; + } + + if let Some((host, _guest)) = port.split_once("->") { + return parse_host_side_port(host); + } + + let without_proto = port.split('/').next().unwrap_or(port).trim(); + let parts = without_proto + .split(':') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect::>(); + + match parts.len() { + 0 | 1 => None, + 2 => parse_nonzero_port(parts[0]), + _ => parse_nonzero_port(parts[parts.len().saturating_sub(2)]), + } +} + +fn parse_host_side_port(host: &str) -> Option { + let host = host.split('/').next().unwrap_or(host).trim(); + let candidate = host.rsplit(':').find(|part| !part.trim().is_empty())?; + parse_nonzero_port(candidate) +} + +fn parse_nonzero_port(value: &str) -> Option { + let cleaned = value + .trim() + .trim_start_matches('[') + .trim_end_matches(']') + .trim(); + let port = cleaned.parse::().ok()?; + (port > 0).then_some(port) +} + +fn format_avg_ms(total_ms: u64, samples: u64) -> String { + match total_ms.checked_div(samples) { + Some(avg) => format_duration_ms(avg), + None => "-".to_string(), + } +} + +fn format_duration_ms(ms: u64) -> String { + if ms >= 1000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + format!("{ms}ms") + } +} + +fn format_count(value: u64) -> String { + if value >= 1_000_000 { + format!("{:.1}M", value as f64 / 1_000_000.0) + } else if value >= 1_000 { + format!("{:.1}K", value as f64 / 1_000.0) + } else { + value.to_string() + } +} + +fn push_agent_more_leaf( + children: &mut Vec, + total: usize, + shown: usize, + label: &str, + agent: AgentKind, +) { + if total > shown { + children.push(TreeNode::leaf(agent_tree_label( + agent, + format!("... {} more {}", total.saturating_sub(shown), label), + ))); + } +} + +fn display_cmd(command: &str) -> String { + truncate(command, 64) +} + +fn short_id(id: &str) -> &str { + id.get(..12.min(id.len())).unwrap_or(id) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + if max <= 1 { + return "…".to_string(); + } + let mut out = s.chars().take(max - 1).collect::(); + out.push('…'); + out +} + +fn pad_plain(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + truncate(s, width) + } else { + format!("{s}{}", " ".repeat(width - len)) + } +} + +fn pad_line(s: &str, width: usize) -> String { + let visible = a3s_tui::style::visible_len(s); + if visible >= width { + s.to_string() + } else { + format!("{s}{}", " ".repeat(width - visible)) + } +} + +fn invert_screen(screen: &str) -> String { + format!( + "\x1b[7m{}\x1b[0m", + screen.replace("\x1b[0m", "\x1b[0m\x1b[7m") + ) +} + +fn center(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + return truncate(s, width); + } + let left = (width - len) / 2; + format!( + "{}{}{}", + " ".repeat(left), + s, + " ".repeat(width - len - left) + ) +} + +fn format_event_pid(pid: Option) -> String { + pid.map(|pid| pid.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +fn format_optional_pct(value: Option) -> String { + value + .map(|value| format!("{value:.1}")) + .unwrap_or_else(|| "-".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn all_columns_config() -> TopConfig { + TopConfig { + hidden_columns: HashSet::new(), + ..TopConfig::default() + } + } + + #[test] + fn detects_known_agents() { + assert_eq!(detect_agent("/usr/bin/a3s code"), Some(AgentKind::A3sCode)); + assert_eq!( + detect_agent("node /bin/claude"), + Some(AgentKind::ClaudeCode) + ); + assert_eq!(detect_agent("codex exec task"), Some(AgentKind::Codex)); + } + + #[test] + fn parses_process_rows() { + let row = parse_process_line(" 123 1 2.5 0.7 01:02:03 codex exec hello").unwrap(); + assert_eq!(row.pid, 123); + assert_eq!(row.ppid, 1); + assert_eq!(row.agent, Some(AgentKind::Codex)); + assert_eq!(row.cpu_pct, 2.5); + assert!(row.cwd.is_none()); + } + + #[test] + fn parses_lsof_cwd_output() { + let text = "p123\nn/Users/roylin/code/a3s\n"; + assert_eq!( + parse_lsof_cwd(text).as_deref(), + Some("/Users/roylin/code/a3s") + ); + } + + #[test] + fn parses_durations() { + assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); + assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); + assert_eq!(parse_duration("1500").unwrap(), Duration::from_millis(1500)); + } + + #[test] + fn defaults_to_agents_view_with_a3s_box_connector() { + let options = TopOptions::default(); + + assert_eq!(options.tab, Tab::Agents); + assert_eq!(options.config.connector, ContainerConnector::A3sBox); + } + + #[test] + fn parses_all_container_option() { + let options = parse_options(vec![ + "--containers".into(), + "--all".into(), + "--filter".into(), + "codex".into(), + "--sort".into(), + "mem".into(), + "--risk".into(), + "high".into(), + "--kind".into(), + "security".into(), + "--connector".into(), + "runc".into(), + "--reverse".into(), + "--invert".into(), + "--compact".into(), + "--watch".into(), + "2s".into(), + ]) + .unwrap(); + + assert_eq!(options.tab, Tab::Containers); + assert!(options.force_all_containers); + assert!(!options.force_active_containers); + assert_eq!(options.filter.as_deref(), Some("codex")); + assert_eq!(options.sort_by, Some(SortBy::Mem)); + assert_eq!(options.risk_filter, Some(RiskFilter::High)); + assert_eq!(options.kind_filter, Some(KindFilter::Security)); + assert_eq!(options.connector, Some(ContainerConnector::RunC)); + assert_eq!(options.reverse_sort, Some(true)); + assert!(options.invert_colors); + assert!(options.compact_columns); + assert!(options.watch); + assert_eq!(options.interval, Duration::from_secs(2)); + } + + #[test] + fn parses_ctop_style_short_options_and_start_help() { + let options = parse_options(vec![ + "-f".into(), + "api".into(), + "-s".into(), + "pids".into(), + "-r".into(), + "-h".into(), + ]) + .unwrap(); + + assert_eq!(options.filter.as_deref(), Some("api")); + assert_eq!(options.sort_by, Some(SortBy::Pids)); + assert_eq!(options.reverse_sort, Some(true)); + assert!(options.start_help); + + let app = TopApp::new(options); + assert!(app.help); + } + + #[test] + fn parses_ctop_style_single_container_target() { + let options = parse_options(vec!["web".into()]).unwrap(); + + assert_eq!(options.tab, Tab::Containers); + assert_eq!(options.container_query.as_deref(), Some("web")); + + let options = parse_options(vec!["--container".into(), "abcdef".into()]).unwrap(); + assert_eq!(options.tab, Tab::Containers); + assert_eq!(options.container_query.as_deref(), Some("abcdef")); + + assert!(parse_options(vec!["web".into(), "api".into()]) + .unwrap_err() + .to_string() + .contains("only one container target")); + } + + #[test] + fn single_container_target_defaults_to_all_unless_active_is_forced() { + let mut options = TopOptions { + container_query: Some("stopped-job".into()), + config: TopConfig { + show_all_containers: false, + ..TopConfig::default() + }, + ..TopOptions::default() + }; + + apply_cli_overrides(&mut options, false, false); + + assert_eq!(options.tab, Tab::Containers); + assert!(options.config.show_all_containers); + + let mut options = TopOptions { + container_query: Some("running-only".into()), + force_active_containers: true, + config: TopConfig { + show_all_containers: true, + ..TopConfig::default() + }, + ..TopOptions::default() + }; + + apply_cli_overrides(&mut options, false, true); + + assert_eq!(options.tab, Tab::Containers); + assert!(!options.config.show_all_containers); + } + + #[test] + fn parses_llm_kind_option() { + let options = + parse_options(vec!["--events".into(), "--kind".into(), "llm".into()]).unwrap(); + + assert_eq!(options.tab, Tab::Events); + assert_eq!(options.kind_filter, Some(KindFilter::Llm)); + assert!(KindFilter::Llm.matches("LlmApi")); + assert!(KindFilter::Llm.matches("LlmCall")); + assert!(!KindFilter::Other.matches("LlmApi")); + } + + #[test] + fn parses_tokens_sort_option() { + let options = parse_options(vec!["--sort".into(), "tokens".into()]).unwrap(); + + assert_eq!(options.sort_by, Some(SortBy::Tokens)); + assert_eq!(SortBy::Mem.next(), SortBy::Net); + assert_eq!(SortBy::Net.next(), SortBy::Block); + assert_eq!(SortBy::Block.next(), SortBy::Pids); + assert_eq!(SortBy::Pids.next(), SortBy::State); + assert_eq!(SortBy::State.next(), SortBy::Id); + assert_eq!(SortBy::Id.next(), SortBy::Uptime); + assert_eq!(SortBy::Uptime.next(), SortBy::Name); + assert_eq!(SortBy::Name.next(), SortBy::Tokens); + assert_eq!(SortBy::Tokens.next(), SortBy::Cpu); + } + + #[test] + fn parses_container_io_sort_options() { + assert_eq!( + parse_options(vec!["--sort".into(), "net".into()]) + .unwrap() + .sort_by, + Some(SortBy::Net) + ); + assert_eq!(SortBy::from_label("block"), Some(SortBy::Block)); + assert_eq!(SortBy::from_label("io"), Some(SortBy::Block)); + assert_eq!(SortBy::from_label("pids"), Some(SortBy::Pids)); + assert_eq!(SortBy::from_label("state"), Some(SortBy::State)); + assert_eq!(SortBy::from_label("id"), Some(SortBy::Id)); + assert_eq!(SortBy::from_label("uptime"), Some(SortBy::Uptime)); + } + + #[test] + fn parses_sessions_option() { + let options = parse_options(vec!["--sessions".into()]).unwrap(); + + assert_eq!(options.tab, Tab::Sessions); + } + + #[test] + fn parses_json_option() { + let options = parse_options(vec!["--json".into(), "--containers".into()]).unwrap(); + + assert!(options.json); + assert_eq!(options.tab, Tab::Containers); + } + + #[test] + fn parses_json_watch_count_option() { + let options = parse_options(vec![ + "--json".into(), + "--watch".into(), + "25ms".into(), + "--count".into(), + "3".into(), + ]) + .unwrap(); + + assert!(options.json); + assert!(options.watch); + assert_eq!(options.interval, Duration::from_millis(25)); + assert_eq!(options.json_count, Some(3)); + assert!(parse_options(vec!["--count".into(), "0".into()]).is_err()); + } + + #[test] + fn invert_screen_reapplies_reverse_after_resets() { + let rendered = invert_screen("left\x1b[31mred\x1b[0mright"); + + assert!(rendered.starts_with("\x1b[7m")); + assert!(rendered.contains("\x1b[0m\x1b[7mright")); + assert!(rendered.ends_with("\x1b[0m")); + } + + #[test] + fn parses_top_config() { + let config = parse_top_config( + r#"{ + "show_all_containers": true, + "show_header": false, + "reverse_sort": true, + "sort_by": "mem", + "risk_filter": "high", + "kind_filter": "tool", + "connector": "runc", + "filter": "codex", + "hidden_columns": ["containers.net", "events.message"] + }"#, + ) + .unwrap(); + + assert!(config.show_all_containers); + assert!(!config.show_header); + assert!(config.reverse_sort); + assert_eq!(config.sort_by, SortBy::Mem); + assert_eq!(config.risk_filter, RiskFilter::High); + assert_eq!(config.kind_filter, KindFilter::Tool); + assert_eq!(config.connector, ContainerConnector::RunC); + assert_eq!(config.filter, "codex"); + assert!(config.hidden_columns.contains("containers.net")); + assert!(config.hidden_columns.contains("events.message")); + } + + #[test] + fn cli_overrides_loaded_config() { + let mut options = TopOptions { + force_all_containers: true, + force_active_containers: false, + filter: Some("claude".into()), + sort_by: Some(SortBy::Name), + risk_filter: Some(RiskFilter::High), + kind_filter: Some(KindFilter::Security), + connector: Some(ContainerConnector::RunC), + reverse_sort: Some(true), + show_header: Some(false), + compact_columns: true, + config: TopConfig { + filter: "codex".into(), + sort_by: SortBy::Cpu, + risk_filter: RiskFilter::All, + kind_filter: KindFilter::All, + hidden_columns: HashSet::new(), + ..TopConfig::default() + }, + ..TopOptions::default() + }; + + apply_cli_overrides(&mut options, true, false); + + assert!(options.config.show_all_containers); + assert_eq!(options.config.filter, "claude"); + assert_eq!(options.config.sort_by, SortBy::Name); + assert_eq!(options.config.risk_filter, RiskFilter::High); + assert_eq!(options.config.kind_filter, KindFilter::Security); + assert_eq!(options.config.connector, ContainerConnector::RunC); + assert!(options.config.reverse_sort); + assert!(!options.config.show_header); + assert!(options.config.hidden_columns.contains("agents.session")); + assert!(options.config.hidden_columns.contains("containers.ports")); + assert!(!options.config.hidden_columns.contains("agents.command")); + } + + #[test] + fn ctop_active_flag_overrides_saved_all_container_config() { + let options = parse_options(vec!["-a".into()]).unwrap(); + assert!(options.force_active_containers); + assert!(!options.force_all_containers); + + let mut options = TopOptions { + force_active_containers: true, + config: TopConfig { + show_all_containers: true, + ..TopConfig::default() + }, + ..TopOptions::default() + }; + + apply_cli_overrides(&mut options, false, true); + + assert!(!options.config.show_all_containers); + } + + #[test] + fn container_visibility_flags_use_last_value() { + let options = parse_options(vec!["-a".into(), "--all".into()]).unwrap(); + assert!(options.force_all_containers); + assert!(!options.force_active_containers); + + let options = parse_options(vec!["--all".into(), "--active".into()]).unwrap(); + assert!(options.force_active_containers); + assert!(!options.force_all_containers); + } + + #[test] + fn config_json_sorts_hidden_columns() { + let mut config = TopConfig::default(); + config.hidden_columns.insert("events.source".into()); + config.hidden_columns.insert("agents.cpu".into()); + + let text = top_config_json(&config); + let agents_idx = text.find("agents.cpu").unwrap(); + let events_idx = text.find("events.source").unwrap(); + + assert!(agents_idx < events_idx); + assert!(text.contains("\"risk_filter\": \"all\"")); + assert!(text.contains("\"kind_filter\": \"all\"")); + } + + #[test] + fn default_columns_are_compact_but_core_agent_fields_remain() { + let config = TopConfig::default(); + assert!(config.hidden_columns.contains("agents.session")); + assert!(config.hidden_columns.contains("agents.task")); + assert!(config.hidden_columns.contains("agents.tools")); + assert!(config.hidden_columns.contains("containers.ports")); + assert!(config.hidden_columns.contains("containers.health")); + assert!(config.hidden_columns.contains("containers.image")); + assert!(config.hidden_columns.contains("containers.mem_usage")); + assert!(config.hidden_columns.contains("containers.cpus")); + assert!(config.hidden_columns.contains("events.pid")); + assert!(!config.hidden_columns.contains("agents.agent")); + assert!(!config.hidden_columns.contains("agents.cpu")); + assert!(!config.hidden_columns.contains("agents.command")); + assert!(!config.hidden_columns.contains("containers.status")); + assert!(!config.hidden_columns.contains("containers.name")); + assert!(!config.hidden_columns.contains("containers.id")); + assert!(!config.hidden_columns.contains("containers.cpu")); + assert!(!config.hidden_columns.contains("containers.mem")); + assert!(!config.hidden_columns.contains("containers.net")); + assert!(!config.hidden_columns.contains("containers.block")); + assert!(!config.hidden_columns.contains("containers.pids")); + assert!(!config.hidden_columns.contains("containers.uptime")); + + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 260; + app.snapshot.processes = vec![ + process_row(42, 1, "codex exec task"), + process_row(100, 42, "bash -lc git status"), + ]; + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "git status", + Risk::Medium, + )]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("Agents")); + assert!(plain.contains("codex")); + assert!(plain.contains("Sessions (1)")); + assert!(plain.contains("Processes (1 system · 1 agent)")); + assert!(plain.contains("Events (1)")); + assert!(plain.contains("sess-a")); + assert!(plain.contains("task-a")); + assert!(plain.contains("> pid 100")); + } + + #[test] + fn default_container_columns_align_with_ctop_and_show_cid() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.width = 260; + app.snapshot.containers = vec![container_row( + "abcdef1234567890", + "a3s-box-dev", + "Up 2 minutes", + Some(12.5), + Some(30.0), + )]; + app.record_history(&mut app.snapshot.clone()); + let plain = a3s_tui::style::strip_ansi(&app.table()); + + for header in [ + "STATUS", "NAME", "CID", "CPU", "MEM", "NET I/O", "IO", "PIDS", "UPTIME", + ] { + assert!(plain.contains(header), "missing {header} in {plain}"); + } + assert!(plain.contains("abcdef123456")); + assert!(plain.contains("12.5%")); + assert!(plain.contains("30.0%")); + assert!(plain.contains("2 minutes")); + assert!(!plain.contains("CPUS")); + assert!(!plain.contains("PORTS")); + assert!(!plain.contains("HEALTH")); + assert!(!plain.contains("IMAGE")); + assert!(!plain.contains("MEM USAGE")); + } + + #[test] + fn scaled_cpu_column_is_configurable_like_ctop_cpus() { + assert_eq!(scale_cpu_pct_by(200.0, 4), 50.0); + assert_eq!(scale_cpu_pct_by(42.0, 0), 42.0); + assert_eq!(scale_cpu_pct_for_cpus(200.0, Some(2)), 100.0); + + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 300; + let mut row = container_row( + "abcdef1234567890", + "a3s-box-dev", + "Up 2 minutes", + Some(100.0), + Some(30.0), + ); + row.cpu_count = Some(2); + app.snapshot.containers = vec![row]; + app.record_history(&mut app.snapshot.clone()); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + let scaled = format!("{:.1}%", scale_cpu_pct_for_cpus(100.0, Some(2))); + + assert!(plain.contains("CPUS"), "{plain}"); + assert!(plain.contains(&scaled), "{plain}"); + } + + #[test] + fn narrow_container_table_keeps_ctop_identity_and_core_metrics() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.width = 80; + app.snapshot.containers = vec![container_row( + "abcdef1234567890", + "a3s-box-dev", + "Up 2 minutes", + Some(12.5), + Some(30.0), + )]; + app.record_history(&mut app.snapshot.clone()); + let plain = a3s_tui::style::strip_ansi(&app.table()); + + for header in ["STATUS", "NAME", "CID", "CPU", "MEM"] { + assert!(plain.contains(header), "missing {header} in {plain}"); + } + assert!(plain.contains("abcdef123456")); + assert!(plain.contains("12.5%")); + assert!(plain.contains("30.0%")); + assert!(!plain.contains("NET I/O")); + assert!(!plain.contains("UPTIME")); + } + + #[test] + fn container_table_marks_current_sort_column() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.width = 260; + app.snapshot.containers = vec![container_row( + "abcdef1234567890", + "a3s-box-dev", + "Up 2 minutes", + Some(12.5), + Some(30.0), + )]; + app.record_history(&mut app.snapshot.clone()); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + let header = plain.lines().next().unwrap(); + assert!(header.contains("CPU↓"), "{header}"); + assert!(!header.contains("MEM↓"), "{header}"); + + app.sort_by = SortBy::Mem; + app.reverse_sort = true; + let plain = a3s_tui::style::strip_ansi(&app.table()); + let header = plain.lines().next().unwrap(); + assert!(header.contains("MEM↑"), "{header}"); + assert!(!header.contains("CPU↑"), "{header}"); + } + + #[test] + fn snapshot_json_includes_agents_sessions_containers_and_events() { + let mut process = process_row(42, 1, "codex exec task"); + process.cwd = Some("/work/a3s".into()); + process.cpu_pct = 12.5; + let child = process_row(100, 42, "bash -lc cargo test"); + let grandchild = process_row(101, 100, "cargo test"); + let mut container = container_row("abcdef123456", "app", "Up", Some(1.0), Some(2.0)); + container.inspect.health = "healthy".into(); + let mut app = TopApp::new(TopOptions::default()); + let mut snapshot = TopSnapshot { + processes: vec![process, child, grandchild], + containers: vec![container], + events: vec![ + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"sess-a","task":"task-a"},"event":{"ToolExec":{"pid":42,"argv":["git","status"],"cwd":"/work/a3s"}}}"#, + ) + .unwrap(), + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"sess-b","task":"task-b"},"event":{"SecurityAction":{"pid":100,"ppid":42,"action":"ptrace","cwd":"/work/a3s"}}}"#, + ) + .unwrap(), + ], + ..Default::default() + }; + app.record_history(&mut snapshot); + app.snapshot = snapshot; + + let value = top_snapshot_json(&app, 123); + + assert_eq!(value["schema"], "a3s.top.snapshot.v1"); + assert_eq!(value["collected_at_unix_ms"], 123); + assert_eq!(value["summary"]["agents"], 1); + assert_eq!(value["summary"]["sessions"], 2); + assert_eq!(value["summary"]["containers"], 1); + assert_eq!(value["summary"]["container_states"]["running"], 1); + assert_eq!(value["summary"]["container_states"]["total"], 1); + assert_eq!(value["summary"]["raw_container_states"]["running"], 1); + assert_eq!(value["agents"][0]["agent"], "codex"); + assert_eq!(value["agents"][0]["activity"]["tools"], 1); + assert_eq!(value["agents"][0]["activity"]["sessions"], 2); + assert_eq!(value["agents"][0]["top_session"], "sess-b"); + assert_eq!(value["agents"][0]["top_task"], "task-b"); + assert_eq!(value["agents"][0]["sessions"][0]["session"], "sess-b"); + assert_eq!(value["agents"][0]["sessions"][0]["security"], 1); + assert_eq!(value["agents"][0]["sessions"][1]["session"], "sess-a"); + assert_eq!(value["agents"][0]["sessions"][1]["tools"], 1); + assert_eq!(value["agents"][0]["history"]["cpu_pct"][0], 12.5); + assert_eq!(value["agents"][0]["recent_events"][0]["kind"], "ToolExec"); + assert_eq!(value["sessions"][0]["workspace"], "/work/a3s"); + assert_eq!(value["containers"][0]["cid"], "abcdef123456"); + assert_eq!(value["containers"][0]["short_id"], "abcdef123456"); + assert_eq!(value["containers"][0]["inspect"]["health"], "healthy"); + assert_eq!(value["containers"][0]["history"]["cpu_pct"][0], 1.0); + assert_eq!(value["processes"][0]["history"]["cpu_pct"][0], 12.5); + assert_eq!(value["agents"][0]["subtree"]["descendants"], 2); + assert_eq!(value["agents"][0]["process_tree"]["pid"], 42); + assert_eq!( + value["agents"][0]["process_tree"]["children"][0]["pid"], + 100 + ); + assert_eq!( + value["agents"][0]["process_tree"]["children"][0]["children"][0]["pid"], + 101 + ); + let details = value["events"][0]["details"].as_array().unwrap(); + assert!(details.iter().any(|detail| detail["key"] == "argv")); + } + + #[test] + fn keeps_one_visible_column_per_tab() { + let mut config = TopConfig::default(); + for id in [ + "containers.status", + "containers.name", + "containers.id", + "containers.cpu", + "containers.cpus", + "containers.mem", + "containers.net", + "containers.block", + "containers.pids", + "containers.uptime", + "containers.image", + "containers.mem_usage", + "containers.ports", + "containers.health", + ] { + config.hidden_columns.insert(id.into()); + } + let app = TopApp::new(TopOptions { + config, + ..TopOptions::default() + }); + + assert!(app.column_visible("containers.status")); + } + + #[test] + fn column_panel_can_restore_compact_defaults_for_current_tab() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + config: all_columns_config(), + ..TopOptions::default() + }); + app.hidden_columns.insert("events.source".into()); + + assert!(app.column_visible("agents.session")); + assert!(app.column_visible("agents.tools")); + assert!(!app.column_visible("events.source")); + + app.open_column_panel(); + app.handle_key(KeyEvent { + code: KeyCode::Char('d'), + modifiers: KeyModifiers::empty(), + }); + + assert!(app.column_panel.is_none()); + assert!(!app.column_visible("agents.session")); + assert!(!app.column_visible("agents.tools")); + assert!(app.column_visible("agents.agent")); + assert!(app.column_visible("agents.command")); + assert!(!app.column_visible("events.source")); + } + + #[test] + fn column_panel_accepts_number_shortcuts() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + + assert!(app.column_visible("containers.id")); + app.open_column_panel(); + let plain = a3s_tui::style::strip_ansi(&app.column_panel_view()); + assert!(plain.contains("> 1 [x] Status")); + assert!(plain.contains(" 2 [x] Name")); + assert!(plain.contains(" 3 [x] CID")); + + app.handle_key(KeyEvent { + code: KeyCode::Char('3'), + modifiers: KeyModifiers::empty(), + }); + app.handle_key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::empty(), + }); + + assert!(app.column_panel.is_none()); + assert!(!app.column_visible("containers.id")); + assert!(app.column_visible("containers.name")); + } + + #[test] + fn help_view_mentions_compact_column_restore() { + let app = TopApp::new(TopOptions::default()); + + let plain = a3s_tui::style::strip_ansi(&app.help_view()); + + assert!(plain.contains("c then d")); + assert!(plain.contains("restore compact columns")); + assert!(plain.contains("s / r")); + assert!(plain.contains("clear filter")); + } + + #[test] + fn shifted_uppercase_keys_resolve() { + let keymap = top_keymap(); + let key = |c| KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::SHIFT, + }; + + assert_eq!(keymap.resolve(&key('S')), Some(TopKey::SaveConfig)); + assert_eq!(keymap.resolve(&key('K')), Some(TopKey::Kill)); + assert_eq!(keymap.resolve(&key('H')), Some(TopKey::ToggleHeader)); + assert_eq!(keymap.resolve(&key('R')), Some(TopKey::ToggleReverse)); + assert_eq!(keymap.resolve(&key('!')), Some(TopKey::ToggleRiskFilter)); + assert_eq!(keymap.resolve(&key('C')), Some(TopKey::Connector)); + assert_eq!(keymap.resolve(&key('G')), Some(TopKey::ToggleKindFilter)); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Char('g'), + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::ToggleKindFilter) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::Filter) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::ToggleReverse) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::OpenBrowser) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::Help) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::TogglePause) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::Home, + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::Home) + ); + assert_eq!( + keymap.resolve(&KeyEvent { + code: KeyCode::End, + modifiers: KeyModifiers::empty(), + }), + Some(TopKey::End) + ); + } + + #[test] + fn home_end_jump_selection_and_p_toggles_pause() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.snapshot.containers = vec![ + container_row("aaa111", "alpha", "Up", Some(1.0), Some(1.0)), + container_row("bbb222", "beta", "Up", Some(1.0), Some(1.0)), + container_row("ccc333", "gamma", "Up", Some(1.0), Some(1.0)), + ]; + + app.handle_key(KeyEvent { + code: KeyCode::End, + modifiers: KeyModifiers::empty(), + }); + assert_eq!(app.selected, 2); + + app.handle_key(KeyEvent { + code: KeyCode::Home, + modifiers: KeyModifiers::empty(), + }); + assert_eq!(app.selected, 0); + + app.handle_key(KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::empty(), + }); + assert!(app.paused); + } + + #[test] + fn ctop_arrow_keys_open_container_view_and_logs() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.snapshot.containers = vec![container_row( + "abcdef", + "app", + "Up 2 minutes", + Some(1.0), + Some(2.0), + )]; + + let focus_cmd = app.handle_key(KeyEvent { + code: KeyCode::Right, + modifiers: KeyModifiers::empty(), + }); + + assert!(focus_cmd.is_some()); + assert_eq!(app.focused_container.as_deref(), Some("abcdef")); + assert!(app + .container_processes + .as_ref() + .is_some_and(|panel| panel.container_id == "abcdef" && panel.loading)); + + app.focused_container = None; + app.container_processes = None; + let log_cmd = app.handle_key(KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::empty(), + }); + + assert!(log_cmd.is_some()); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.container_id, "abcdef"); + assert_eq!(log.container_name, "app"); + assert!(log.loading); + assert_eq!(app.tab, Tab::Containers); + } + + #[test] + fn primary_tab_order_is_agents_then_containers() { + assert_eq!( + Tab::ALL, + [ + Tab::Agents, + Tab::Containers, + Tab::Sessions, + Tab::Events, + Tab::Processes, + ] + ); + assert_eq!(Tab::PRIMARY, [Tab::Agents, Tab::Containers]); + assert_eq!(Tab::Agents.next(), Tab::Containers); + assert_eq!(Tab::Containers.next(), Tab::Agents); + assert_eq!(Tab::Agents.prev(), Tab::Containers); + } + + #[test] + fn arrow_keys_switch_primary_agents_and_containers_tabs() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + + app.handle_key(KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::empty(), + }); + assert_eq!(app.tab, Tab::Containers); + + app.handle_key(KeyEvent { + code: KeyCode::BackTab, + modifiers: KeyModifiers::empty(), + }); + assert_eq!(app.tab, Tab::Agents); + } + + #[test] + fn lowercase_r_reverses_sort_without_restarting_container() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.snapshot.containers = vec![container_row( + "abcdef123456", + "a3s-box-dev", + "Up 2 minutes", + Some(12.5), + Some(30.0), + )]; + + app.handle_key(KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::empty(), + }); + + assert!(app.reverse_sort); + assert!(app.confirm.is_none()); + assert!(app.container_menu.is_none()); + + app.handle_key(KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::empty(), + }); + + assert!(!app.reverse_sort); + assert!(app.confirm.is_none()); + } + + #[test] + fn parses_observer_events() { + let line = r#"{"timestamp":"2026-06-26T08:00:00Z","identity":{"agent":"codex","task":"task-1","session":"sess-1"},"provider":null,"event":{"ToolExec":{"pid":2,"argv":["git","status"],"cwd":"/tmp"}}}"#; + let row = parse_observer_line(line).unwrap(); + assert_eq!(row.ts, "2026-06-26T08:00:00Z"); + assert_eq!(row.source, "codex"); + assert_eq!(row.session.as_deref(), Some("sess-1")); + assert_eq!(row.task.as_deref(), Some("task-1")); + assert_eq!(row.pid, Some(2)); + assert!(row.ppid.is_none()); + assert_eq!(row.kind, "ToolExec"); + assert_eq!(row.risk, Risk::Medium); + assert!(row.message.contains("argv=[\"git\",\"status\"]")); + assert!(row + .details + .iter() + .any(|(key, value)| key == "cwd" && value == "/tmp")); + } + + #[test] + fn parses_numeric_observer_timestamps() { + let row = parse_observer_line( + r#"{"ts":1782460800000,"identity":{"agent":"codex"},"event":{"ToolExec":{"argv":["pwd"]}}}"#, + ) + .unwrap(); + + assert_eq!(row.ts, "1782460800000"); + } + + #[test] + fn events_table_renders_process_scope_columns() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 360; + app.snapshot.events = vec![parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"pid":77,"ppid":42,"argv":["git","status"],"cwd":"/tmp/a3s"}}}"#, + ) + .unwrap()]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("PID")); + assert!(plain.contains("PPID")); + assert!(plain.contains("77")); + assert!(plain.contains("42")); + } + + #[test] + fn events_process_scope_columns_are_configurable() { + let app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + let choices = app.column_choices(Tab::Events); + + assert!(choices.iter().any(|choice| choice.id == "events.pid")); + assert!(choices.iter().any(|choice| choice.id == "events.ppid")); + } + + #[test] + fn event_filter_matches_payload_details() { + let line = r#"{"identity":{"agent":"codex","task":"task-1","session":"sess-1"},"event":{"ToolExec":{"a":1,"b":2,"c":3,"d":4,"z":"needle-value"}}}"#; + let row = parse_observer_line(line).unwrap(); + assert!(!row.message.contains("needle-value")); + + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.filter = "needle".into(); + app.snapshot.events = vec![row]; + + assert_eq!(app.filtered_events().len(), 1); + } + + #[test] + fn container_lifecycle_events_track_a3s_box_state_changes() { + let mut previous = + container_row("abcdef1234567890", "dev", "running", Some(1.0), Some(2.0)); + previous.connector = ContainerConnector::A3sBox; + previous.inspect.health = "healthy".into(); + let mut current = previous.clone(); + current.status = "paused".into(); + + let events = + container_lifecycle_events(ContainerConnector::A3sBox, &[previous], &[current]); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].source, "a3s-box"); + assert_eq!(events[0].kind, "Container"); + assert_eq!(events[0].risk, Risk::Medium); + assert!(events[0].message.contains("pause dev")); + assert!(events[0] + .details + .contains(&("action".into(), "pause".into()))); + assert!(events[0] + .details + .contains(&("cid".into(), "abcdef123456".into()))); + assert!(events[0] + .details + .contains(&("previous_status".into(), "running".into()))); + } + + #[test] + fn runtime_container_events_are_merged_into_snapshots() { + let mut previous = + container_row("abcdef1234567890", "dev", "running", Some(1.0), Some(2.0)); + previous.connector = ContainerConnector::A3sBox; + let mut current = previous.clone(); + current.status = "paused".into(); + let snapshot = TopSnapshot { + containers: vec![current], + ..Default::default() + }; + let mut app = TopApp::new(TopOptions::default()); + app.last_refresh = Some(Instant::now()); + app.snapshot.containers = vec![previous]; + + app.apply_snapshot( + snapshot, + ObserverState::default(), + ContainerConnector::A3sBox, + ); + + assert_eq!(app.snapshot.events.len(), 1); + assert_eq!(app.snapshot.events[0].source, "a3s-box"); + assert_eq!(app.snapshot.events[0].kind, "Container"); + let value = top_snapshot_json(&app, 123); + assert_eq!(value["events"][0]["source"], "a3s-box"); + assert_eq!(value["events"][0]["kind"], "Container"); + assert_eq!(value["events"][0]["details"][1]["key"], "action"); + assert_eq!(value["events"][0]["details"][1]["value"], "pause"); + } + + #[test] + fn counts_process_descendants() { + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.processes = vec![ + process_row(1, 0, "codex"), + process_row(2, 1, "node child"), + process_row(3, 2, "sh grandchild"), + process_row(4, 0, "other"), + ]; + + assert_eq!(app.descendant_count(1), 2); + assert_eq!(app.descendant_count(4), 0); + } + + #[test] + fn scopes_agent_activity_to_process_tree() { + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.processes = vec![ + process_row(10, 1, "codex exec left"), + process_row(20, 1, "codex exec right"), + process_row(11, 10, "bash child"), + process_row(21, 20, "bash child"), + ]; + app.snapshot.events = vec![ + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"left"},"event":{"ToolExec":{"pid":11,"ppid":10,"argv":["git","status"],"cwd":"/tmp"}}}"#, + ) + .unwrap(), + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"right"},"event":{"SecurityAction":{"pid":999,"ppid":20,"action":"ptrace","target":"other"}}}"#, + ) + .unwrap(), + event_row( + "codex", + Some("ambiguous"), + None, + "ToolExec", + "no pid", + Risk::Medium, + ), + ]; + + let left = app.agent_activity_for_process(&app.snapshot.processes[0]); + let right = app.agent_activity_for_process(&app.snapshot.processes[1]); + let aggregate = app.agent_activity(AgentKind::Codex); + + assert_eq!(left.events, 1); + assert_eq!(left.tools, 1); + assert_eq!(left.high_risk, 0); + assert_eq!(right.events, 1); + assert_eq!(right.security, 1); + assert_eq!(right.high_risk, 1); + assert_eq!(aggregate.events, 3); + } + + #[test] + fn scopes_pidless_agent_events_by_workspace() { + let mut left = process_row(10, 1, "codex exec left"); + left.cwd = Some("/work/left".into()); + let mut right = process_row(20, 1, "codex exec right"); + right.cwd = Some("/work/right".into()); + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.processes = vec![left, right]; + app.snapshot.events = vec![ + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"left"},"event":{"ToolExec":{"argv":["git","status"],"cwd":"/work/left/crate"}}}"#, + ) + .unwrap(), + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"right"},"event":{"SecurityAction":{"action":"ptrace","target":"other","cwd":"/work/right"}}}"#, + ) + .unwrap(), + event_row( + "codex", + Some("ambiguous"), + None, + "ToolExec", + "no pid or cwd", + Risk::Medium, + ), + ]; + + let left = app.agent_activity_for_process(&app.snapshot.processes[0]); + let right = app.agent_activity_for_process(&app.snapshot.processes[1]); + + assert_eq!(left.events, 1); + assert_eq!(left.tools, 1); + assert_eq!(right.events, 1); + assert_eq!(right.security, 1); + assert_eq!(right.high_risk, 1); + } + + #[test] + fn pidless_workspace_events_do_not_duplicate_across_matching_agents() { + let mut first = process_row(10, 1, "codex exec first"); + first.cwd = Some("/work/shared".into()); + let mut second = process_row(20, 1, "codex exec second"); + second.cwd = Some("/work/shared".into()); + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.processes = vec![first, second]; + app.snapshot.events = vec![parse_observer_line( + r#"{"identity":{"agent":"codex","session":"shared"},"event":{"ToolExec":{"argv":["git","status"],"cwd":"/work/shared"}}}"#, + ) + .unwrap()]; + + assert_eq!( + app.agent_activity_for_process(&app.snapshot.processes[0]) + .events, + 0 + ); + assert_eq!( + app.agent_activity_for_process(&app.snapshot.processes[1]) + .events, + 0 + ); + } + + #[test] + fn workspace_path_matching_uses_path_boundaries() { + assert!(workspace_paths_overlap("/work/a3s", "/work/a3s/crates/cli")); + assert!(workspace_paths_overlap("/work/a3s/", "\"/work/a3s\"")); + assert!(!workspace_paths_overlap("/work/a3s", "/work/a3s-other")); + assert!(!workspace_paths_overlap("-", "/work/a3s")); + } + + #[test] + fn finds_recent_agent_events() { + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.events = vec![ + EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("sess-a".into()), + task: Some("task-a".into()), + pid: None, + ppid: None, + kind: "ToolExec".into(), + message: "git status".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + EventRow { + ts: "recent".into(), + source: "claude".into(), + session: Some("sess-b".into()), + task: None, + pid: None, + ppid: None, + kind: "FileAccess".into(), + message: "README.md".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + ]; + + let events = app.recent_agent_events(AgentKind::Codex); + let activity = app.agent_activity(AgentKind::Codex); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].message, "git status"); + assert_eq!(activity.events, 1); + assert_eq!(activity.sessions, 1); + assert_eq!(activity.tools, 1); + } + + #[test] + fn aggregates_agent_activity_across_source_aliases() { + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.events = vec![ + EventRow { + ts: "recent".into(), + source: "claude-code".into(), + session: Some("sess-a".into()), + task: Some("task-a".into()), + pid: None, + ppid: None, + kind: "ToolExec".into(), + message: "bash".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + EventRow { + ts: "recent".into(), + source: "claude".into(), + session: Some("sess-a".into()), + task: Some("task-b".into()), + pid: None, + ppid: None, + kind: "SecurityAction".into(), + message: "ptrace".into(), + details: Vec::new(), + risk: Risk::High, + }, + EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("other".into()), + task: None, + pid: None, + ppid: None, + kind: "ToolExec".into(), + message: "git status".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + ]; + + let activity = app.agent_activity(AgentKind::ClaudeCode); + + assert_eq!(activity.events, 2); + assert_eq!(activity.sessions, 1); + assert_eq!(activity.tools, 1); + assert_eq!(activity.security, 1); + assert_eq!(activity.files, 0); + assert_eq!(activity.egress, 0); + assert_eq!(activity.high_risk, 1); + } + + #[test] + fn groups_observer_events_into_session_rows() { + let events = vec![ + EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("sess-a".into()), + task: Some("task-a".into()), + pid: None, + ppid: None, + kind: "ToolExec".into(), + message: "git status".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("sess-a".into()), + task: Some("task-b".into()), + pid: None, + ppid: None, + kind: "SecurityAction".into(), + message: "ptrace".into(), + details: Vec::new(), + risk: Risk::High, + }, + EventRow { + ts: "recent".into(), + source: "collector".into(), + session: None, + task: None, + pid: None, + ppid: None, + kind: "warning".into(), + message: "ignored".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("sess-a".into()), + task: Some("task-c".into()), + pid: None, + ppid: None, + kind: "FileAccess".into(), + message: "README.md".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("sess-a".into()), + task: Some("task-d".into()), + pid: None, + ppid: None, + kind: "Egress".into(), + message: "example.com".into(), + details: Vec::new(), + risk: Risk::Medium, + }, + ]; + + let rows = session_rows(&events); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].source, "codex"); + assert_eq!(rows[0].session, "sess-a"); + assert_eq!(rows[0].events, 4); + assert_eq!(rows[0].tools, 1); + assert_eq!(rows[0].security, 1); + assert_eq!(rows[0].files, 1); + assert_eq!(rows[0].egress, 1); + assert_eq!(rows[0].high_risk, 1); + assert_eq!(rows[0].risk, Risk::High); + assert_eq!(rows[0].workspace, "-"); + } + + #[test] + fn sessions_extract_workspace_from_event_payload() { + let event = parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"argv":["cargo","test"],"cwd":"/Users/roylin/code/a3s"}}}"#, + ) + .unwrap(); + + let rows = session_rows(&[event]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].workspace, "/Users/roylin/code/a3s"); + } + + #[test] + fn aggregates_llm_model_and_tokens() { + let event = parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"LlmApi":{"pid":7,"is_request":false,"model":"gpt-4o","prompt_tokens":12,"completion_tokens":34}}}"#, + ) + .unwrap(); + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.events = vec![event.clone()]; + + let activity = app.agent_activity(AgentKind::Codex); + assert_eq!(activity.llm, 1); + assert_eq!(activity.prompt_tokens, 12); + assert_eq!(activity.completion_tokens, 34); + assert_eq!(activity.total_tokens, 46); + assert_eq!(activity.model, "gpt-4o"); + + let sessions = session_rows(&[event]); + assert_eq!(sessions[0].llm, 1); + assert_eq!(sessions[0].total_tokens, 46); + assert_eq!(sessions[0].model, "gpt-4o"); + } + + #[test] + fn extracts_llm_provider_latency_and_wire_metrics() { + let event = parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"provider":"OpenAi","event":{"LlmCall":{"pid":7,"sni":"api.openai.com","peer":"1.2.3.4","req_bytes":1024,"resp_bytes":2048,"latency":{"secs":1,"nanos":500000000},"ttft":{"secs":0,"nanos":250000000}}}}"#, + ) + .unwrap(); + let network = event_llm_network(&event).unwrap(); + + assert_eq!(network.provider.as_deref(), Some("OpenAi")); + assert_eq!(network.latency_ms, Some(1500)); + assert_eq!(network.ttft_ms, Some(250)); + assert_eq!(network.req_bytes, 1024); + assert_eq!(network.resp_bytes, 2048); + + let mut app = TopApp::new(TopOptions::default()); + app.snapshot.events = vec![event.clone()]; + let activity = app.agent_activity(AgentKind::Codex); + assert_eq!(activity.provider, "OpenAi"); + assert_eq!(activity.latency_ms, 1500); + assert_eq!(activity.latency_samples, 1); + assert_eq!(activity.ttft_ms, 250); + assert_eq!(activity.req_bytes, 1024); + assert_eq!(activity.resp_bytes, 2048); + + let sessions = session_rows(&[event]); + assert_eq!(sessions[0].provider, "OpenAi"); + assert_eq!(sessions[0].latency_ms, 1500); + assert_eq!(sessions[0].ttft_ms, 250); + assert_eq!(sessions[0].req_bytes, 1024); + assert_eq!(sessions[0].resp_bytes, 2048); + } + + #[test] + fn header_summarizes_llm_usage() { + let mut app = TopApp::new(TopOptions::default()); + app.width = 360; + app.snapshot.events = vec![parse_observer_line( + r#"{"identity":{"agent":"codex","session":"sess-a"},"event":{"LlmApi":{"pid":7,"prompt_tokens":1200,"completion_tokens":300}}}"#, + ) + .unwrap()]; + + let plain = a3s_tui::style::strip_ansi(&app.header()); + + assert!(plain.contains("llm:1")); + assert!(plain.contains("tok:1.5K")); + } + + #[test] + fn header_summarizes_container_states() { + let mut app = TopApp::new(TopOptions::default()); + app.width = 360; + app.snapshot.containers = vec![ + container_row("run", "run", "Up 2 minutes", Some(1.0), Some(1.0)), + container_row("pause", "pause", "Up 4 minutes (Paused)", None, None), + container_row("exit", "exit", "Exited (0) 1 hour ago", None, None), + container_row("dead", "dead", "dead", None, None), + ]; + + let plain = a3s_tui::style::strip_ansi(&app.header()); + + assert!(plain.contains("boxes:4 run:1 pause:1 exit:1 dead:1")); + } + + #[test] + fn tokens_sort_prioritizes_busy_agents_and_sessions() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + config: all_columns_config(), + ..TopOptions::default() + }); + app.sort_by = SortBy::Tokens; + app.snapshot.processes = vec![ + process_row(10, 1, "claude worker"), + process_row(20, 1, "codex worker"), + ]; + app.snapshot.events = vec![ + parse_observer_line( + r#"{"identity":{"agent":"claude","session":"sess-claude"},"event":{"LlmApi":{"pid":10,"model":"claude-3","prompt_tokens":5,"completion_tokens":5}}}"#, + ) + .unwrap(), + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"sess-codex"},"event":{"LlmApi":{"pid":20,"model":"gpt-4o","prompt_tokens":100,"completion_tokens":50}}}"#, + ) + .unwrap(), + ]; + + let agents = app.filtered_agents(); + assert_eq!(agents[0].agent, Some(AgentKind::Codex)); + + app.tab = Tab::Sessions; + let sessions = app.filtered_sessions(); + assert_eq!(sessions[0].session, "sess-codex"); + assert_eq!(sessions[0].total_tokens, 150); + } + + #[test] + fn sessions_table_renders_grouped_activity() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 180; + app.snapshot.events = vec![EventRow { + ts: "recent".into(), + source: "claude-code".into(), + session: Some("sess-a".into()), + task: Some("task-a".into()), + pid: None, + ppid: None, + kind: "ToolExec".into(), + message: "bash".into(), + details: Vec::new(), + risk: Risk::Medium, + }]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("AGENT")); + assert!(plain.contains("claude")); + assert!(plain.contains("sess-a")); + assert!(plain.contains("task-a")); + assert!(plain.contains("CWD")); + assert!(plain.contains("Tool")); + assert!(plain.contains("SEC")); + assert!(plain.contains("FILE")); + assert!(plain.contains("NET")); + } + + #[test] + fn sessions_table_renders_llm_usage() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 240; + app.snapshot.events = vec![parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"provider":"OpenAi","event":{"LlmApi":{"pid":7,"model":"gpt-4o","prompt_tokens":1200,"completion_tokens":3400,"latency_ms":1500}}}"#, + ) + .unwrap()]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("LLM")); + assert!(plain.contains("TOK")); + assert!(plain.contains("MODEL")); + assert!(plain.contains("4.6K")); + assert!(plain.contains("gpt-4o")); + assert!(plain.contains("PROV")); + assert!(plain.contains("OpenAi")); + assert!(plain.contains("1.5s")); + } + + #[test] + fn agents_tree_groups_sessions_processes_and_events_by_agent() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 360; + app.snapshot.processes = vec![ + process_row(42, 1, "codex exec task"), + process_row(100, 42, "bash -lc cargo test"), + ]; + app.snapshot.events = vec![ + event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "SecurityAction", + "ptrace", + Risk::High, + ), + event_row( + "codex", + Some("sess-a"), + Some("task-b"), + "FileAccess", + "README.md", + Risk::Medium, + ), + event_row( + "codex", + Some("sess-a"), + Some("task-c"), + "Egress", + "example.com", + Risk::Medium, + ), + ]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("Agents")); + assert!(plain.contains("codex")); + assert!(plain.contains("Sessions (1)")); + assert!(plain.contains("Processes (1 system · 1 agent)")); + assert!(plain.contains("Events (3)")); + assert!(plain.contains("sess-a")); + assert!(plain.contains("task-a")); + assert!(plain.contains("> pid 100")); + assert!(plain.contains("SecurityAction")); + assert!(plain.contains("FileAccess")); + assert!(plain.contains("Egress")); + } + + #[test] + fn agents_tree_uses_agent_theme_colors() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 360; + app.snapshot.processes = vec![ + process_row(42, 1, "claude code task"), + process_row(84, 1, "codex exec task"), + ]; + + let rendered = app.table(); + let claude_prefix = agent_tree_label(AgentKind::ClaudeCode, "> claude"); + let codex_prefix = agent_tree_label(AgentKind::Codex, " codex"); + + assert!(rendered.contains(claude_prefix.trim_end_matches("\u{1b}[0m"))); + assert!(rendered.contains(codex_prefix.trim_end_matches("\u{1b}[0m"))); + assert!(a3s_tui::style::strip_ansi(&rendered).contains("codex")); + } + + #[test] + fn agents_filter_uses_working_directory() { + let mut row = process_row(42, 1, "codex exec task"); + row.cwd = Some("/Users/roylin/code/a3s".into()); + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 280; + app.snapshot.processes = vec![row]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("Processes (0 system · 1 agent)")); + + app.filter = "code/a3s".into(); + assert_eq!(app.filtered_agents().len(), 1); + } + + #[test] + fn agents_filter_matches_related_sessions_and_events() { + let mut codex = process_row(42, 1, "codex exec task"); + codex.cwd = Some("/work/a3s".into()); + let mut claude = process_row(84, 1, "claude code other"); + claude.cwd = Some("/work/other".into()); + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.snapshot.processes = vec![codex, claude]; + app.snapshot.events = vec![ + parse_observer_line( + r#"{"identity":{"agent":"codex","session":"sess-target","task":"task-target"},"provider":"openai","event":{"LlmCall":{"pid":42,"model":"gpt-5","prompt_tokens":10,"completion_tokens":5,"cwd":"/work/a3s"}}}"#, + ) + .unwrap(), + parse_observer_line( + r#"{"identity":{"agent":"claude","session":"other"},"event":{"ToolExec":{"pid":84,"argv":["pwd"],"cwd":"/work/other"}}}"#, + ) + .unwrap(), + ]; + + app.filter = "sess-target".into(); + assert_eq!(app.filtered_agents().len(), 1); + assert_eq!(app.filtered_agents()[0].pid, 42); + + app.filter = "gpt-5".into(); + assert_eq!(app.filtered_agents().len(), 1); + assert_eq!(app.filtered_agents()[0].pid, 42); + + app.filter = "prompt_tokens".into(); + assert_eq!(app.filtered_agents().len(), 1); + assert_eq!(app.filtered_agents()[0].pid, 42); + + app.filter = "other".into(); + assert_eq!(app.filtered_agents().len(), 1); + assert_eq!(app.filtered_agents()[0].pid, 84); + } + + #[test] + fn process_tree_usage_sums_descendants() { + let rows = vec![ + ProcessRow { + cpu_pct: 1.0, + mem_pct: 2.0, + ..process_row(42, 1, "codex exec task") + }, + ProcessRow { + cpu_pct: 30.0, + mem_pct: 4.0, + ..process_row(100, 42, "cargo test") + }, + ProcessRow { + cpu_pct: 5.0, + mem_pct: 1.0, + ..process_row(101, 100, "rustc") + }, + ProcessRow { + cpu_pct: 99.0, + mem_pct: 99.0, + ..process_row(200, 1, "other") + }, + ]; + + let usage = process_tree_usage(&rows, 42); + + assert_eq!(usage.descendants, 2); + assert_eq!(usage.cpu_pct, 36.0); + assert_eq!(usage.mem_pct, 7.0); + } + + #[test] + fn agents_tree_processes_show_agent_started_system_processes() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 260; + app.snapshot.processes = vec![ + process_row(42, 1, "codex exec task"), + ProcessRow { + cpu_pct: 30.0, + mem_pct: 4.0, + ..process_row(100, 42, "bash -lc cargo test") + }, + ProcessRow { + cpu_pct: 5.0, + mem_pct: 1.0, + ..process_row(101, 100, "rustc") + }, + process_row(200, 1, "unrelated"), + ]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("Processes (2 system · 1 agent)")); + assert!(plain.contains("> pid 100 · ppid 42 · CPU 30.0% · MEM 4.0%")); + assert!(plain.contains("pid 101 · ppid 100 · CPU 5.0% · MEM 1.0%")); + assert!(!plain.contains("> pid 42")); + assert!(!plain.contains("pid 200")); + } + + #[test] + fn agents_tree_uses_process_tree_resource_totals() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 220; + app.snapshot.processes = vec![ + ProcessRow { + cpu_pct: 1.0, + mem_pct: 2.0, + ..process_row(42, 1, "codex exec task") + }, + ProcessRow { + cpu_pct: 30.0, + mem_pct: 4.0, + ..process_row(100, 42, "cargo test") + }, + ]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("31.0")); + assert!(plain.contains("6.0")); + assert!(plain.contains("children 1")); + } + + #[test] + fn agents_tree_selection_can_land_on_session_only_agent() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 220; + app.filter = "sess-a".into(); + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "LlmCall", + "thinking", + Risk::Low, + )]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert_eq!(app.visible_len(), 1); + assert!(plain.contains("> codex")); + assert!(plain.contains("S1 P0 E1")); + assert!(plain.contains("Sessions (1)")); + assert!(plain.contains("Processes (0 system · 0 agent)")); + assert!(plain.contains("Events (1)")); + } + + #[test] + fn o_focuses_session_only_agent_from_agents_tree() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.filter = "sess-a".into(); + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "bash", + Risk::Medium, + )]; + + app.handle_key(KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.tab, Tab::Sessions); + assert_eq!( + app.focused_session, + Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }) + ); + } + + #[test] + fn agents_tree_renders_separate_resource_trends() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 220; + app.snapshot.processes = vec![ + ProcessRow { + cpu_pct: 1.0, + mem_pct: 2.0, + ..process_row(42, 1, "codex exec task") + }, + ProcessRow { + cpu_pct: 30.0, + mem_pct: 4.0, + ..process_row(100, 42, "cargo test") + }, + ]; + app.history.insert( + agent_tree_history_key(42), + MetricHistory { + cpu: vec![1.0, 31.0], + mem: vec![2.0, 6.0], + ..MetricHistory::default() + }, + ); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("Resources")); + assert!(plain.contains("CPU 31.0%")); + assert!(plain.contains("MEM 6.0%")); + } + + #[test] + fn agents_tree_does_not_double_count_nested_agent_processes() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 220; + app.snapshot.processes = vec![ + ProcessRow { + cpu_pct: 1.0, + mem_pct: 2.0, + ..process_row(42, 1, "codex exec parent") + }, + ProcessRow { + cpu_pct: 30.0, + mem_pct: 4.0, + ..process_row(100, 42, "codex exec child") + }, + ProcessRow { + cpu_pct: 5.0, + mem_pct: 1.0, + ..process_row(101, 100, "cargo test") + }, + ]; + + let group = app + .agent_tree_groups() + .into_iter() + .find(|group| group.agent == AgentKind::Codex) + .unwrap(); + + assert_eq!(group.usage.cpu_pct, 36.0); + assert_eq!(group.usage.mem_pct, 7.0); + assert_eq!(group.usage.descendants, 2); + } + + #[test] + fn agent_cpu_sort_uses_process_tree_totals() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.sort_by = SortBy::Cpu; + app.snapshot.processes = vec![ + ProcessRow { + cpu_pct: 1.0, + ..process_row(10, 1, "codex exec slow-root") + }, + ProcessRow { + cpu_pct: 50.0, + ..process_row(11, 10, "cargo test") + }, + ProcessRow { + cpu_pct: 20.0, + ..process_row(20, 1, "claude") + }, + ]; + + let rows = app.filtered_agents(); + + assert_eq!(rows[0].pid, 10); + } + + #[test] + fn risk_filter_applies_to_events_and_sessions() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.risk_filter = RiskFilter::High; + app.snapshot.events = vec![ + event_row( + "codex", + Some("safe"), + Some("task-a"), + "ToolExec", + "git status", + Risk::Medium, + ), + event_row( + "codex", + Some("danger"), + Some("task-b"), + "SecurityAction", + "ptrace", + Risk::High, + ), + ]; + + let events = app.filtered_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].session.as_deref(), Some("danger")); + + app.tab = Tab::Sessions; + let sessions = app.filtered_sessions(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session, "danger"); + } + + #[test] + fn kind_filter_applies_to_events_sessions_and_session_focus() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.kind_filter = KindFilter::Security; + app.snapshot.events = vec![ + event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "git status", + Risk::Medium, + ), + event_row( + "codex", + Some("sess-a"), + Some("task-b"), + "SecurityAction", + "ptrace", + Risk::High, + ), + event_row( + "codex", + Some("sess-b"), + Some("task-c"), + "FileAccess", + "README.md", + Risk::Medium, + ), + ]; + + let events = app.filtered_events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, "SecurityAction"); + + app.tab = Tab::Sessions; + let sessions = app.filtered_sessions(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session, "sess-a"); + assert_eq!(sessions[0].events, 1); + assert_eq!(sessions[0].last_kind, "SecurityAction"); + + app.focused_session = Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }); + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("ptrace")); + assert!(!plain.contains("git status")); + } + + #[test] + fn high_risk_filter_keeps_agents_with_high_risk_activity() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.risk_filter = RiskFilter::High; + app.snapshot.processes = vec![process_row(42, 1, "codex exec task")]; + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "SecurityAction", + "ptrace", + Risk::High, + )]; + + let agents = app.filtered_agents(); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].pid, 42); + } + + #[test] + fn filter_keys_cycle_and_reset_selection() { + let mut app = TopApp::new(TopOptions::default()); + app.selected = 3; + app.detail = true; + + app.handle_key(KeyEvent { + code: KeyCode::Char('!'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.risk_filter, RiskFilter::Medium); + assert_eq!(app.selected, 0); + assert!(!app.detail); + + app.selected = 2; + app.detail = true; + app.handle_key(KeyEvent { + code: KeyCode::Char('g'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.kind_filter, KindFilter::Tool); + assert_eq!(app.selected, 0); + assert!(!app.detail); + } + + #[test] + fn filter_editor_esc_clears_and_ctrl_keys_edit() { + let mut app = TopApp::new(TopOptions::default()); + let key = |code| KeyEvent { + code, + modifiers: KeyModifiers::empty(), + }; + let ctrl = |c| KeyEvent { + code: KeyCode::Char(c), + modifiers: KeyModifiers::CONTROL, + }; + + app.filter = "codex".into(); + app.selected = 4; + app.detail = true; + app.handle_key(key(KeyCode::Char('/'))); + app.handle_key(ctrl('u')); + app.handle_key(key(KeyCode::Char('a'))); + app.handle_key(key(KeyCode::Char('p'))); + app.handle_key(key(KeyCode::Char('i'))); + app.handle_key(key(KeyCode::Esc)); + + assert_eq!(app.filter, ""); + assert!(!app.editing_filter); + assert_eq!(app.selected, 0); + assert!(!app.detail); + assert!(app.filter_before_edit.is_none()); + + app.handle_key(key(KeyCode::Char('/'))); + app.handle_key(ctrl('u')); + for c in "agent run".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(ctrl('w')); + app.handle_key(key(KeyCode::Enter)); + + assert_eq!(app.filter, "agent "); + assert!(!app.editing_filter); + assert!(app.filter_before_edit.is_none()); + } + + #[test] + fn sort_key_opens_select_panel_and_applies_choice() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.sort_by = SortBy::Cpu; + + app.handle_key(KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::empty(), + }); + + let panel = app.sort_panel.as_ref().unwrap(); + assert_eq!(panel.select.selected_index(), 0); + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("sort by")); + assert!(plain.contains("state")); + assert!(plain.contains("uptime")); + assert!(!plain.contains("tokens")); + + app.handle_key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::empty(), + }); + app.handle_key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.sort_by, SortBy::Mem); + assert!(app.sort_panel.is_none()); + } + + #[test] + fn sort_panel_accepts_number_shortcuts() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.sort_by = SortBy::Cpu; + app.open_sort_panel(); + + let plain = a3s_tui::style::strip_ansi(&app.sort_panel_view()); + assert!(plain.contains("> 1 cpu")); + assert!(plain.contains(" 2 mem")); + assert!(plain.contains(" 3 net")); + + app.handle_key(KeyEvent { + code: KeyCode::Char('4'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.sort_by, SortBy::Block); + assert!(app.sort_panel.is_none()); + } + + #[test] + fn sort_panel_choices_are_scoped_to_current_tab() { + let mut agents = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + agents.open_sort_panel(); + assert_eq!( + agents.sort_panel.as_ref().unwrap().choices, + vec![ + SortBy::Cpu, + SortBy::Mem, + SortBy::Net, + SortBy::Pids, + SortBy::Name, + SortBy::Tokens, + ] + ); + + let mut processes = TopApp::new(TopOptions { + tab: Tab::Processes, + ..TopOptions::default() + }); + processes.open_sort_panel(); + let choices = &processes.sort_panel.as_ref().unwrap().choices; + assert!(choices.contains(&SortBy::Id)); + assert!(!choices.contains(&SortBy::Tokens)); + + let mut events = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + events.open_sort_panel(); + assert!(events.sort_panel.is_none()); + assert!(events.note.as_deref().unwrap().contains("newest-first")); + } + + #[test] + fn esc_closes_sort_panel_without_changing_sort() { + let mut app = TopApp::new(TopOptions::default()); + app.sort_by = SortBy::Tokens; + + app.handle_key(KeyEvent { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::empty(), + }); + app.handle_key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.sort_by, SortBy::Tokens); + assert!(app.sort_panel.is_none()); + } + + #[test] + fn connector_key_opens_select_panel_and_switches_runtime() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + config: all_columns_config(), + ..TopOptions::default() + }); + app.focused_container = Some("abcdef".into()); + app.container_processes = Some(ContainerProcessPanel { + container_id: "abcdef".into(), + container_name: "app".into(), + rows: Vec::new(), + scroll: 0, + error: None, + loading: false, + }); + + app.handle_key(KeyEvent { + code: KeyCode::Char('C'), + modifiers: KeyModifiers::SHIFT, + }); + + let panel = app.connector_panel.as_ref().unwrap(); + assert_eq!(panel.select.selected_index(), 0); + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("container connector")); + assert!(plain.contains("a3s-box")); + assert!(plain.contains("docker")); + assert!(plain.contains("runc")); + + app.handle_key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::empty(), + }); + let cmd = app.handle_key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::empty(), + }); + + assert!(cmd.is_some()); + assert_eq!(app.connector, ContainerConnector::Docker); + assert!(app.connector_panel.is_none()); + assert!(app.focused_container.is_none()); + assert!(app.container_processes.is_none()); + } + + #[test] + fn connector_panel_accepts_number_shortcuts() { + let mut app = TopApp::new(TopOptions::default()); + app.open_connector_panel(); + + let plain = a3s_tui::style::strip_ansi(&app.connector_panel_view()); + assert!(plain.contains("> 1 a3s-box")); + assert!(plain.contains(" 2 docker")); + assert!(plain.contains(" 3 runc")); + + let cmd = app.handle_key(KeyEvent { + code: KeyCode::Char('3'), + modifiers: KeyModifiers::empty(), + }); + + assert!(cmd.is_some()); + assert_eq!(app.connector, ContainerConnector::RunC); + assert!(app.connector_panel.is_none()); + } + + #[test] + fn esc_closes_connector_panel_without_changing_runtime() { + let mut app = TopApp::new(TopOptions::default()); + app.connector = ContainerConnector::RunC; + + app.handle_key(KeyEvent { + code: KeyCode::Char('C'), + modifiers: KeyModifiers::SHIFT, + }); + app.handle_key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.connector, ContainerConnector::RunC); + assert!(app.connector_panel.is_none()); + } + + #[test] + fn focused_session_renders_event_stream_without_unrelated_events() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + ..TopOptions::default() + }); + app.snapshot.events = vec![ + event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "git status", + Risk::Medium, + ), + event_row( + "codex", + Some("sess-b"), + Some("task-b"), + "ToolExec", + "npm test", + Risk::Medium, + ), + event_row( + "claude-code", + Some("sess-a"), + Some("task-c"), + "FileAccess", + "README.md", + Risk::Medium, + ), + ]; + app.focused_session = Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("session view codex")); + assert!(plain.contains("sess-a")); + assert!(plain.contains("git status")); + assert!(!plain.contains("npm test")); + assert!(!plain.contains("README.md")); + } + + #[test] + fn focused_session_renders_event_process_scope() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + ..TopOptions::default() + }); + app.width = 180; + app.snapshot.events = vec![parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"pid":77,"ppid":42,"argv":["git","status"],"cwd":"/tmp/a3s"}}}"#, + ) + .unwrap()]; + app.focused_session = Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("PID")); + assert!(plain.contains("PPID")); + assert!(plain.contains("77")); + assert!(plain.contains("42")); + } + + #[test] + fn o_focuses_selected_session() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + ..TopOptions::default() + }); + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "git status", + Risk::Medium, + )]; + + app.handle_key(KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!( + app.focused_session, + Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }) + ); + assert_eq!(app.visible_len(), 1); + + app.handle_key(KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::empty(), + }); + + assert!(app.focused_session.is_none()); + } + + #[test] + fn o_on_event_focuses_event_session() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.snapshot.events = vec![ + event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "git status", + Risk::Medium, + ), + event_row( + "codex", + Some("sess-b"), + Some("task-b"), + "ToolExec", + "npm test", + Risk::Medium, + ), + ]; + + app.handle_key(KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.tab, Tab::Sessions); + assert_eq!( + app.focused_session, + Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }) + ); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("session view codex")); + assert!(plain.contains("git status")); + assert!(!plain.contains("npm test")); + } + + #[test] + fn o_on_non_agent_event_sets_note() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.snapshot.events = vec![event_row( + "collector", + None, + None, + "warning", + "docker unavailable", + Risk::Medium, + )]; + + app.handle_key(KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.tab, Tab::Events); + assert!(app.focused_session.is_none()); + assert_eq!( + app.note.as_deref(), + Some("event focus is available for coding-agent events") + ); + } + + #[test] + fn event_detail_shows_identity_scope_and_focus_hint() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.detail = true; + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "SecurityAction", + "action=ptrace pid=3 target=other", + Risk::High, + )]; + + let plain = a3s_tui::style::strip_ansi(&app.details()); + + assert!(plain.contains("event codex")); + assert!(plain.contains("SecurityAction")); + assert!(plain.contains("session sess-a task task-a")); + assert!(plain.contains("source codex · session sess-a · task task-a")); + assert!(plain.contains("actions o session focus")); + } + + #[test] + fn event_detail_shows_payload_fields() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Events, + ..TopOptions::default() + }); + app.detail = true; + app.snapshot.events = vec![parse_observer_line( + r#"{"identity":{"agent":"codex","task":"task-a","session":"sess-a"},"event":{"ToolExec":{"pid":7,"argv":["git","status"],"cwd":"/tmp/a3s"}}}"#, + ) + .unwrap()]; + + let plain = a3s_tui::style::strip_ansi(&app.details()); + + assert!(plain.contains("detail argv [\"git\",\"status\"]")); + assert!(plain.contains("detail cwd /tmp/a3s")); + assert!(plain.contains("detail pid 7")); + } + + #[test] + fn session_focus_detail_shows_selected_event() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + ..TopOptions::default() + }); + app.detail = true; + app.focused_session = Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }); + app.snapshot.events = vec![event_row( + "codex", + Some("sess-a"), + Some("task-a"), + "ToolExec", + "argv=[\"git\",\"status\"]", + Risk::Medium, + )]; + + let plain = a3s_tui::style::strip_ansi(&app.details()); + + assert!(plain.contains("event codex")); + assert!(plain.contains("ToolExec")); + assert!(plain.contains("argv=[\"git\",\"status\"]")); + assert!(plain.contains("actions o session focus")); + } + + #[test] + fn container_menu_reflects_running_state() { + let mut running = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0)); + running.ports = "8080:80".into(); + let stopped = container_row("123456", "job", "Exited (0) 1 hour ago", None, None); + let paused = container_row( + "999999", + "db", + "Up 4 minutes (Paused)", + Some(0.0), + Some(1.0), + ); + + let running_actions = container_menu_items(&running) + .into_iter() + .map(|item| item.action) + .collect::>(); + let stopped_actions = container_menu_items(&stopped) + .into_iter() + .map(|item| item.action) + .collect::>(); + let paused_actions = container_menu_items(&paused) + .into_iter() + .map(|item| item.action) + .collect::>(); + + assert!(running_actions.contains(&ContainerMenuAction::ExecShell)); + assert!(running_actions.contains(&ContainerMenuAction::OpenBrowser)); + assert!(running_actions.contains(&ContainerMenuAction::Pause)); + assert!(running_actions.contains(&ContainerMenuAction::Stop)); + assert!(!running_actions.contains(&ContainerMenuAction::Remove)); + assert!(stopped_actions.contains(&ContainerMenuAction::Start)); + assert!(stopped_actions.contains(&ContainerMenuAction::Remove)); + assert!(paused_actions.contains(&ContainerMenuAction::Unpause)); + assert!(!paused_actions.contains(&ContainerMenuAction::ExecShell)); + } + + #[test] + fn runc_menu_hides_docker_only_actions() { + let mut row = container_row("abcdef", "app", "running", Some(1.0), Some(2.0)); + row.connector = ContainerConnector::RunC; + + let actions = container_menu_items(&row) + .into_iter() + .map(|item| item.action) + .collect::>(); + + assert!(actions.contains(&ContainerMenuAction::Focus)); + assert!(actions.contains(&ContainerMenuAction::Pause)); + assert!(actions.contains(&ContainerMenuAction::Stop)); + assert!(!actions.contains(&ContainerMenuAction::Logs)); + assert!(!actions.contains(&ContainerMenuAction::ExecShell)); + assert!(!actions.contains(&ContainerMenuAction::Restart)); + } + + #[test] + fn w_opens_first_published_container_web_port() { + let external_action = Arc::new(Mutex::new(None)); + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + external_action: external_action.clone(), + ..TopOptions::default() + }); + let mut row = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0)); + row.ports = "8080:80".into(); + app.snapshot.containers = vec![row]; + + let cmd = app.handle_key(KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::empty(), + }); + + assert!(cmd.is_some()); + let action = external_action.lock().unwrap().clone(); + assert!(matches!( + action, + Some(ExternalAction::OpenBrowser { url, name }) + if url == "http://localhost:8080/" && name == "app" + )); + } + + #[test] + fn w_without_published_port_sets_note() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.snapshot.containers = vec![container_row( + "abcdef", + "app", + "Up 2 minutes", + Some(1.0), + Some(2.0), + )]; + + let cmd = app.handle_key(KeyEvent { + code: KeyCode::Char('w'), + modifiers: KeyModifiers::empty(), + }); + + assert!(cmd.is_none()); + assert_eq!( + app.note.as_deref(), + Some("app has no published web port to open") + ); + } + + #[test] + fn a3s_box_menu_exposes_box_runtime_actions() { + let mut row = container_row("abcdef", "app", "running", Some(1.0), Some(2.0)); + row.connector = ContainerConnector::A3sBox; + row.ports = "8080:80".into(); + + let actions = container_menu_items(&row) + .into_iter() + .map(|item| item.action) + .collect::>(); + + assert!(actions.contains(&ContainerMenuAction::Logs)); + assert!(actions.contains(&ContainerMenuAction::ExecShell)); + assert!(actions.contains(&ContainerMenuAction::OpenBrowser)); + assert!(actions.contains(&ContainerMenuAction::Restart)); + assert!(actions.contains(&ContainerMenuAction::Pause)); + assert!(actions.contains(&ContainerMenuAction::Stop)); + } + + #[test] + fn enter_opens_container_menu_and_x_toggles_detail() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + config: all_columns_config(), + ..TopOptions::default() + }); + app.snapshot.containers = vec![container_row( + "abcdef", + "app", + "Up 2 minutes", + Some(1.0), + Some(2.0), + )]; + + app.handle_key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::empty(), + }); + + assert!(app.container_menu.is_some()); + + app.container_menu = None; + app.handle_key(KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::empty(), + }); + + assert!(app.detail); + } + + #[test] + fn container_menu_r_shortcut_restarts_only_inside_menu() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.snapshot.containers = vec![container_row( + "abcdef123456", + "app", + "Up 2 minutes", + Some(1.0), + Some(2.0), + )]; + + app.open_container_menu(); + let plain = a3s_tui::style::strip_ansi(&app.container_menu_view()); + assert!(plain.contains("r Restart container")); + + app.handle_key(KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::empty(), + }); + + assert!(app.container_menu.is_none()); + assert!(!app.reverse_sort); + assert!(matches!( + app.confirm, + Some(Action::RestartContainer(_, _, ref name)) if name.contains("app") + )); + } + + #[test] + fn container_menu_shortcuts_follow_available_actions() { + let running = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0)); + let mut web = running.clone(); + web.ports = "8080:80".into(); + let stopped = container_row("123456", "job", "Exited (0) 1 hour ago", None, None); + let paused = container_row( + "999999", + "db", + "Up 4 minutes (Paused)", + Some(0.0), + Some(1.0), + ); + + let running_keys = container_menu_items(&running) + .into_iter() + .map(|item| (item.key, item.action)) + .collect::>(); + let web_keys = container_menu_items(&web) + .into_iter() + .map(|item| (item.key, item.action)) + .collect::>(); + let stopped_keys = container_menu_items(&stopped) + .into_iter() + .map(|item| (item.key, item.action)) + .collect::>(); + let paused_keys = container_menu_items(&paused) + .into_iter() + .map(|item| (item.key, item.action)) + .collect::>(); + + assert!(running_keys.contains(&('o', ContainerMenuAction::Focus))); + assert!(running_keys.contains(&('l', ContainerMenuAction::Logs))); + assert!(running_keys.contains(&('e', ContainerMenuAction::ExecShell))); + assert!(!running_keys.contains(&('w', ContainerMenuAction::OpenBrowser))); + assert!(web_keys.contains(&('w', ContainerMenuAction::OpenBrowser))); + assert!(running_keys.contains(&('p', ContainerMenuAction::Pause))); + assert!(running_keys.contains(&('s', ContainerMenuAction::Stop))); + assert!(running_keys.contains(&('r', ContainerMenuAction::Restart))); + assert!(stopped_keys.contains(&('s', ContainerMenuAction::Start))); + assert!(stopped_keys.contains(&('d', ContainerMenuAction::Remove))); + assert!(paused_keys.contains(&('u', ContainerMenuAction::Unpause))); + assert!(paused_keys.contains(&('s', ContainerMenuAction::Stop))); + } + + #[test] + fn focused_container_renders_single_view() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.height = 42; + let mut row = container_row("abcdef", "app", "Up 2 minutes", Some(42.0), Some(30.0)); + row.inspect = ContainerInspect { + health: "healthy".into(), + restarts: "2".into(), + restart_policy: "unless-stopped".into(), + created: "2026-06-26 08:00:00".into(), + started: "2026-06-26 08:01:00".into(), + exit: "-".into(), + mounts: "1 mount: /data(rw)".into(), + env: "4 vars".into(), + labels: "1 label: com.example.role".into(), + networks: "1 net: bridge 172.17.0.2".into(), + }; + row.cpu_count = Some(2); + row.net_io = "1.00KiB / 2.00KiB".into(); + row.block_io = "4.00KiB / 8.00KiB".into(); + row.pids = "7".into(); + row.ports = "0.0.0.0:3000->3000/tcp".into(); + app.snapshot.containers = vec![row]; + app.focused_container = Some("abcdef".into()); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("single view app")); + assert!(plain.contains("CPU")); + assert!(plain.contains("MEM")); + assert!(plain.contains("NET trend")); + assert!(plain.contains("IO trend")); + assert!(plain.contains("RESOURCE")); + assert!(plain.contains("NET I/O")); + assert!(plain.contains("CPUS")); + assert!(plain.contains("PIDS")); + assert!(plain.contains("0.0.0.0:3000->3000/tcp")); + assert!(plain.contains("HEALTH")); + assert!(plain.contains("healthy")); + assert!(plain.contains("RESTART POLICY")); + assert!(plain.contains("unless-stopped")); + assert!(!plain.contains("CONTAINER")); + } + + #[test] + fn focused_container_footer_surfaces_ctop_actions() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.focused_container = Some("abcdef".into()); + + assert_eq!( + app.footer_help_text(), + "Esc list · ↑/↓ proc · Enter actions(start/stop/restart/pause) · l logs · e shell · w browser · K stop" + ); + + app.container_menu = Some(ContainerMenu { + container: container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0)), + items: vec![ContainerMenuItem { + action: ContainerMenuAction::Restart, + key: 'r', + label: "Restart container".into(), + }], + select: Select::new(vec!["Restart container"]), + }); + assert_eq!( + app.footer_help_text(), + "Enter run action · ↑/↓ select · Esc close menu" + ); + } + + #[test] + fn single_container_target_matches_name_cid_short_cid_and_prefix() { + let row = container_row( + "abcdef1234567890", + "web", + "Up 2 minutes", + Some(1.0), + Some(2.0), + ); + + assert!(container_matches_query(&row, "web")); + assert!(container_matches_query(&row, "abcdef1234567890")); + assert!(container_matches_query(&row, "abcdef123456")); + assert!(container_matches_query(&row, "abcdef")); + assert!(!container_matches_query(&row, "api")); + + let mut app = TopApp::new(TopOptions { + container_query: Some("web".into()), + ..TopOptions::default() + }); + app.snapshot.containers = vec![ + row.clone(), + container_row( + "1111111234567890", + "api", + "Up 1 minute", + Some(1.0), + Some(2.0), + ), + ]; + + let rows = app.filtered_containers(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "web"); + assert_eq!(top_snapshot_json(&app, 0)["config"]["container"], "web"); + } + + #[test] + fn single_container_target_collects_name_and_id_candidates() { + assert_eq!(container_target_filters(None), vec![None]); + assert_eq!(container_target_filters(Some(" ")), vec![None]); + assert_eq!( + container_target_filters(Some("web")), + vec![Some("name=web".into()), Some("id=web".into())] + ); + } + + #[test] + fn dedupes_container_candidates_from_runtime_filters() { + let mut rows = vec![ + container_row( + "abcdef1234567890", + "web", + "Up 2 minutes", + Some(1.0), + Some(2.0), + ), + container_row( + "abcdef1234567890", + "web", + "Up 2 minutes", + Some(1.0), + Some(2.0), + ), + container_row( + "1111111234567890", + "api", + "Up 1 minute", + Some(1.0), + Some(2.0), + ), + ]; + + dedupe_container_rows(&mut rows); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].name, "web"); + assert_eq!(rows[1].name, "api"); + } + + #[test] + fn containers_table_renders_and_filters_ports() { + let mut row = container_row("abcdef", "app", "Up 2 minutes", Some(1.0), Some(2.0)); + row.ports = "0.0.0.0:3000->3000/tcp".into(); + row.inspect.health = "healthy".into(); + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + config: all_columns_config(), + ..TopOptions::default() + }); + app.width = 260; + app.snapshot.containers = vec![row]; + + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("PORTS")); + assert!(plain.contains("HEALTH")); + assert!(plain.contains("CID")); + assert!(plain.contains("0.0.0.0:3000->3000/tcp")); + assert!(plain.contains("healthy")); + + app.filter = "3000".into(); + assert_eq!(app.filtered_containers().len(), 1); + app.filter = "healthy".into(); + assert_eq!(app.filtered_containers().len(), 1); + app.filter = "abcdef".into(); + assert_eq!(app.filtered_containers().len(), 1); + } + + #[test] + fn focused_container_renders_process_table() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.width = 160; + app.height = 32; + app.snapshot.containers = vec![container_row( + "abcdef", + "app", + "Up 2 minutes", + Some(42.0), + Some(30.0), + )]; + app.focused_container = Some("abcdef".into()); + app.container_processes = Some(ContainerProcessPanel { + container_id: "abcdef".into(), + container_name: "app".into(), + rows: vec![ContainerProcessRow { + pid: "123".into(), + ppid: "1".into(), + cpu_pct: Some(2.5), + mem_pct: Some(0.7), + elapsed: "00:01".into(), + command: "node server.js".into(), + }], + scroll: 0, + error: None, + loading: false, + }); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("PID")); + assert!(plain.contains("PPID")); + assert!(plain.contains("node server.js")); + assert!(plain.contains("2.5")); + } + + #[test] + fn focused_container_scrolls_process_table() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.width = 160; + app.height = 32; + app.snapshot.containers = vec![container_row( + "abcdef", + "app", + "Up 2 minutes", + Some(42.0), + Some(30.0), + )]; + app.focused_container = Some("abcdef".into()); + let rows = (0..30) + .map(|idx| ContainerProcessRow { + pid: format!("{}", 100 + idx), + ppid: "1".into(), + cpu_pct: Some(idx as f32), + mem_pct: Some(0.5), + elapsed: "00:01".into(), + command: format!("worker-{idx:02}"), + }) + .collect::>(); + app.container_processes = Some(ContainerProcessPanel { + container_id: "abcdef".into(), + container_name: "app".into(), + rows, + scroll: 0, + error: None, + loading: false, + }); + + app.handle_key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::empty(), + }); + assert_eq!(app.container_processes.as_ref().unwrap().scroll, 1); + + app.handle_key(KeyEvent { + code: KeyCode::PageDown, + modifiers: KeyModifiers::empty(), + }); + assert!(app.container_processes.as_ref().unwrap().scroll > 1); + + app.handle_key(KeyEvent { + code: KeyCode::End, + modifiers: KeyModifiers::empty(), + }); + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("worker-29")); + assert!(!plain.contains("worker-00")); + + app.handle_key(KeyEvent { + code: KeyCode::Home, + modifiers: KeyModifiers::empty(), + }); + let plain = a3s_tui::style::strip_ansi(&app.table()); + assert!(plain.contains("worker-00")); + } + + #[test] + fn log_scroll_controls_follow_mode() { + let mut app = TopApp::new(TopOptions::default()); + app.height = 10; + app.log = Some(LogPanel { + connector: ContainerConnector::Docker, + container_id: "abcdef".into(), + container_name: "app".into(), + text: numbered_lines(10), + scroll: 7, + timestamps: false, + loading: false, + refreshing: false, + follow: true, + }); + + app.handle_key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::empty(), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 6); + assert!(!log.follow); + + app.handle_key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::empty(), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 7); + assert!(log.follow); + + app.handle_key(KeyEvent { + code: KeyCode::Home, + modifiers: KeyModifiers::empty(), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 0); + assert!(!log.follow); + + app.handle_key(KeyEvent { + code: KeyCode::End, + modifiers: KeyModifiers::empty(), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 7); + assert!(log.follow); + } + + #[test] + fn log_follow_key_toggles_tail_mode() { + let mut app = TopApp::new(TopOptions::default()); + app.height = 10; + app.log = Some(LogPanel { + connector: ContainerConnector::Docker, + container_id: "abcdef".into(), + container_name: "app".into(), + text: numbered_lines(10), + scroll: 2, + timestamps: false, + loading: false, + refreshing: false, + follow: false, + }); + + app.handle_key(KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::empty(), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 7); + assert!(log.follow); + + app.handle_key(KeyEvent { + code: KeyCode::Char('f'), + modifiers: KeyModifiers::empty(), + }); + assert!(!app.log.as_ref().unwrap().follow); + } + + #[test] + fn log_refresh_command_marks_panel_refreshing() { + let mut app = TopApp::new(TopOptions::default()); + app.log = Some(LogPanel { + connector: ContainerConnector::Docker, + container_id: "abcdef".into(), + container_name: "app".into(), + text: "line".into(), + scroll: 0, + timestamps: false, + loading: false, + refreshing: false, + follow: true, + }); + + assert!(app.open_log_refresh_cmd().is_some()); + assert!(app.log.as_ref().unwrap().refreshing); + assert!(app.open_log_refresh_cmd().is_none()); + } + + #[test] + fn log_refresh_key_marks_panel_refreshing() { + let mut app = TopApp::new(TopOptions::default()); + app.log = Some(LogPanel { + connector: ContainerConnector::Docker, + container_id: "abcdef".into(), + container_name: "app".into(), + text: "line".into(), + scroll: 0, + timestamps: false, + loading: false, + refreshing: false, + follow: true, + }); + + assert!(app + .handle_key(KeyEvent { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::empty(), + }) + .is_some()); + assert!(app.log.as_ref().unwrap().refreshing); + } + + #[test] + fn log_results_follow_or_preserve_scroll() { + let mut app = TopApp::new(TopOptions::default()); + app.height = 10; + app.log = Some(LogPanel { + connector: ContainerConnector::Docker, + container_id: "abcdef".into(), + container_name: "app".into(), + text: numbered_lines(10), + scroll: 7, + timestamps: false, + loading: false, + refreshing: true, + follow: true, + }); + + app.update(Msg::ContainerLogs { + connector: ContainerConnector::Docker, + id: "abcdef".into(), + name: "app".into(), + timestamps: false, + result: Ok(numbered_lines(12)), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 9); + assert!(log.follow); + assert!(!log.refreshing); + + app.log.as_mut().unwrap().follow = false; + app.log.as_mut().unwrap().scroll = 4; + app.update(Msg::ContainerLogs { + connector: ContainerConnector::Docker, + id: "abcdef".into(), + name: "app".into(), + timestamps: false, + result: Ok(numbered_lines(12)), + }); + let log = app.log.as_ref().unwrap(); + assert_eq!(log.scroll, 4); + assert!(!log.follow); + } + + #[test] + fn focused_agent_renders_agent_view() { + let mut app = TopApp::new(TopOptions { tab: Tab::Agents, - interval: Duration::from_millis(1500), - } + ..TopOptions::default() + }); + app.snapshot.processes = vec![ProcessRow { + cpu_pct: 33.0, + mem_pct: 12.0, + ..process_row(42, 1, "codex exec task") + }]; + app.snapshot.events = vec![EventRow { + ts: "recent".into(), + source: "codex".into(), + session: Some("sess-a".into()), + task: Some("task-a".into()), + pid: Some(42), + ppid: None, + kind: "ToolExec".into(), + message: "git status".into(), + details: Vec::new(), + risk: Risk::Medium, + }]; + app.focused_agent_pid = Some(42); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("agent view codex")); + assert!(plain.contains("activity events 1")); + assert!(plain.contains("TIME")); + assert!(plain.contains("KIND")); + assert!(plain.contains("PID")); + assert!(plain.contains("ToolExec")); + assert!(plain.contains("SESSION")); + assert!(plain.contains("sess-a")); + assert!(plain.contains("task-a")); + assert!(plain.contains("git status")); + assert!(plain.contains("command codex exec task")); + assert!(!plain.contains("AGENT")); + } + + #[test] + fn focused_agent_renders_child_process_tree() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.width = 120; + app.height = 32; + app.snapshot.processes = vec![ + ProcessRow { + cpu_pct: 33.0, + mem_pct: 12.0, + ..process_row(42, 1, "codex exec task") + }, + ProcessRow { + cpu_pct: 4.0, + mem_pct: 1.5, + ..process_row(100, 42, "bash -lc cargo test") + }, + ProcessRow { + cpu_pct: 2.0, + mem_pct: 0.5, + ..process_row(101, 100, "git status --short") + }, + ]; + app.focused_agent_pid = Some(42); + + let plain = a3s_tui::style::strip_ansi(&app.table()); + + assert!(plain.contains("subtree cpu 39.0% mem 14.0%")); + assert!(plain.contains("42 cpu 33.0% mem 12.0% codex exec task")); + assert!(plain.contains("100 cpu 4.0% mem 1.5% bash -lc cargo test")); + assert!(plain.contains("101 cpu 2.0% mem 0.5% git status --short")); + assert!(plain.contains("└── 100")); + } + + #[test] + fn o_focuses_selected_agent() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.snapshot.processes = vec![process_row(42, 1, "codex exec task")]; + + app.handle_key(KeyEvent { + code: KeyCode::Char('o'), + modifiers: KeyModifiers::empty(), + }); + + assert_eq!(app.focused_agent_pid, Some(42)); + assert_eq!(app.tab, Tab::Agents); + } + + #[test] + fn esc_closes_container_focus_before_detail() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.focused_container = Some("abcdef".into()); + app.detail = true; + + app.handle_key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::empty(), + }); + + assert!(app.focused_container.is_none()); + } + + #[test] + fn esc_closes_agent_focus_before_detail() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Agents, + ..TopOptions::default() + }); + app.focused_agent_pid = Some(42); + app.detail = true; + + app.handle_key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::empty(), + }); + + assert!(app.focused_agent_pid.is_none()); + } + + #[test] + fn esc_closes_session_focus_before_detail() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Sessions, + ..TopOptions::default() + }); + app.focused_session = Some(SessionFocus { + source: "codex".into(), + session: "sess-a".into(), + }); + app.detail = true; + + app.handle_key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::empty(), + }); + + assert!(app.focused_session.is_none()); + } + + #[test] + fn sorts_containers_by_memory_percent() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + app.sort_by = SortBy::Mem; + app.snapshot.containers = vec![ + container_row("low", "low", "Up", Some(1.0), Some(5.0)), + container_row("high", "high", "Up", Some(1.0), Some(42.0)), + ]; + + let rows = app.filtered_containers(); + + assert_eq!(rows[0].name, "high"); + } + + #[test] + fn sorts_containers_by_ctop_state_id_and_uptime_fields() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + let exited = container_row("ccc333", "exited", "Exited (0) 1 hour ago", None, None); + let running_short = container_row("aaa111", "short", "Up 2 minutes", Some(1.0), Some(1.0)); + let running_long = container_row("bbb222", "long", "Up 3 hours", Some(1.0), Some(1.0)); + + app.snapshot.containers = vec![exited.clone(), running_short.clone(), running_long.clone()]; + app.sort_by = SortBy::State; + assert_eq!(app.filtered_containers()[0].name, "long"); + + app.snapshot.containers = vec![running_long.clone(), exited.clone(), running_short.clone()]; + app.sort_by = SortBy::Id; + assert_eq!(app.filtered_containers()[0].id, "aaa111"); + + app.snapshot.containers = vec![running_short, exited, running_long]; + app.sort_by = SortBy::Uptime; + assert_eq!(app.filtered_containers()[0].name, "long"); + assert_eq!(parse_uptime_seconds("Up 2 minutes"), Some(120)); + assert_eq!(container_state_label("Up 4 minutes (Paused)"), "paused"); + } + + #[test] + fn container_state_colors_match_runtime_state() { + assert_eq!(container_state_color("running"), GREEN); + assert_eq!(container_state_color("Up 4 minutes"), GREEN); + assert_eq!(container_state_color("restarting"), ORANGE); + assert_eq!(container_state_color("paused"), YELLOW); + assert_eq!( + container_state_color("Exited (0) 1 hour ago"), + Color::BrightBlack + ); + assert_eq!(container_state_color("created"), CYAN); + assert_eq!(container_state_color("dead"), RED); + } + + #[test] + fn summarizes_container_states() { + let rows = vec![ + container_row("run", "run", "Up 2 minutes", Some(1.0), Some(1.0)), + container_row("restart", "restart", "Restarting (1)", None, None), + container_row("pause", "pause", "paused", None, None), + container_row("exit", "exit", "stopped", None, None), + container_row("create", "create", "created", None, None), + container_row("dead", "dead", "dead", None, None), + container_row("other", "other", "unknown", None, None), + ]; + + let summary = container_state_summary(&rows); + + assert_eq!(summary.total, 7); + assert_eq!(summary.running, 1); + assert_eq!(summary.restarting, 1); + assert_eq!(summary.paused, 1); + assert_eq!(summary.exited, 1); + assert_eq!(summary.created, 1); + assert_eq!(summary.dead, 1); + assert_eq!(summary.other, 1); + assert_eq!( + summary.header_label(), + "7 run:1 restart:1 pause:1 exit:1 create:1 dead:1 other:1" + ); + } + + #[test] + fn parses_human_byte_pairs_for_container_sorting() { + assert_eq!(parse_human_bytes("1.5kB"), Some(1500)); + assert_eq!(parse_human_bytes("1.00KiB"), Some(1024)); + assert_eq!(parse_byte_pair_total("1.00KiB / 2.00KiB"), 3072); + assert_eq!(parse_byte_pair_total("-"), 0); + } + + #[test] + fn parses_container_process_tables() { + let rows = parse_container_process_table( + "PID PPID %CPU %MEM ELAPSED COMMAND\n123 1 2.5 0.7 00:01 node server.js\n", + ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].pid, "123"); + assert_eq!(rows[0].ppid, "1"); + assert_eq!(rows[0].cpu_pct, Some(2.5)); + assert_eq!(rows[0].mem_pct, Some(0.7)); + assert_eq!(rows[0].elapsed, "00:01"); + assert_eq!(rows[0].command, "node server.js"); + + let rows = parse_container_process_table( + "UID PID PPID C STIME TTY TIME CMD\nroot 321 1 0 10:00 ? 00:00:01 sleep infinity\n", + ); + assert_eq!(rows[0].pid, "321"); + assert_eq!(rows[0].command, "sleep infinity"); } -} -pub async fn run(args: Vec) -> anyhow::Result<()> { - let options = parse_options(args)?; - ProgramBuilder::new(TopApp::new(options)) - .with_alt_screen() - .with_fps(30) - .run() - .await?; - Ok(()) -} + #[test] + fn parses_runc_process_ids() { + let rows = parse_runc_processes("PID\n123\n456\n"); -fn parse_options(args: Vec) -> anyhow::Result { - let mut options = TopOptions::default(); - let mut it = args.into_iter(); - while let Some(arg) = it.next() { - match arg.as_str() { - "--agents" => options.tab = Tab::Agents, - "--containers" => options.tab = Tab::Containers, - "--processes" => options.tab = Tab::Processes, - "--events" => options.tab = Tab::Events, - "--watch" | "--interval" => { - let value = it - .next() - .ok_or_else(|| anyhow::anyhow!("{arg} requires a value"))?; - options.interval = parse_duration(&value)?; - } - "-h" | "--help" => { - print_help(); - std::process::exit(0); - } - other => return Err(anyhow::anyhow!("unknown a3s top option '{other}'")), - } + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].pid, "123"); + assert_eq!(rows[1].command, "runc process"); } - Ok(options) -} -fn print_help() { - println!( - "a3s top — live monitor for coding agents, containers, and processes\n\n\ - usage:\n \ - a3s top [--agents|--containers|--processes|--events] [--watch 1500ms]\n\n\ - keys:\n \ - Tab/Shift+Tab switch tabs · ↑/↓ select · / filter · s sort · Space pause\n \ - Enter detail · K terminate/stop · r restart container · q quit\n\n\ - observer:\n \ - set A3S_TOP_OBSERVER_LOG=/path/to/events.ndjson to show observer events" - ); -} + #[test] + fn sorts_containers_by_network_block_and_pids() { + let mut app = TopApp::new(TopOptions { + tab: Tab::Containers, + ..TopOptions::default() + }); + let mut low = container_row("low", "low", "Up", Some(1.0), Some(1.0)); + low.net_io = "1kB / 1kB".into(); + low.block_io = "5kB / 1kB".into(); + low.pids = "2".into(); + let mut high = container_row("high", "high", "Up", Some(1.0), Some(1.0)); + high.net_io = "10kB / 2kB".into(); + high.block_io = "1kB / 1kB".into(); + high.pids = "9".into(); + app.snapshot.containers = vec![low.clone(), high.clone()]; -fn top_keymap() -> Keymap { - let mut km = Keymap::new(); - km.register(KeyBinding::new(KeyCode::Char('q')), TopKey::Quit, "quit"); - km.register( - KeyBinding::with_modifiers(KeyCode::Char('c'), KeyModifiers::CONTROL), - TopKey::Quit, - "quit", - ); - km.register(KeyBinding::new(KeyCode::Up), TopKey::Up, "select up"); - km.register(KeyBinding::new(KeyCode::Char('k')), TopKey::Up, "select up"); - km.register(KeyBinding::new(KeyCode::Down), TopKey::Down, "select down"); - km.register( - KeyBinding::new(KeyCode::Char('j')), - TopKey::Down, - "select down", - ); - km.register(KeyBinding::new(KeyCode::PageUp), TopKey::PageUp, "page up"); - km.register( - KeyBinding::new(KeyCode::PageDown), - TopKey::PageDown, - "page down", - ); - km.register(KeyBinding::new(KeyCode::Tab), TopKey::NextTab, "next tab"); - km.register(KeyBinding::new(KeyCode::Right), TopKey::NextTab, "next tab"); - km.register( - KeyBinding::new(KeyCode::BackTab), - TopKey::PrevTab, - "previous tab", - ); - km.register( - KeyBinding::new(KeyCode::Left), - TopKey::PrevTab, - "previous tab", - ); - km.register( - KeyBinding::new(KeyCode::Char('/')), - TopKey::Filter, - "filter", - ); - km.register(KeyBinding::new(KeyCode::Char('s')), TopKey::Sort, "sort"); - km.register( - KeyBinding::new(KeyCode::Char(' ')), - TopKey::TogglePause, - "pause", - ); - km.register(KeyBinding::new(KeyCode::Enter), TopKey::Detail, "detail"); - km.register( - KeyBinding::new(KeyCode::Char('x')), - TopKey::Detail, - "detail", - ); - km.register( - KeyBinding::new(KeyCode::Char('K')), - TopKey::Kill, - "terminate", - ); - km.register( - KeyBinding::new(KeyCode::Char('r')), - TopKey::Restart, - "restart", - ); - km -} + app.sort_by = SortBy::Net; + assert_eq!(app.filtered_containers()[0].name, "high"); -async fn collect_snapshot() -> TopSnapshot { - let (processes, containers, mut events) = tokio::join!( - collect_processes(), - collect_containers(), - collect_observer_events(), - ); - let (processes, process_error) = match processes { - Ok(rows) => (rows, None), - Err(err) => (Vec::new(), Some(format!("process collector: {err}"))), - }; - let (containers, container_error) = match containers { - Ok(rows) => (rows, None), - Err(err) => (Vec::new(), Some(format!("container collector: {err}"))), - }; + app.snapshot.containers = vec![low.clone(), high.clone()]; + app.sort_by = SortBy::Block; + assert_eq!(app.filtered_containers()[0].name, "low"); - let mut errors = Vec::new(); - errors.extend(process_error); - errors.extend(container_error); - if events.is_empty() { - events.extend(errors.iter().map(|err| EventRow { - ts: "now".into(), - source: "collector".into(), - kind: "warning".into(), - message: err.clone(), - risk: Risk::Medium, - })); + app.snapshot.containers = vec![low, high]; + app.sort_by = SortBy::Pids; + assert_eq!(app.filtered_containers()[0].name, "high"); } - TopSnapshot { - processes, - containers, - events, - errors, - } -} + #[test] + fn maps_container_menu_actions_to_confirmable_actions() { + let container = container_row("abcdef", "app", "Up", Some(1.0), Some(2.0)); -async fn collect_processes() -> anyhow::Result> { - let output = Command::new("ps") - .args(["-axo", "pid=,ppid=,pcpu=,pmem=,etime=,args="]) - .output() - .await?; - if !output.status.success() { - return Err(anyhow::anyhow!("ps exited with status {}", output.status)); + assert!(matches!( + container_action(container.clone(), ContainerMenuAction::Pause), + Action::PauseContainer(ContainerConnector::Docker, _, name) if name.contains("app") + )); + assert!(matches!( + container_action(container, ContainerMenuAction::Remove), + Action::RemoveContainer(ContainerConnector::Docker, _, name) if name.contains("app") + )); } - let text = String::from_utf8_lossy(&output.stdout); - let mut rows = text - .lines() - .filter_map(parse_process_line) - .collect::>(); - rows.sort_by(|a, b| { - b.agent.is_some().cmp(&a.agent.is_some()).then( - b.cpu_pct - .partial_cmp(&a.cpu_pct) - .unwrap_or(std::cmp::Ordering::Equal), + + #[test] + fn parses_runc_list_json() { + let rows = parse_runc_list( + r#"[ + { + "id": "example", + "pid": 1234, + "status": "running", + "bundle": "/containers/example", + "rootfs": "/containers/example/rootfs", + "created": "2026-06-26T00:00:00Z" + } + ]"#, ) - }); - Ok(rows) -} - -fn parse_process_line(line: &str) -> Option { - let mut it = line.split_whitespace(); - let pid = it.next()?.parse().ok()?; - let ppid = it.next()?.parse().ok()?; - let cpu_pct = it.next()?.parse().ok()?; - let mem_pct = it.next()?.parse().ok()?; - let elapsed = it.next()?.to_string(); - let command = it.collect::>().join(" "); - if command.is_empty() { - return None; + .unwrap(); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].connector, ContainerConnector::RunC); + assert_eq!(rows[0].name, "example"); + assert_eq!(rows[0].status, "running"); + assert_eq!(rows[0].pids, "1234"); + assert!(rows[0].image.contains("rootfs:")); } - let agent = detect_agent(&command); - Some(ProcessRow { - pid, - ppid, - cpu_pct, - mem_pct, - elapsed, - risk: process_risk(&command, agent), - command, - agent, - }) -} -async fn collect_containers() -> anyhow::Result> { - let ps = Command::new("docker") - .args([ - "ps", - "--no-trunc", - "--format", - "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}", - ]) - .output() - .await; - let Ok(ps) = ps else { - return Ok(Vec::new()); - }; - if !ps.status.success() { - return Ok(Vec::new()); + #[test] + fn runc_global_args_follow_ctop_env_vars() { + let old_root = std::env::var_os("RUNC_ROOT"); + let old_systemd = std::env::var_os("RUNC_SYSTEMD_CGROUP"); + + std::env::remove_var("RUNC_ROOT"); + std::env::remove_var("RUNC_SYSTEMD_CGROUP"); + assert_eq!(runc_global_args(), vec!["--root", "/run/runc"]); + + std::env::set_var("RUNC_ROOT", "/tmp/custom-runc"); + assert_eq!(runc_global_args(), vec!["--root", "/tmp/custom-runc"]); + + std::env::set_var("RUNC_SYSTEMD_CGROUP", "true"); + assert_eq!( + runc_global_args(), + vec!["--root", "/tmp/custom-runc", "--systemd-cgroup"] + ); + + std::env::set_var("RUNC_SYSTEMD_CGROUP", "0"); + assert_eq!(runc_global_args(), vec!["--root", "/tmp/custom-runc"]); + + restore_var("RUNC_ROOT", old_root); + restore_var("RUNC_SYSTEMD_CGROUP", old_systemd); } - let text = String::from_utf8_lossy(&ps.stdout); - let mut containers = text - .lines() - .filter_map(|line| { - let mut parts = line.splitn(4, '\t'); - Some(ContainerRow { - id: parts.next()?.to_string(), - name: parts.next()?.to_string(), - image: parts.next()?.to_string(), - status: parts.next().unwrap_or_default().to_string(), - cpu_pct: None, - mem_usage: "-".into(), - net_io: "-".into(), - block_io: "-".into(), - pids: "-".into(), - }) - }) - .collect::>(); - if containers.is_empty() { - return Ok(containers); + + #[test] + fn runc_container_filters_match_active_all_and_single_target() { + let mut active_rows = vec![ + container_row("abcdef1234567890", "api", "running", None, None), + container_row("1111111234567890", "paused-box", "paused", None, None), + container_row("2222221234567890", "stopped-box", "stopped", None, None), + container_row("3333331234567890", "created-box", "created", None, None), + ]; + filter_runc_container_rows(&mut active_rows, false, None); + let names = active_rows + .iter() + .map(|row| row.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["api", "paused-box"]); + + let mut all_target_rows = vec![ + container_row("abcdef1234567890", "api", "running", None, None), + container_row("2222221234567890", "stopped-box", "stopped", None, None), + ]; + filter_runc_container_rows(&mut all_target_rows, true, Some("222222")); + assert_eq!(all_target_rows.len(), 1); + assert_eq!(all_target_rows[0].name, "stopped-box"); + + let mut active_target_rows = vec![ + container_row("abcdef1234567890", "api", "running", None, None), + container_row("2222221234567890", "stopped-box", "stopped", None, None), + ]; + filter_runc_container_rows(&mut active_target_rows, false, Some("stopped-box")); + assert!(active_target_rows.is_empty()); + + let mut name_target_rows = vec![ + container_row("abcdef1234567890", "api", "running", None, None), + container_row("1111111234567890", "worker", "running", None, None), + ]; + filter_runc_container_rows(&mut name_target_rows, false, Some("api")); + assert_eq!(name_target_rows.len(), 1); + assert_eq!(name_target_rows[0].id, "abcdef1234567890"); } - let stats = Command::new("docker") - .args([ - "stats", - "--no-stream", - "--format", - "{{.ID}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}", - ]) - .output() - .await; - let Ok(stats) = stats else { - return Ok(containers); - }; - if !stats.status.success() { - return Ok(containers); + #[test] + fn parses_docker_container_ports() { + let rows = parse_docker_container_list( + "abcdef\tapp\tnginx:latest\tUp 2 minutes\t0.0.0.0:8080->80/tcp, :::8080->80/tcp\n123456\tworker\talpine\tUp 1 second\t\n", + ); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].ports, "0.0.0.0:8080->80/tcp, :::8080->80/tcp"); + assert_eq!(rows[1].ports, "-"); } - let stats_text = String::from_utf8_lossy(&stats.stdout); - let mut by_id: HashMap, String, String, String, String)> = HashMap::new(); - for line in stats_text.lines() { - let mut parts = line.split('\t'); - let Some(id) = parts.next() else { continue }; - let cpu = parts.next().and_then(parse_percent); - let mem = parts.next().unwrap_or("-").to_string(); - let net = parts.next().unwrap_or("-").to_string(); - let block = parts.next().unwrap_or("-").to_string(); - let pids = parts.next().unwrap_or("-").to_string(); - by_id.insert(id.to_string(), (cpu, mem, net, block, pids)); - } - for c in &mut containers { - let short = short_id(&c.id); - let stats = by_id.get(&c.id).or_else(|| by_id.get(short)).cloned(); - if let Some((cpu, mem, net, block, pids)) = stats { - c.cpu_pct = cpu; - c.mem_usage = mem; - c.net_io = net; - c.block_io = block; - c.pids = pids; - } + + #[test] + fn derives_browser_url_from_a3s_box_and_docker_ports() { + let mut row = container_row("abcdef", "app", "running", Some(1.0), Some(2.0)); + + row.ports = "8080:80".into(); + assert_eq!( + container_web_url(&row).as_deref(), + Some("http://localhost:8080/") + ); + + row.ports = "127.0.0.1:3000:80".into(); + assert_eq!( + container_web_url(&row).as_deref(), + Some("http://localhost:3000/") + ); + + row.ports = "80/tcp, 0.0.0.0:9090->90/tcp".into(); + assert_eq!( + container_web_url(&row).as_deref(), + Some("http://localhost:9090/") + ); + + row.ports = ":::8443->443/tcp".into(); + assert_eq!( + container_web_url(&row).as_deref(), + Some("http://localhost:8443/") + ); + + row.ports = "0:80".into(); + assert_eq!(container_web_url(&row), None); } - Ok(containers) -} -async fn collect_observer_events() -> Vec { - let Some(path) = std::env::var_os("A3S_TOP_OBSERVER_LOG") else { - return Vec::new(); - }; - let text = match tokio::fs::read_to_string(path).await { - Ok(text) => text, - Err(err) => { - return vec![EventRow { - ts: "now".into(), - source: "observer".into(), - kind: "error".into(), - message: format!("failed to read observer log: {err}"), - risk: Risk::Medium, - }]; - } - }; - text.lines() - .rev() - .take(200) - .filter_map(parse_observer_line) - .collect::>() -} + #[test] + fn parses_a3s_box_ps_and_stats_output() { + let rows = parse_a3s_box_ps( + "abc123\tdev\talpine:latest\trunning\t8080:80\t2 minutes ago\tsleep 3600\n", + ); -fn parse_observer_line(line: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(line).ok()?; - let identity = value.get("identity")?; - let source = identity - .get("agent") - .and_then(|v| v.as_str()) - .unwrap_or("agent") - .to_string(); - let event = value.get("event")?.as_object()?; - let (kind, payload) = event.iter().next()?; - let message = match payload { - serde_json::Value::Object(map) => map - .iter() - .take(4) - .map(|(k, v)| format!("{k}={}", compact_json_value(v))) - .collect::>() - .join(" "), - other => compact_json_value(other), - }; - let risk = match kind.as_str() { - "SecurityAction" | "FileDelete" => Risk::High, - "Egress" | "FileAccess" | "ToolExec" => Risk::Medium, - _ => Risk::Low, - }; - Some(EventRow { - ts: "recent".into(), - source, - kind: kind.clone(), - message, - risk, - }) -} + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].connector, ContainerConnector::A3sBox); + assert_eq!(rows[0].id, "abc123"); + assert_eq!(rows[0].name, "dev"); + assert_eq!(rows[0].status, "running"); + assert_eq!(rows[0].ports, "8080:80"); + assert_eq!(rows[0].inspect.created, "2 minutes ago"); + assert_eq!(rows[0].inspect.started, "2 minutes ago"); + assert!(rows[0].inspect.labels.contains("sleep 3600")); -async fn run_action(action: Action) -> String { - match action { - Action::KillProcess(pid, label) => { - let status = Command::new("kill") - .arg("-TERM") - .arg(pid.to_string()) - .status() - .await; - match status { - Ok(s) if s.success() => format!("sent SIGTERM to PID {pid} · {label}"), - Ok(s) => format!("kill failed for PID {pid}: {s}"), - Err(err) => format!("kill failed for PID {pid}: {err}"), - } - } - Action::StopContainer(id, name) => { - let status = Command::new("docker").args(["stop", &id]).status().await; - match status { - Ok(s) if s.success() => format!("stopped container {name}"), - Ok(s) => format!("docker stop failed for {name}: {s}"), - Err(err) => format!("docker stop failed for {name}: {err}"), - } - } - Action::RestartContainer(id, name) => { - let status = Command::new("docker").args(["restart", &id]).status().await; - match status { - Ok(s) if s.success() => format!("restarted container {name}"), - Ok(s) => format!("docker restart failed for {name}: {s}"), - Err(err) => format!("docker restart failed for {name}: {err}"), - } - } + let json_rows = parse_a3s_box_ps_json( + r#"[ + { + "id": "abcdef1234567890", + "short_id": "abcdef123456", + "name": "dev", + "image": "alpine:latest", + "status": "running (healthy)", + "raw_status": "running", + "created": "2 minutes ago", + "created_at": "2026-06-26T08:00:00Z", + "started_at": "2026-06-26T08:01:00Z", + "ports": ["8080:80"], + "command": "sleep 3600", + "labels": {"role": "api"}, + "health": "healthy", + "pid": 4242 + } + ]"#, + ); + assert_eq!(json_rows.len(), 1); + assert_eq!(json_rows[0].id, "abcdef1234567890"); + assert_eq!(json_rows[0].name, "dev"); + assert_eq!(json_rows[0].status, "running"); + assert_eq!(json_rows[0].ports, "8080:80"); + assert_eq!(json_rows[0].pids, "-"); + assert_eq!(json_rows[0].inspect.health, "healthy"); + assert_eq!(json_rows[0].inspect.created, "2 minutes ago"); + assert_eq!(json_rows[0].inspect.started, "2026-06-26 08:01:00"); + assert!(json_rows[0].inspect.labels.contains("sleep 3600")); + assert!(json_rows[0].inspect.labels.contains("1 label")); + + let stats = parse_a3s_box_stats( + "BOX ID NAME STATUS CPU % MEM USAGE / LIMIT MEM % PID NET I/O IO\nabc123 dev running 12.50% 64.0 MB / 512.0 MB 12.5% 4242 1.0 KB / 2.0 KB 4.0 KB / 8.0 KB\n", + ); + let by_id = stats.get("abc123").unwrap(); + assert_eq!(by_id.cpu_pct, Some(12.5)); + assert_eq!(by_id.mem_pct, Some(12.5)); + assert_eq!(by_id.mem_usage, "64.0 MB / 512.0 MB"); + assert_eq!(by_id.net_io, "1.0 KB / 2.0 KB"); + assert_eq!(by_id.block_io, "4.0 KB / 8.0 KB"); + assert_eq!(by_id.pid.as_deref(), Some("4242")); + assert!(stats.contains_key("dev")); + + let legacy = parse_a3s_box_stats( + "BOX ID NAME STATUS CPU % MEM USAGE / LIMIT MEM % PID\nabc123 dev running 12.50% 64.0 MB / 512.0 MB 12.5% 4242\n", + ); + assert_eq!(legacy.get("abc123").unwrap().net_io, "-"); + assert_eq!(legacy.get("abc123").unwrap().block_io, "-"); + + let legacy_io = parse_a3s_box_stats( + "BOX ID NAME STATUS CPU % MEM USAGE / LIMIT MEM % PID IO\nabc123 dev running 12.50% 64.0 MB / 512.0 MB 12.5% 4242 4.0 KB / 8.0 KB\n", + ); + assert_eq!(legacy_io.get("abc123").unwrap().net_io, "-"); + assert_eq!(legacy_io.get("abc123").unwrap().block_io, "4.0 KB / 8.0 KB"); + + let json_stats = parse_a3s_box_stats_json( + r#"[ + { + "id": "abc123", + "short_id": "abc123", + "name": "dev", + "status": "running", + "pid": 4242, + "cpus": 2, + "cpu_percent": 12.5, + "memory_bytes": 67108864, + "memory_limit_bytes": 536870912, + "memory_percent": 12.5, + "network_rx_bytes": 1024, + "network_tx_bytes": 2048, + "block_read_bytes": 4096, + "block_write_bytes": 8192, + "pids_current": 7 + } + ]"#, + ); + let by_id = json_stats.get("abc123").unwrap(); + assert_eq!(by_id.cpu_pct, Some(12.5)); + assert_eq!(by_id.cpu_count, Some(2)); + assert_eq!(by_id.mem_pct, Some(12.5)); + assert_eq!(by_id.mem_usage, "64.0 MB / 512.0 MB"); + assert_eq!(by_id.net_io, "1.0 KB / 2.0 KB"); + assert_eq!(by_id.block_io, "4.0 KB / 8.0 KB"); + assert_eq!(by_id.pid.as_deref(), Some("4242")); + assert_eq!(by_id.pids_current, Some(7)); + assert!(json_stats.contains_key("dev")); } -} -fn detect_agent(command: &str) -> Option { - let l = command.to_lowercase(); - if l.contains("a3s-code") || l.contains("a3s code") || l.ends_with("/a3s") { - Some(AgentKind::A3sCode) - } else if l.contains("claude") { - Some(AgentKind::ClaudeCode) - } else if l.contains("codex") { - Some(AgentKind::Codex) - } else if l.contains("cursor-agent") || l.contains("cursor") { - Some(AgentKind::Cursor) - } else if l.contains("gemini") { - Some(AgentKind::Gemini) - } else { - None + #[cfg(unix)] + #[test] + fn command_output_error_prefers_stderr_for_visible_collector_errors() { + use std::os::unix::process::ExitStatusExt; + + let output = std::process::Output { + status: std::process::ExitStatus::from_raw(1 << 8), + stdout: b"stdout fallback".to_vec(), + stderr: b"box runtime unavailable\n".to_vec(), + }; + + let message = command_output_error("a3s-box ps", &output); + + assert!(message.contains("a3s-box ps exited with status")); + assert!(message.contains("box runtime unavailable")); + assert!(!message.contains("stdout fallback")); } -} -fn process_risk(command: &str, agent: Option) -> Risk { - let lower = command.to_lowercase(); - if lower.contains("sudo ") - || lower.contains(" rm -rf ") - || lower.contains("ptrace") - || lower.contains("nmap ") - { - Risk::High - } else if agent.is_some() - || lower.contains("docker ") - || lower.contains("curl ") - || lower.contains("bash -c") - { - Risk::Medium - } else { - Risk::Low + #[cfg(unix)] + #[tokio::test] + async fn a3s_box_ps_rows_surfaces_command_failures() { + use std::os::unix::fs::PermissionsExt; + + let root = + std::env::temp_dir().join(format!("a3s-top-box-ps-fail-test-{}", std::process::id())); + let binary = root.join("a3s-box"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + &binary, + b"#!/bin/sh\necho 'box ps failed visibly' >&2\nexit 42\n", + ) + .unwrap(); + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let err = a3s_box_ps_rows(&binary, false, None) + .await + .unwrap_err() + .to_string(); + + let _ = std::fs::remove_dir_all(root); + assert!(err.contains("a3s-box ps --format json exited with status")); + assert!(err.contains("box ps failed visibly")); + assert!(err.contains("fallback table failed")); } -} -fn sort_processes(rows: &mut [ProcessRow], sort_by: SortBy) { - match sort_by { - SortBy::Cpu => rows.sort_by(|a, b| { - b.agent.is_some().cmp(&a.agent.is_some()).then( - b.cpu_pct - .partial_cmp(&a.cpu_pct) - .unwrap_or(std::cmp::Ordering::Equal), - ) - }), - SortBy::Mem => rows.sort_by(|a, b| { - b.agent.is_some().cmp(&a.agent.is_some()).then( - b.mem_pct - .partial_cmp(&a.mem_pct) - .unwrap_or(std::cmp::Ordering::Equal), - ) - }), - SortBy::Name => rows.sort_by(|a, b| a.command.cmp(&b.command)), + #[test] + fn counts_a3s_box_processes_without_probe_process() { + let rows = parse_container_process_table( + "PID PPID %CPU %MEM ELAPSED COMMAND\n1 0 0.0 0.1 02:00 /sbin/init\n42 1 1.5 0.3 00:01 node server.js\n99 1 0.0 0.0 00:00 ps -eo pid,ppid,pcpu,pmem,etime,args\n", + ); + + assert_eq!(a3s_box_process_count_from_rows(&rows), 2); + + let filtered = filter_a3s_box_probe_processes(rows); + assert_eq!(filtered.len(), 2); + assert!(filtered + .iter() + .all(|row| row.command != "ps -eo pid,ppid,pcpu,pmem,etime,args")); } -} -fn parse_percent(s: &str) -> Option { - s.trim().trim_end_matches('%').parse().ok() -} + #[test] + fn parses_a3s_box_top_json_output() { + let rows = parse_a3s_box_top_json( + r#"[ + { + "pid": "1", + "ppid": "0", + "cpu_percent": 0.0, + "memory_percent": 0.1, + "elapsed": "02:00", + "command": "/sbin/init" + }, + { + "pid": 42, + "ppid": 1, + "cpu_percent": "1.5%", + "memory_percent": "0.3%", + "elapsed": "00:01", + "command": "node server.js" + } + ]"#, + ) + .unwrap(); -fn parse_duration(s: &str) -> anyhow::Result { - if let Some(ms) = s.strip_suffix("ms") { - return Ok(Duration::from_millis(ms.parse()?)); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].pid, "1"); + assert_eq!(rows[0].ppid, "0"); + assert_eq!(rows[0].cpu_pct, Some(0.0)); + assert_eq!(rows[0].mem_pct, Some(0.1)); + assert_eq!(rows[1].pid, "42"); + assert_eq!(rows[1].ppid, "1"); + assert_eq!(rows[1].cpu_pct, Some(1.5)); + assert_eq!(rows[1].mem_pct, Some(0.3)); + assert_eq!(rows[1].command, "node server.js"); } - if let Some(sec) = s.strip_suffix('s') { - let seconds: f64 = sec.parse()?; - return Ok(Duration::from_secs_f64(seconds)); + + #[test] + fn parses_a3s_box_inspect_metadata() { + let rows = parse_a3s_box_inspect( + r#"[ + { + "id": "abcdef1234567890", + "name": "dev", + "image": "alpine:latest", + "status": "running", + "created_at": "2026-06-26T08:00:00Z", + "started_at": "2026-06-26T08:01:00Z", + "restart_policy": "on-failure", + "restart_count": 3, + "max_restart_count": 5, + "exit_code": 137, + "health_status": "healthy", + "volumes": ["/host:/guest"], + "volume_names": ["data"], + "env": { + "TOKEN": "secret", + "RUST_LOG": "debug" + }, + "labels": { + "com.example.role": "api" + }, + "network_name": "bridge", + "add_host": ["db:10.0.0.2"], + "status_detail": { + "health": "healthy", + "restart_count": 3 + }, + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "ExitCode": 0 + } + } + ]"#, + ); + let inspect = rows.get("abcdef1234567890").unwrap(); + + assert_eq!(rows.get("abcdef123456").unwrap(), inspect); + assert_eq!(inspect.health, "healthy"); + assert_eq!(inspect.restarts, "3"); + assert_eq!(inspect.restart_policy, "on-failure:5"); + assert_eq!(inspect.created, "2026-06-26 08:00:00"); + assert_eq!(inspect.started, "2026-06-26 08:01:00"); + assert_eq!(inspect.exit, "-"); + assert_eq!(inspect.env, "2 vars"); + assert!(inspect.mounts.contains("/host:/guest")); + assert!(inspect.mounts.contains("data")); + assert!(inspect.labels.contains("com.example.role")); + assert!(inspect.networks.contains("bridge")); + assert!(inspect.networks.contains("db:10.0.0.2")); + assert!(!inspect.env.contains("secret")); } - Ok(Duration::from_millis(s.parse()?)) -} -fn compact_json_value(v: &serde_json::Value) -> String { - match v { - serde_json::Value::String(s) => truncate(s, 80), - _ => truncate(&v.to_string(), 80), + #[test] + fn parses_docker_inspect_metadata() { + let rows = parse_docker_inspect( + r#"[ + { + "Id": "abcdef1234567890", + "Created": "2026-06-26T08:00:00.123456789Z", + "RestartCount": 3, + "State": { + "Status": "running", + "StartedAt": "2026-06-26T08:01:00Z", + "FinishedAt": "0001-01-01T00:00:00Z", + "ExitCode": 0, + "OOMKilled": false, + "Dead": false, + "Health": {"Status": "healthy", "FailingStreak": 0} + }, + "HostConfig": { + "RestartPolicy": {"Name": "on-failure", "MaximumRetryCount": 5} + }, + "Config": { + "Env": ["TOKEN=secret", "RUST_LOG=debug"], + "Labels": { + "com.example.role": "api", + "maintainer": "a3s" + } + }, + "Mounts": [ + {"Type": "bind", "Source": "/host/data", "Destination": "/data", "RW": true}, + {"Type": "volume", "Name": "cfg", "Destination": "/cfg", "RW": false} + ], + "NetworkSettings": { + "Networks": { + "bridge": {"IPAddress": "172.17.0.2"} + } + } + } + ]"#, + ); + let inspect = rows.get("abcdef1234567890").unwrap(); + + assert_eq!(rows.get("abcdef123456").unwrap(), inspect); + assert_eq!(inspect.health, "healthy"); + assert_eq!(inspect.restarts, "3"); + assert_eq!(inspect.restart_policy, "on-failure:5"); + assert_eq!(inspect.created, "2026-06-26 08:00:00"); + assert_eq!(inspect.started, "2026-06-26 08:01:00"); + assert_eq!(inspect.exit, "-"); + assert_eq!(inspect.env, "2 vars"); + assert!(inspect.mounts.contains("/data(rw)")); + assert!(inspect.mounts.contains("/cfg(ro)")); + assert!(inspect.labels.contains("com.example.role")); + assert!(inspect.networks.contains("bridge 172.17.0.2")); + assert!(!inspect.env.contains("secret")); } -} -fn display_cmd(command: &str) -> String { - truncate(command, 64) -} + #[test] + fn parses_runc_stats_json() { + let stats = parse_runc_stats_event( + r#"{ + "type": "stats", + "id": "example", + "data": { + "cpu": {"usage": {"total": 2000000000}}, + "memory": {"usage": {"usage": 1048576, "limit": 4194304}}, + "pids": {"current": 7}, + "network_interfaces": [ + {"Name": "eth0", "RxBytes": 1024, "TxBytes": 2048}, + {"Name": "lo", "RxBytes": 512, "TxBytes": 256} + ], + "blkio": { + "ioServiceBytesRecursive": [ + {"op": "Read", "value": 4096}, + {"op": "Write", "value": 8192} + ] + } + } + }"#, + ) + .unwrap(); -fn short_id(id: &str) -> &str { - id.get(..12.min(id.len())).unwrap_or(id) -} + assert_eq!(stats.cpu_usage_total_ns, Some(2_000_000_000)); + assert_eq!(stats.memory_usage, Some(1_048_576)); + assert_eq!(stats.memory_limit, Some(4_194_304)); + assert_eq!(stats.pids_current, Some(7)); + assert_eq!(stats.net_rx, 1536); + assert_eq!(stats.net_tx, 2304); + assert_eq!(stats.block_read, 4096); + assert_eq!(stats.block_write, 8192); + } -fn truncate(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); + #[test] + fn applies_runc_stats_to_container_row() { + let mut row = container_row("example", "example", "running", None, None); + row.connector = ContainerConnector::RunC; + + apply_runc_stats( + &mut row, + &RuncStats { + memory_usage: Some(1_048_576), + memory_limit: Some(4_194_304), + cpu_usage_total_ns: Some(2_000_000_000), + pids_current: Some(7), + net_rx: 1024, + net_tx: 2048, + block_read: 4096, + block_write: 8192, + }, + ); + + assert_eq!(row.mem_pct, Some(25.0)); + assert_eq!(row.cpu_usage_total_ns, Some(2_000_000_000)); + assert_eq!(row.mem_usage, "1.00MiB / 4.00MiB"); + assert_eq!(row.net_io, "1.00KiB / 2.00KiB"); + assert_eq!(row.block_io, "4.00KiB / 8.00KiB"); + assert_eq!(row.pids, "7"); } - if max <= 1 { - return "…".to_string(); + + #[test] + fn records_metric_history_and_prunes_stale_keys() { + let mut app = TopApp::new(TopOptions::default()); + let mut snapshot = TopSnapshot::default(); + snapshot.processes.push(ProcessRow { + cpu_pct: 12.0, + mem_pct: 3.0, + ..process_row(42, 0, "codex") + }); + snapshot.processes.push(ProcessRow { + cpu_pct: 4.0, + mem_pct: 2.0, + ..process_row(100, 42, "cargo test") + }); + snapshot.containers.push(ContainerRow { + connector: ContainerConnector::Docker, + id: "abcdef".into(), + name: "app".into(), + image: "img".into(), + status: "Up".into(), + inspect: ContainerInspect::default(), + cpu_pct: Some(8.0), + cpu_count: None, + cpu_usage_total_ns: None, + mem_pct: Some(9.0), + mem_usage: "1MiB / 10MiB".into(), + net_io: "1.00KiB / 2.00KiB".into(), + block_io: "4.00KiB / 8.00KiB".into(), + pids: "1".into(), + ports: "-".into(), + }); + + app.record_history(&mut snapshot); + + assert_eq!(app.metric_history("process:42").cpu, vec![12.0]); + assert_eq!(app.metric_history("agent-tree:42").cpu, vec![16.0]); + assert_eq!(app.metric_history("agent-tree:42").mem, vec![5.0]); + assert_eq!(app.metric_history("container:abcdef").mem, vec![9.0]); + assert_eq!( + app.metric_history("container:abcdef").net_io_bytes, + vec![3072.0] + ); + assert_eq!( + app.metric_history("container:abcdef").block_io_bytes, + vec![12288.0] + ); + + app.record_history(&mut TopSnapshot::default()); + assert!(app.history.is_empty()); } - let mut out = s.chars().take(max - 1).collect::(); - out.push('…'); - out -} -fn pad_plain(s: &str, width: usize) -> String { - let len = s.chars().count(); - if len >= width { - truncate(s, width) - } else { - format!("{s}{}", " ".repeat(width - len)) + #[test] + fn derives_cpu_percent_from_runc_raw_totals() { + let now = Instant::now(); + let mut history = MetricHistory::default(); + + assert_eq!( + observe_raw_cpu_pct(&mut history, Some(1_000_000_000), now), + None + ); + + let pct = observe_raw_cpu_pct( + &mut history, + Some(1_500_000_000), + now + Duration::from_secs(1), + ) + .unwrap(); + + assert!((pct - 50.0).abs() < 0.01); } -} -fn pad_line(s: &str, width: usize) -> String { - let visible = a3s_tui::style::visible_len(s); - if visible >= width { - s.to_string() - } else { - format!("{s}{}", " ".repeat(width - visible)) + #[test] + fn caps_metric_history() { + let mut history = MetricHistory::default(); + for value in 0..(HISTORY_LIMIT + 5) { + push_history(&mut history, value as f32, value as f32); + } + + assert_eq!(history.cpu.len(), HISTORY_LIMIT); + assert_eq!(history.cpu[0], 5.0); } -} -fn center(s: &str, width: usize) -> String { - let len = s.chars().count(); - if len >= width { - return truncate(s, width); + #[test] + fn observer_chunk_is_incremental_and_newest_first() { + let mut file = ObserverFileState::default(); + let first = + r#"{"identity":{"agent":"codex"},"event":{"ToolExec":{"argv":["git","status"]}}}"#; + let second = + r#"{"identity":{"agent":"claude"},"event":{"FileAccess":{"path":"README.md"}}}"#; + + let rows = append_observer_chunk(&mut file, first); + assert!(rows.is_empty()); + assert_eq!(file.pending, first); + + let rows = append_observer_chunk(&mut file, &format!("\n{second}\n")); + + assert!(file.pending.is_empty()); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].source, "claude"); + assert_eq!(rows[1].source, "codex"); } - let left = (width - len) / 2; - format!( - "{}{}{}", - " ".repeat(left), - s, - " ".repeat(width - len - left) - ) -} -#[cfg(test)] -mod tests { - use super::*; + #[test] + fn observer_rows_merge_newest_first_across_files() { + let mut state = ObserverState { + paths: vec![PathBuf::from("left.ndjson"), PathBuf::from("right.ndjson")], + ..ObserverState::default() + }; + let mut left = ObserverFileState::default(); + let mut right = ObserverFileState::default(); + push_observer_rows( + &mut state, + append_observer_chunk( + &mut left, + r#"{"identity":{"agent":"codex"},"event":{"ToolExec":{"argv":["git","status"]}}} +"#, + ), + ); + push_observer_rows( + &mut state, + append_observer_chunk( + &mut right, + r#"{"identity":{"agent":"claude"},"event":{"FileAccess":{"path":"README.md"}}} +"#, + ), + ); + + assert_eq!(state.events.len(), 2); + assert_eq!(state.events[0].source, "claude"); + assert_eq!(state.events[1].source, "codex"); + assert_eq!(observer_status_label(&state), "obs:2 files"); + } #[test] - fn detects_known_agents() { - assert_eq!(detect_agent("/usr/bin/a3s code"), Some(AgentKind::A3sCode)); + fn observer_auto_discovers_known_agent_logs_and_dedupes_explicit_paths() { + let root = + std::env::temp_dir().join(format!("a3s-top-observer-auto-test-{}", std::process::id())); + let explicit = root.join(".a3s").join("observer").join("events.ndjson"); + let claude = root + .join(".claude") + .join("projects") + .join("-tmp-work") + .join("session.jsonl"); + let codex = root + .join(".codex") + .join("sessions") + .join("2026") + .join("06") + .join("26") + .join("rollout.jsonl"); + let a3s = root + .join(".a3s") + .join("tui-sessions") + .join("runs") + .join("tui-default.json"); + let a3s_workspace = root + .join(".a3s") + .join("workspace") + .join("users") + .join("u") + .join("sessions") + .join("s") + .join(".sessions") + .join("runs") + .join("s.json"); + write_file(&explicit, "{}\n"); + write_file(&claude, "{}\n"); + write_file(&codex, "{}\n"); + write_file(&a3s, "[]\n"); + write_file(&a3s_workspace, "[]\n"); + + let old_home = std::env::var_os("HOME"); + let old_log = std::env::var_os("A3S_TOP_OBSERVER_LOG"); + let old_logs = std::env::var_os("A3S_TOP_OBSERVER_LOGS"); + let old_auto = std::env::var_os("A3S_TOP_OBSERVER_AUTO"); + std::env::set_var("HOME", &root); + std::env::set_var("A3S_TOP_OBSERVER_AUTO", "1"); + std::env::set_var( + "A3S_TOP_OBSERVER_LOGS", + explicit.to_string_lossy().to_string(), + ); + std::env::remove_var("A3S_TOP_OBSERVER_LOG"); + + let paths = observer_paths(); + + restore_var("HOME", old_home); + restore_var("A3S_TOP_OBSERVER_LOG", old_log); + restore_var("A3S_TOP_OBSERVER_LOGS", old_logs); + restore_var("A3S_TOP_OBSERVER_AUTO", old_auto); + let _ = std::fs::remove_dir_all(root); + assert_eq!( - detect_agent("node /bin/claude"), - Some(AgentKind::ClaudeCode) + paths.paths.iter().filter(|path| *path == &explicit).count(), + 1 ); - assert_eq!(detect_agent("codex exec task"), Some(AgentKind::Codex)); + assert!(paths.paths.contains(&claude)); + assert!(paths.paths.contains(&codex)); + assert!(paths.paths.contains(&a3s)); + assert!(paths.paths.contains(&a3s_workspace)); + assert!(!paths.auto_paths.contains(&explicit)); + assert!(paths.auto_paths.contains(&claude)); } #[test] - fn parses_process_rows() { - let row = parse_process_line(" 123 1 2.5 0.7 01:02:03 codex exec hello").unwrap(); - assert_eq!(row.pid, 123); - assert_eq!(row.ppid, 1); - assert_eq!(row.agent, Some(AgentKind::Codex)); - assert_eq!(row.cpu_pct, 2.5); + fn observer_status_labels_auto_sources() { + let auto = PathBuf::from("/tmp/claude.jsonl"); + let state = ObserverState { + paths: vec![auto.clone()], + auto_paths: HashSet::from([auto]), + ..ObserverState::default() + }; + + assert_eq!(observer_status_label(&state), "obs:auto:claude.jsonl"); } #[test] - fn parses_durations() { - assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); - assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2)); - assert_eq!(parse_duration("1500").unwrap(), Duration::from_millis(1500)); + fn observer_timestamp_sort_key_normalizes_iso_and_numeric_millis() { + assert_eq!( + observer_timestamp_sort_key("2026-06-26T08:00:00Z"), + Some(1_782_460_800_000) + ); + assert_eq!( + observer_timestamp_sort_key("1782460800000"), + Some(1_782_460_800_000) + ); + assert!(observer_timestamp_sort_key("now").unwrap() > 1_782_460_800_000); } #[test] - fn parses_observer_events() { - let line = r#"{"identity":{"agent":"codex","task":"1","session":null},"provider":null,"event":{"ToolExec":{"pid":2,"argv":["git","status"],"cwd":"/tmp"}}}"#; - let row = parse_observer_line(line).unwrap(); - assert_eq!(row.source, "codex"); + fn observer_trimming_keeps_newest_events_across_sources() { + let mut events = (0..220) + .map(|idx| EventRow { + ts: format!("{}", 1_000_000 + idx), + source: "a3s-code".into(), + session: Some("old".into()), + task: None, + pid: None, + ppid: None, + kind: "AgentEvent".into(), + message: format!("old-{idx}"), + details: Vec::new(), + risk: Risk::Low, + }) + .collect::>(); + events.push(EventRow { + ts: "2026-06-26T08:00:00Z".into(), + source: "codex".into(), + session: Some("new".into()), + task: None, + pid: None, + ppid: None, + kind: "LlmCall".into(), + message: "new codex event".into(), + details: Vec::new(), + risk: Risk::Low, + }); + + trim_observer_events(&mut events); + + assert_eq!(events.len(), OBSERVER_EVENT_LIMIT); + assert_eq!(events[0].source, "codex"); + assert!(events + .iter() + .any(|event| event.message == "new codex event")); + } + + #[test] + fn parses_claude_jsonl_tool_use() { + let row = parse_observer_line( + r#"{ + "type":"assistant", + "timestamp":"2026-06-26T08:00:00Z", + "sessionId":"claude-session", + "cwd":"/work/a3s", + "message":{ + "model":"claude-opus-4", + "usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}, + "content":[ + {"type":"tool_use","name":"Bash","input":{"command":"cargo test","description":"Run tests"}} + ] + } + }"#, + ) + .unwrap(); + + assert_eq!(row.source, "claude"); + assert_eq!(row.session.as_deref(), Some("claude-session")); assert_eq!(row.kind, "ToolExec"); assert_eq!(row.risk, Risk::Medium); + assert!(row.message.contains("Bash")); + assert_eq!( + event_detail_value(&row, &["model"]).as_deref(), + Some("claude-opus-4") + ); + assert_eq!(event_token_usage(&row).unwrap().total, 15); + assert_eq!(event_workspace(&row).as_deref(), Some("/work/a3s")); + } + + #[test] + fn parses_codex_jsonl_token_usage() { + let row = parse_observer_line( + r#"{ + "timestamp":"2026-06-26T08:00:00Z", + "type":"event_msg", + "payload":{ + "type":"token_count", + "info":{ + "last_token_usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}, + "model_context_window":258400 + } + } + }"#, + ) + .unwrap(); + + assert_eq!(row.source, "codex"); + assert_eq!(row.kind, "LlmCall"); + assert_eq!(event_token_usage(&row).unwrap().total, 10); + assert_eq!( + event_detail_value(&row, &["context_window"]).as_deref(), + Some("258400") + ); + } + + #[test] + fn codex_token_usage_prefers_last_turn_over_lifetime_total() { + let row = parse_observer_line( + r#"{ + "timestamp":"2026-06-26T08:00:00Z", + "type":"event_msg", + "payload":{ + "type":"token_count", + "info":{ + "total_token_usage":{"input_tokens":1000000,"output_tokens":500000,"total_tokens":1500000}, + "last_token_usage":{"input_tokens":70,"output_tokens":30,"total_tokens":100} + } + } + }"#, + ) + .unwrap(); + + assert_eq!(event_token_usage(&row).unwrap().total, 100); + assert_eq!( + event_detail_value(&row, &["lifetime_total_tokens"]).as_deref(), + Some("1500000") + ); + } + + #[test] + fn parses_a3s_run_record_document() { + let rows = parse_observer_json_document( + r#"[ + { + "snapshot": { + "id": "run-1", + "session_id": "session-1", + "workspace": "/work/a3s" + }, + "events": [ + { + "sequence": 1, + "timestamp_ms": 1782470400000, + "event": {"type":"tool_start","name":"shell","command":"cargo test"} + }, + { + "sequence": 2, + "timestamp_ms": 1782470400100, + "event": {"type":"turn_end","total_tokens":123} + } + ] + } + ]"#, + ); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].source, "a3s-code"); + assert_eq!(rows[0].session.as_deref(), Some("session-1")); + assert_eq!(rows[0].task.as_deref(), Some("run-1")); + assert_eq!(rows[0].kind, "ToolExec"); + assert_eq!(event_workspace(&rows[0]).as_deref(), Some("/work/a3s")); + assert_eq!(rows[1].kind, "LlmCall"); + assert_eq!(event_token_usage(&rows[1]).unwrap().total, 123); + } + + fn event_row( + source: &str, + session: Option<&str>, + task: Option<&str>, + kind: &str, + message: &str, + risk: Risk, + ) -> EventRow { + EventRow { + ts: "recent".into(), + source: source.into(), + session: session.map(Into::into), + task: task.map(Into::into), + pid: None, + ppid: None, + kind: kind.into(), + message: message.into(), + details: Vec::new(), + risk, + } + } + + fn process_row(pid: u32, ppid: u32, command: &str) -> ProcessRow { + let agent = detect_agent(command); + ProcessRow { + pid, + ppid, + cpu_pct: 0.0, + mem_pct: 0.0, + elapsed: "00:01".into(), + cwd: None, + command: command.into(), + agent, + risk: process_risk(command, agent), + } + } + + fn container_row( + id: &str, + name: &str, + status: &str, + cpu_pct: Option, + mem_pct: Option, + ) -> ContainerRow { + ContainerRow { + connector: ContainerConnector::Docker, + id: id.into(), + name: name.into(), + image: "img".into(), + status: status.into(), + inspect: ContainerInspect::default(), + cpu_pct, + cpu_count: None, + cpu_usage_total_ns: None, + mem_pct, + mem_usage: "1MiB / 10MiB".into(), + net_io: "-".into(), + block_io: "-".into(), + pids: "1".into(), + ports: "-".into(), + } + } + + fn numbered_lines(count: usize) -> String { + (0..count) + .map(|idx| format!("line-{idx}")) + .collect::>() + .join("\n") + } + + fn write_file(path: &Path, content: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + fn restore_var(key: &str, value: Option) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } } } diff --git a/src/top/view.rs b/src/top/view.rs new file mode 100644 index 0000000..a46530b --- /dev/null +++ b/src/top/view.rs @@ -0,0 +1,113 @@ +//! Shared process-table renderer used by `a3s top`'s Processes tab and the +//! lightweight `/top` panel in `a3s code`, so both show identical columns, +//! colours, and agent highlighting. The rich sparkline columns degrade to +//! blank cells when the caller has no per-pid history. + +use std::collections::HashSet; + +use a3s_tui::components::{CellAlign, DataColumn, DataRow, DataTable, Sparkline}; +use a3s_tui::style::Color; + +use super::{display_workspace, ProcessRow, YELLOW}; + +/// Per-pid `(cpu, mem)` history lookup feeding the sparkline columns. +pub(crate) type HistoryFn<'a> = dyn Fn(u32) -> (Vec, Vec) + 'a; + +/// View parameters for [`render_process_table`]. +pub(crate) struct ProcessTableView<'a> { + pub(crate) selected: usize, + pub(crate) scroll: usize, + pub(crate) width: u16, + pub(crate) height: usize, + /// Column ids to hide (empty = show all). + pub(crate) hidden: &'a HashSet, + /// Per-pid history feeding the sparkline columns. `None` renders those cells + /// blank — graceful degradation for the lightweight `/top` panel. + pub(crate) history: Option<&'a HistoryFn<'a>>, +} + +fn configured(hidden: &HashSet, id: &str, column: DataColumn) -> DataColumn { + if hidden.contains(id) { + column.hidden() + } else { + column + } +} + +fn sparkline(values: &[f32], color: Color) -> String { + Sparkline::new(values.iter().copied().map(f64::from)) + .width(8) + .range(0.0, 100.0) + .fg(color) + .view() +} + +/// Render the host process table. Agent rows wear their brand colour; other +/// rows are coloured by risk. The selected row is highlighted by the table. +pub(crate) fn render_process_table(rows: &[ProcessRow], view: &ProcessTableView) -> String { + let h = view.hidden; + let columns = vec![ + configured( + h, + "processes.pid", + DataColumn::new("PID").width(7).align(CellAlign::Right), + ), + configured( + h, + "processes.ppid", + DataColumn::new("PPID").width(7).align(CellAlign::Right), + ), + configured( + h, + "processes.cpu", + DataColumn::new("CPU%").width(6).align(CellAlign::Right), + ), + configured(h, "processes.cpu_history", DataColumn::new("CPU").width(8)), + configured( + h, + "processes.mem", + DataColumn::new("MEM%").width(6).align(CellAlign::Right), + ), + configured(h, "processes.mem_history", DataColumn::new("MEM").width(8)), + configured(h, "processes.risk", DataColumn::new("RISK").width(5)), + configured(h, "processes.elapsed", DataColumn::new("ELAPSED").width(9)), + configured(h, "processes.cwd", DataColumn::new("CWD").width(18)), + configured( + h, + "processes.command", + DataColumn::new("COMMAND").min_width(16), + ), + ]; + + let mut table = DataTable::new(columns) + .header_fg(Color::BrightBlack) + .separator_fg(Color::BrightBlack) + .selected((!rows.is_empty()).then_some(view.selected)) + .scroll(view.scroll) + .empty("no diagnostic processes match the current filter"); + + for row in rows { + let color = row + .agent + .map(|a| a.color()) + .unwrap_or_else(|| row.risk.color()); + let (cpu_hist, mem_hist) = match view.history { + Some(history) => history(row.pid), + None => (Vec::new(), Vec::new()), + }; + let cells = vec![ + row.pid.to_string(), + row.ppid.to_string(), + format!("{:.1}", row.cpu_pct), + sparkline(&cpu_hist, color), + format!("{:.1}", row.mem_pct), + sparkline(&mem_hist, YELLOW), + row.risk.label().to_string(), + row.elapsed.clone(), + display_workspace(row.cwd.as_deref()), + row.command.clone(), + ]; + table.add_row(DataRow::new(cells).fg(color)); + } + table.view(view.width, view.height) +} diff --git a/src/tui/config.rs b/src/tui/config.rs index 27105f0..06e93d6 100644 --- a/src/tui/config.rs +++ b/src/tui/config.rs @@ -9,6 +9,9 @@ pub(crate) fn config_template() -> &'static str { default_model = "openai/my-model" +# Optional OS endpoint. When set, a3s code enables /login and /logout. +# os = "https://os.example.com" + providers "openai" { apiKey = "sk-REPLACE-ME" baseUrl = "https://api.openai.com/v1/" # or any OpenAI-compatible endpoint diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 552f6e7..bad65b3 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -9,11 +9,18 @@ //! the update handler issues the next pump — feeding the async event stream into //! the synchronous TEA update loop one event at a time. -use std::collections::BinaryHeap; +use std::collections::{BinaryHeap, HashSet}; +use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; +use a3s_code_core::config::OsConfig; +use a3s_code_core::context::RecentWorkspaceFilesContextProvider; use a3s_code_core::hitl::TimeoutAction; +use a3s_code_core::workspace::{ + LocalWorkspaceManifest, LocalWorkspaceManifestSnapshot, ManifestWorkspaceBackend, + WorkspaceServices, +}; use a3s_code_core::{Agent, AgentEvent, AgentSession, SessionOptions, SystemPromptSlots}; use a3s_tui::cmd::{self, Cmd}; use a3s_tui::components::textarea::TextareaMsg; @@ -27,12 +34,14 @@ use a3s_tui::style::{Color, Style}; use a3s_tui::{Event, KeyCode, KeyModifiers, Model, ProgramBuilder}; use tokio::sync::{mpsc, Mutex}; +use crate::top::{collect_processes, render_process_table, ProcessRow, ProcessTableView}; + mod config; mod gitutil; mod image; mod panels; mod render; -mod skills; +pub(crate) mod skills; mod syntax; mod update; mod util; @@ -45,7 +54,7 @@ use syntax::*; use update::*; use util::*; -/// Theme accent — ShuAn OS blue. Single source of truth for the UI accent color. +/// Theme accent — OS blue. Single source of truth for the UI accent color. // Tokyo Night palette — muted, cohesive accents used across the whole UI. const ACCENT: Color = Color::Rgb(122, 162, 247); // soft blue (primary) const TN_GREEN: Color = Color::Rgb(158, 206, 106); @@ -53,9 +62,42 @@ const TN_YELLOW: Color = Color::Rgb(224, 175, 104); const TN_RED: Color = Color::Rgb(247, 118, 142); const TN_CYAN: Color = Color::Rgb(125, 207, 255); const TN_ORANGE: Color = Color::Rgb(255, 158, 100); +const TN_PURPLE: Color = Color::Rgb(187, 154, 247); // magenta / purple accent const TN_FG: Color = Color::Rgb(192, 202, 245); // body text const TN_GRAY: Color = Color::Rgb(122, 132, 168); // completed / muted tasks +/// Self-contained system-prompt directive injected ONLY when signed in to the OS +/// platform. It disambiguates "OS" (the user means the signed-in 书安OS open +/// platform, not this machine's operating system) AND inlines exactly how to call +/// the progressive API, so the model can act immediately — without first +/// discovering/loading the `a3s-os-capabilities` skill (that extra hop is why a +/// passive catalog entry rarely triggered: the model fell back to `whoami`). +/// `base_url` is the signed-in address so the endpoint is concrete. +fn os_platform_guide(base_url: &str) -> String { + format!( + "[OS platform] You are signed in to the 书安OS open platform at {base_url} (via /login). \ +DEFAULT RULE: while signed in, \"OS\" in the user's questions ALWAYS means THIS 书安OS platform — \ +never this machine's operating system. So \"what's my OS account\", \"what modules does OS have\", \ +etc. are about the platform. Answer them via the platform's progressive API; do NOT answer from \ +this machine (whoami / hostname / paths / working directory describe the local box, not the \ +platform — they are the WRONG answer). The endpoint and auth token are ALREADY in your shell \ +environment (exported at login) — use them directly; do NOT read ~/.a3s/os-auth.json or any config \ +file on each call:\n\ + curl -s -X POST \"$A3S_OS_BASE_URL/api/v1/kernel/capabilities\" \ +-H \"Authorization: Bearer $A3S_OS_TOKEN\" -H 'Content-Type: application/json' \ +-d '{{\"action\":\"list\"}}'\n\ +Body fields: `action` = list|search|describe|execute, plus `module` / `operation` / `params`. \ +Go broad→narrow: `list` (modules) → `describe`/`search` for the one operation → `execute`. \ +Pipe responses through `jq` to extract ONLY the fields you need (e.g. \ +`| jq -r '.data.modules[].name'`) so output stays a few lines; summarize the result for the user \ +in a few lines and do NOT paste the whole raw JSON back. \ +If a response contains a `viewUrl` (deep link to the console page for what was asked; extract \ +robustly e.g. `jq -r '.. | .viewUrl? // empty'`), ALWAYS show it to the user as a labeled \ +clickable link on its own line (e.g. `🔗 在控制台查看: `). The `a3s-os-capabilities` \ +skill has full examples." + ) +} + /// Built-in slash commands shown in the `/` menu. const SLASH_COMMANDS: &[(&str, &str)] = &[ ( @@ -73,6 +115,8 @@ const SLASH_COMMANDS: &[(&str, &str)] = &[ "/workflow", "view the latest ultracode dynamic workflow (read-only)", ), + ("/login", "sign in to the configured OS account"), + ("/logout", "sign out from the configured OS account"), ("/plugin", "enable/disable Claude skills & plugins"), ("/reload", "re-scan skills/plugins (hot-reload the / menu)"), ("/update", "upgrade a3s to the latest release"), @@ -97,27 +141,10 @@ const SLASH_COMMANDS: &[(&str, &str)] = &[ /// Slash commands that mutate the session / conversation and so must NOT run /// mid-stream — hidden from the menu and rejected while a turn is in flight. const IDLE_ONLY: &[&str] = &[ - "/clear", "/compact", "/model", "/effort", "/goal", "/loop", "/relay", "/update", "/init", + "/clear", "/compact", "/model", "/effort", "/goal", "/loop", "/relay", "/reload", "/update", + "/init", ]; -/// Workspace files for the `@` picker (git-tracked, gitignore-respected). -fn workspace_files(dir: &str) -> Vec { - std::process::Command::new("git") - .arg("-C") - .arg(dir) - .args(["ls-files", "--cached", "--others", "--exclude-standard"]) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| { - String::from_utf8_lossy(&o.stdout) - .lines() - .map(String::from) - .collect() - }) - .unwrap_or_default() -} - /// Slash commands whose name starts with `input` (input begins with `/`). fn slash_candidates(input: &str) -> Vec<(&'static str, &'static str)> { SLASH_COMMANDS @@ -127,47 +154,168 @@ fn slash_candidates(input: &str) -> Vec<(&'static str, &'static str)> { .collect() } -/// One row of the `/top` process panel. -struct ProcRow { - pid: String, - cpu: f32, - mem: f32, - cmd: String, - agent: Option<&'static str>, +/// Glyph + colour for a plan task's status. +fn task_status_style(status: a3s_code_core::planning::TaskStatus) -> (char, Color) { + use a3s_code_core::planning::TaskStatus; + match status { + TaskStatus::Completed => ('✔', TN_GRAY), + TaskStatus::InProgress => ('▶', TN_YELLOW), + TaskStatus::Failed => ('✗', TN_RED), + TaskStatus::Skipped | TaskStatus::Cancelled => ('⊘', TN_GRAY), + _ => ('□', TN_GRAY), // Pending + } } -/// Detect a coding-agent process from its command line. -fn detect_agent(cmd: &str) -> Option<&'static str> { - let l = cmd.to_lowercase(); - if l.contains("a3s-code") - || l.contains("a3s code") - || l.contains("/a3s ") - || l.ends_with("/a3s") - { - Some("a3s-code") - } else if l.contains("claude") { - Some("claude code") - } else if l.contains("codex") { - Some("codex") - } else if l.contains("cursor-agent") { - Some("cursor") - } else if l.contains("gemini") { - Some("gemini") +/// A turn that delegated work (tools / subagents / planning) but stopped without +/// a final user-facing answer should auto-synthesize one. This applies in EVERY +/// mode, not just ultracode: parallel fan-out and planning run at all efforts, so +/// the "did work, produced no answer" gap can happen anywhere (e.g. a high-effort +/// plan that fans out to subagents which return artifacts-only). Fires at most +/// once per turn (`synthesis_used`). +fn needs_synthesis( + synthesis_inflight: bool, + synthesis_used: bool, + had_agent_activity: bool, + text_after_activity: bool, +) -> bool { + !synthesis_inflight && !synthesis_used && had_agent_activity && !text_after_activity +} + +/// Rough in-flight token estimate for text that's still streaming, before the +/// provider's exact `usage` arrives on End. ASCII text averages ~4 chars/token, +/// but CJK and other wide scripts are closer to ~1 token/char — so a flat +/// `chars / 4` under-counts Chinese by 3-4× and makes the live counter lurch +/// upward when it snaps to the real number. Count the two classes separately. +fn estimate_tokens(s: &str) -> usize { + let (ascii, wide) = s.chars().fold((0usize, 0usize), |(a, w), c| { + if c.is_ascii() { + (a + 1, w) + } else { + (a, w + 1) + } + }); + ascii / 4 + wide +} + +/// Conservative context window assumed for models that declare no `limit.context` +/// in config. Most modern models are >= this, so the ctx% indicator and +/// auto-compaction keep working instead of silently disabling (a 0 window turns +/// both off). Declared limits always override this. +const DEFAULT_CONTEXT_LIMIT: u32 = 128_000; + +/// The fixed `max_context_tokens` the core uses for its auto-compaction trigger. +/// The core compares each turn's prompt tokens against this constant and has no +/// setter for the real model window, so we compensate by scaling the threshold. +const CORE_MAX_CONTEXT_TOKENS: f32 = 200_000.0; + +/// Resolve a model's usable context window: the declared limit, or a sane +/// default when it's missing/zero so context management never silently no-ops. +fn resolve_ctx_limit(raw: Option) -> u32 { + match raw { + Some(c) if c > 0 => c, + _ => DEFAULT_CONTEXT_LIMIT, + } +} + +/// Scale the core's auto-compact threshold so it fires at ~85% of `window` (the +/// model's REAL context window) rather than 85% of the core's fixed 200k. For a +/// 128k model: `0.85 * 128k / 200k = 0.544` → triggers at ~108.8k (= 85% of +/// 128k). Windows above ~235k clamp to 1.0 (trigger at 200k): a touch early, but +/// it never lets the window overflow. +fn auto_compact_threshold_for(window: u32) -> f32 { + let window = if window > 0 { + window as f32 } else { - None + CORE_MAX_CONTEXT_TOKENS + }; + (0.85 * window / CORE_MAX_CONTEXT_TOKENS).clamp(0.05, 1.0) +} + +fn workflow_doc_for_tool(name: &str, args: Option<&serde_json::Value>) -> Option<(String, String)> { + match name { + "program" => { + let src = args + .and_then(|a| a.get("source")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty())?; + Some(( + format!("# Dynamic workflow script\n\n```javascript\n{src}\n```\n"), + "dynamic workflow script · /workflow to view read-only".to_string(), + )) + } + "parallel_task" => { + let tasks = args + .and_then(|a| a.get("tasks")) + .and_then(|t| t.as_array())? + .iter() + .collect::>(); + workflow_doc_for_tasks(&tasks, true) + } + "task" => { + let args = args?; + if let Some(tasks) = args.get("tasks").and_then(|t| t.as_array()) { + let tasks = tasks.iter().collect::>(); + workflow_doc_for_tasks(&tasks, tasks.len() > 1) + } else { + workflow_doc_for_tasks(&[args], false) + } + } + _ => None, } } -/// Glyph + colour for a plan task's status. -fn task_status_style(status: a3s_code_core::planning::TaskStatus) -> (char, Color) { - use a3s_code_core::planning::TaskStatus; - match status { - TaskStatus::Completed => ('✔', TN_GRAY), - TaskStatus::InProgress => ('▶', Color::Yellow), - TaskStatus::Failed => ('✗', Color::Red), - TaskStatus::Skipped | TaskStatus::Cancelled => ('⊘', Color::BrightBlack), - _ => ('□', Color::BrightBlack), // Pending +fn workflow_doc_for_tasks( + tasks: &[&serde_json::Value], + parallel: bool, +) -> Option<(String, String)> { + if tasks.is_empty() { + return None; } + + let mut doc = if parallel { + format!( + "# Dynamic workflow\n\nFanned out {} parallel subagent task(s):\n\n", + tasks.len() + ) + } else { + "# Dynamic workflow\n\nDelegated subagent task(s):\n\n".to_string() + }; + + for (i, task) in tasks.iter().enumerate() { + let desc = task + .get("description") + .or_else(|| task.get("prompt")) + .or_else(|| task.get("task")) + .and_then(|v| v.as_str()) + .unwrap_or("(task)"); + let agent = task + .get("agent") + .and_then(|v| v.as_str()) + .unwrap_or("agent"); + let prompt = task + .get("prompt") + .or_else(|| task.get("task")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + doc.push_str(&format!( + "## {}. {desc}\n\nAgent: `{agent}`\n\n{prompt}\n\n", + i + 1 + )); + } + + let label = if parallel { + format!( + "dynamic workflow · {} parallel tasks · /workflow to view read-only", + tasks.len() + ) + } else { + format!( + "dynamic workflow · {} delegated task{} · /workflow to view read-only", + tasks.len(), + if tasks.len() == 1 { "" } else { "s" } + ) + }; + Some((doc, label)) } /// Brand/theme colour for a coding agent, used to tag its rows and tabs. @@ -178,50 +326,15 @@ fn agent_color(agent: &str) -> Color { "codex" => Color::Rgb(16, 163, 127), // OpenAI green "cursor" => Color::Rgb(180, 182, 200), "gemini" => Color::Rgb(124, 137, 245), - _ => Color::BrightBlack, + _ => TN_GRAY, } } -/// Snapshot the process table via `ps`, sorted by CPU, agents first. -async fn fetch_top() -> Vec { - let out = tokio::process::Command::new("ps") - .args(["-axo", "pid=,pcpu=,pmem=,args="]) - .output() - .await; - let Ok(out) = out else { return Vec::new() }; - let text = String::from_utf8_lossy(&out.stdout); - let mut rows: Vec = text - .lines() - .filter_map(|line| { - // ps right-aligns columns with runs of spaces, so collapse them. - let mut it = line.split_whitespace(); - let pid = it.next()?.to_string(); - let cpu: f32 = it.next()?.parse().ok()?; - let mem: f32 = it.next()?.parse().ok()?; - let cmd = it.collect::>().join(" "); - if cmd.is_empty() { - return None; - } - let agent = detect_agent(&cmd); - Some(ProcRow { - pid, - cpu, - mem, - cmd, - agent, - }) - }) - .collect(); - // Agents first, then by CPU descending. - rows.sort_by(|a, b| { - b.agent.is_some().cmp(&a.agent.is_some()).then( - b.cpu - .partial_cmp(&a.cpu) - .unwrap_or(std::cmp::Ordering::Equal), - ) - }); - rows.truncate(200); - rows +/// Snapshot host processes for the `/top` panel via the shared `a3s top` +/// collector, so the panel and `a3s top` agree on rows, agent detection, risk, +/// CWD, and ordering (agents first, then CPU descending). +async fn fetch_top() -> Vec { + collect_processes().await.unwrap_or_default() } /// One visible row of the `/ide` file tree (a flattened, expandable tree). @@ -649,6 +762,8 @@ impl PartialOrd for Queued { /// pump command can own a clone; pumps run sequentially, so the mutex never /// actually contends. type SharedRx = Arc>>; +type SharedManifestRx = + Arc>>; #[derive(PartialEq)] enum State { @@ -681,6 +796,8 @@ enum Msg { StreamStarted(SharedRx), StreamEnded, StreamError(String), + WorkspaceManifest(Box), + WorkspaceManifestStopped, SpinnerTick, /// Advance the welcome-mascot animation frame. BannerTick, @@ -691,10 +808,14 @@ enum Msg { ShellOutput(String), /// `/update` version check finished: the latest version tag, if reachable. UpdatePlan(Option), + /// OS login completed. + OsLogin(Result), + /// OS access token was refreshed (or refresh failed) in the background. + OsRefreshed(Result), /// Answer from a `/btw` background side-thread. SideNote(String), /// Refreshed process snapshot for the `/top` panel. - TopData(Vec), + TopData(Vec), /// Tick to re-fetch the `/top` snapshot. TopRefresh, /// Result of the async `/relay` session scan. @@ -729,6 +850,21 @@ fn pump(rx: SharedRx) -> Cmd { }) } +fn pump_manifest(rx: SharedManifestRx) -> Cmd { + cmd::cmd(move || async move { + let mut guard = rx.lock().await; + loop { + match guard.recv().await { + Ok(snapshot) => return Msg::WorkspaceManifest(Box::new(snapshot)), + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + return Msg::WorkspaceManifestStopped; + } + } + } + }) +} + fn spinner_tick() -> Cmd { cmd::tick(Duration::from_millis(80), Msg::SpinnerTick) } @@ -738,14 +874,38 @@ fn banner_tick() -> Cmd { cmd::tick(Duration::from_millis(280), Msg::BannerTick) } +fn with_recent_workspace_context( + opts: SessionOptions, + manifest: &Arc, +) -> SessionOptions { + opts.with_context_provider(Arc::new(RecentWorkspaceFilesContextProvider::new( + manifest.clone(), + ))) +} + +fn touch_workspace_file_path_for_manifest( + manifest: &LocalWorkspaceManifest, + workspace: &str, + path: &Path, +) { + let root = Path::new(workspace); + if let Ok(relative) = path.strip_prefix(root) { + if let Some(path) = relative.to_str() { + manifest.touch_file(path); + } + } +} + /// A running (or just-finished) parallel subagent task, for the bottom tracker. struct SubAgent { task_id: String, agent: String, description: String, started: Instant, + ended: Option, tokens: u64, done: bool, + success: Option, } struct App { @@ -769,8 +929,16 @@ struct App { model_menu: Option, /// Active tab in the /model panel (0 = config; account tabs when signed in). model_tab: usize, - /// Custom LLM client to inject (Codex account); None uses config.acl creds. + /// Custom LLM client to inject for signed-in account tabs; None uses config.acl. llm_override: Option>, + /// Optional OS endpoint from config.acl; enables /login and /logout. + os_config: Option, + /// Restored OS login (from `~/.a3s/os-auth.json`, persisted across runs); + /// `None` = signed out. Loaded on startup, set by /login, cleared by /logout. + os_session: Option, + /// True while an OS access-token refresh is in flight (guards the BannerTick + /// trigger from spawning a second refresh before the first resolves). + os_refreshing: bool, /// Current model effort (index into EFFORT_LEVELS). effort: usize, /// `/effort` slider panel: temp selection while open. @@ -807,10 +975,26 @@ struct App { active_agents: usize, /// Parallel subagent tasks shown in the bottom tracker panel. subagents: Vec, + /// True once this turn used tools/planning/subagents that need a final + /// user-facing synthesis if the model stops without text afterwards. + turn_had_agent_activity: bool, + /// True once assistant text arrived after the latest tool/planning/subagent + /// activity in this turn. + turn_text_after_activity: bool, + /// Guard for the hidden ultracode continuation that turns raw workflow + /// results into a final answer. + ultracode_synthesis_inflight: bool, + /// At most one hidden synthesis continuation per user turn. + ultracode_synthesis_used: bool, /// Project instructions (CLAUDE.md/AGENT.md), injected into the system prompt. instructions: Option, /// Summary of earlier conversation after a manual `/compact` (reseed). compact_summary: Option, + /// Shared in-memory workspace file manifest, refreshed by a background watcher. + workspace_manifest: Arc, + workspace_manifest_rx: SharedManifestRx, + /// Manifest-backed workspace backend used by agent tools. + workspace_services: Arc, /// Brief rainbow-ribbon flourish on the input border when ultracode is picked. rainbow_until: Option, rainbow_frame: usize, @@ -847,8 +1031,8 @@ struct App { history_pos: Option, /// Model name reported by the provider (captured from the first turn). model: Option, - /// Cumulative tokens used this session. - total_tokens: usize, + /// Cumulative OUTPUT (generated) tokens this session — what `↓` reports. + output_tokens: usize, /// Accumulated streamed JSON args of the in-progress tool call, so the /// result line can show what the tool actually did (command/path/pattern). tool_args: String, @@ -876,11 +1060,14 @@ struct App { plan: Vec<(String, String, char, Color)>, // (id, content, glyph, colour) /// `/top` process panel: `Some(rows)` when open; `top_scroll` is the scroll /// offset and `top_sel` the highlighted (absolute) row index. - top: Option>, + top: Option>, top_scroll: usize, top_sel: usize, + /// `/top` agent drill-down: `Some(pid)` focuses one coding agent's process + /// subtree (the agent root + its descendants). `None` shows all processes. + top_focus: Option, /// Pending force-kill confirmation in `/top`: (pid, command label). - top_kill: Option<(String, String)>, + top_kill: Option<(u32, String)>, /// `/ide` file-tree + viewer panel (Some when open). ide: Option, /// `/git` full-screen panel (Some when open). @@ -915,6 +1102,12 @@ struct App { keymap: Keymap, } +impl App { + pub(crate) fn touch_workspace_file(&self, path: &str) { + self.workspace_manifest.touch_file(path); + } +} + impl Model for App { type Msg = Msg; @@ -923,9 +1116,14 @@ impl Model for App { let mut cmds = vec![cmd::cmd(|| async { Msg::UpdateCheck(check_latest_version().await) })]; + cmds.push(pump_manifest(self.workspace_manifest_rx.clone())); + // Heartbeat for EVERY session (fresh or resumed) — the BannerTick handler + // self-gates the mascot animation, but it's also the sole driver of the + // ultracode /effort confirm→apply and the idle auto-review. Resumed + // sessions used to start no heartbeat, so neither ever fired. + cmds.push(banner_tick()); if self.messages.is_empty() { self.viewport.set_content(&self.banner()); - cmds.push(banner_tick()); // start the mascot animation } else { // Resumed session — show the prior conversation, scrolled to the end. self.rebuild_viewport(); @@ -942,7 +1140,13 @@ impl Model for App { self.relayout(); self.textarea .set_width(width.saturating_sub((PAD + 2) as u16)); + // Re-wrap the live answer at the new width instead of discarding it + // (the old reset lost any text streamed before the resize). + let raw = self.streaming.raw_content().to_string(); self.streaming = StreamingMarkdown::new((width as usize).saturating_sub(PAD + 2)); + if !raw.is_empty() { + self.streaming.push(&raw); + } self.rebuild_viewport(); } @@ -966,7 +1170,7 @@ impl Model for App { self.quit_armed = Some(Instant::now()); self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" press Ctrl+C again to exit"), ); return None; @@ -1002,7 +1206,7 @@ impl Model for App { return Some(cmd::cmd(move || async move { let _ = tokio::process::Command::new("kill") .arg("-9") - .arg(&pid) + .arg(pid.to_string()) .output() .await; Msg::TopData(fetch_top().await) // refresh after kill @@ -1013,9 +1217,19 @@ impl Model for App { } return None; } - let last = self.top.as_ref().map_or(0, |r| r.len()).saturating_sub(1); + let rows = self.top_rows(); + let last = rows.len().saturating_sub(1); match key.code { - KeyCode::Esc => self.top = None, + // Esc backs out of an agent focus first, then closes. + KeyCode::Esc => { + if self.top_focus.is_some() { + self.top_focus = None; + self.top_sel = 0; + self.top_scroll = 0; + } else { + self.top = None; + } + } KeyCode::Up | KeyCode::Char('k') => { self.top_sel = self.top_sel.saturating_sub(1) } @@ -1024,14 +1238,23 @@ impl Model for App { } KeyCode::PageUp => self.top_sel = self.top_sel.saturating_sub(10), KeyCode::PageDown => self.top_sel = (self.top_sel + 10).min(last), - // Enter asks to force-kill the highlighted process. - KeyCode::Enter => { - let info = self - .top - .as_ref() - .and_then(|rs| rs.get(self.top_sel)) - .map(|r| (r.pid.clone(), r.cmd.clone())); - self.top_kill = info; + // Enter / → drills into the selected coding agent's + // process subtree (its child processes). + KeyCode::Enter | KeyCode::Right => { + if self.top_focus.is_none() { + if let Some(row) = rows.get(self.top_sel) { + if row.agent.is_some() { + self.top_focus = Some(row.pid); + self.top_sel = 0; + self.top_scroll = 0; + } + } + } + } + // Shift+K asks to force-kill the highlighted process. + KeyCode::Char('K') => { + self.top_kill = + rows.get(self.top_sel).map(|r| (r.pid, r.command.clone())); } _ => {} } @@ -1052,11 +1275,10 @@ impl Model for App { if self.state == State::Awaiting { return self.handle_approval_key(&key); } - // /model picker takes keys while open. + // /model picker takes keys while open — consume EVERY key so + // nothing leaks to the hidden input box behind the overlay. if self.model_menu.is_some() { - if let Some(result) = self.handle_model_key(&key) { - return result; - } + return self.handle_model_key(&key).unwrap_or(None); } // /effort slider takes keys while open. if let Some(sel) = self.effort_panel { @@ -1096,7 +1318,7 @@ impl Model for App { self.rebuild_viewport(); self.push_line( &Style::new() - .fg(Color::Green) + .fg(TN_GREEN) .render(&format!(" ◆ code theme: {}", THEMES[sel].name)), ); } @@ -1126,10 +1348,9 @@ impl Model for App { return None; } // /relay picker takes keys while open. + // /relay picker: consume EVERY key so none leaks to the input. if self.relay_menu.is_some() { - if let Some(result) = self.handle_relay_key(&key) { - return result; - } + return self.handle_relay_key(&key).unwrap_or(None); } // Shift+End jumps to the latest output and resumes auto-follow. if key.code == KeyCode::End && key.modifiers.contains(KeyModifiers::SHIFT) { @@ -1159,7 +1380,7 @@ impl Model for App { } // Esc interrupts the in-progress run (input stays usable otherwise). if self.state == State::Streaming && key.code == KeyCode::Esc { - self.push_line(&Style::new().fg(Color::Yellow).render(" ⎋ interrupting…")); + self.push_line(&Style::new().fg(TN_YELLOW).render(" ⎋ interrupting…")); let session = self.session.clone(); return Some(cmd::cmd(move || async move { session.cancel().await; @@ -1228,15 +1449,30 @@ impl Model for App { } Msg::StreamError(e) => { - self.push_line(&Style::new().fg(Color::Red).render(&format!(" error: {e}"))); + self.push_line(&Style::new().fg(TN_RED).render(&format!(" error: {e}"))); + self.loop_remaining = 0; // a failed turn stops the /loop self.finish(); + // Don't strand messages queued while this turn was starting. + return self.drain_queue(); + } + + Msg::WorkspaceManifest(snapshot) => { + self.files = snapshot.file_paths(); + self.file_sel = self.file_sel.min(self.files.len().saturating_sub(1)); + return Some(pump_manifest(self.workspace_manifest_rx.clone())); + } + + Msg::WorkspaceManifestStopped => { + let snapshot = self.workspace_manifest.snapshot(); + self.files = snapshot.file_paths(); + self.file_sel = self.file_sel.min(self.files.len().saturating_sub(1)); } Msg::Interrupted => { // Esc force-aborted the turn: keep partial output, drop the // stream (finish() clears rx so late events are ignored), idle. self.finalize_streaming(); - self.push_line(&Style::new().fg(Color::Yellow).render(" ⎋ interrupted")); + self.push_line(&Style::new().fg(TN_YELLOW).render(" ⎋ interrupted")); self.loop_remaining = 0; // Esc also stops a /loop self.finish(); return self.drain_queue(); @@ -1245,27 +1481,11 @@ impl Model for App { Msg::Agent(event) => return self.on_agent_event(*event), Msg::StreamEnded => { + // Channel closed without a normal End event (abnormal close). if self.state == State::Streaming { self.finalize_streaming(); - self.completed += 1; } - self.finish(); - // /loop: auto-continue until the agent says DONE, the cap is hit, - // or Esc. Queued user messages take priority. - if self.loop_remaining > 0 && self.queue.is_empty() { - self.loop_remaining -= 1; - let n = self.loop_remaining; - self.push_line( - &Style::new() - .fg(Color::BrightBlack) - .render(&format!(" ↻ loop ({n} left · Esc to stop)")), - ); - return Some(cmd::msg(Msg::Submit( - "Continue. If the task is fully complete, reply DONE and stop.".to_string(), - ))); - } - // Run the next queued message (submitted while busy), if any. - return self.drain_queue(); + return self.complete_turn(); } Msg::SpinnerTick => { @@ -1345,18 +1565,32 @@ impl Model for App { }); return Some(cmd::batch(vec![banner_tick(), review])); } + // Keep the OS access token fresh: refresh proactively before it + // expires so the agent's $A3S_OS_TOKEN never goes stale mid-session. + if !self.os_refreshing { + if let Some(s) = &self.os_session { + if crate::a3s_os::needs_refresh(s) { + self.os_refreshing = true; + let session = s.clone(); + let refresh = cmd::cmd(move || async move { + Msg::OsRefreshed( + crate::a3s_os::refresh_session(&session) + .await + .map_err(|e| e.to_string()), + ) + }); + return Some(cmd::batch(vec![banner_tick(), refresh])); + } + } + } return Some(banner_tick()); } Msg::AutoReview(text) => { if !text.trim().is_empty() { // Dim + unobtrusive — it's a passive side note, not output. - let dim = |s: &str| { - format!( - " {}", - Style::new().fg(Color::BrightBlack).italic().render(s) - ) - }; + let dim = + |s: &str| format!(" {}", Style::new().fg(TN_GRAY).italic().render(s)); let mut lines = vec![dim("⟳ inactivity review")]; lines.extend(text.trim().lines().map(dim)); self.push_line(&lines.join("\n")); @@ -1368,7 +1602,7 @@ impl Model for App { if summary.trim().is_empty() { self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(" compaction failed (empty summary)"), ); return None; @@ -1382,23 +1616,23 @@ impl Model for App { Ok((s, _)) => { self.session = Arc::new(s); self.messages.clear(); - self.total_tokens = 0; + self.output_tokens = 0; self.last_prompt_tokens = 0; self.push_line( &Style::new() - .fg(Color::Green) + .fg(TN_GREEN) .bold() .render(" ✦ context compacted — continuing from this summary:"), ); self.push_line(&gutter( - Color::Cyan, + TN_CYAN, self.compact_summary.as_deref().unwrap_or(""), )); self.rebuild_viewport(); } Err(e) => self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(&format!(" compaction failed: {e}")), ), } @@ -1427,7 +1661,7 @@ impl Model for App { if !approved { self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(&format!(" ⎿ denied {label}")), ); } @@ -1450,7 +1684,7 @@ impl Model for App { Msg::ShellOutput(text) => { let body = text.lines().take(40).collect::>().join("\n"); - self.push_line(&gutter(Color::BrightBlack, body.trim_end())); + self.push_line(&gutter(TN_GRAY, body.trim_end())); } Msg::UpdatePlan(latest) => { @@ -1460,7 +1694,7 @@ impl Model for App { match latest { None => self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" couldn't reach the release server — try again later"), ), Some(l) if crate::update::version_ge(current, &l) => self.push_line( @@ -1481,13 +1715,54 @@ impl Model for App { ))); return Some(cmd::quit()); } - self.push_line(&Style::new().fg(Color::BrightBlack).render(&format!( + self.push_line(&Style::new().fg(TN_GRAY).render(&format!( " → a3s {l} available — download: https://github.com/A3S-Lab/Cli/releases/latest" ))); } } } + Msg::OsLogin(result) => match result { + Ok(label) => { + // The browser flow already saved to disk; load it into memory + // and rebuild so the login-gated skill activates this run. + self.os_session = self + .os_config + .as_ref() + .and_then(crate::a3s_os::current_session); + if let Some(s) = &self.os_session { + crate::a3s_os::export_os_env(s); + } + self.refresh_after_auth(); + self.push_line(&Style::new().fg(TN_GREEN).render(&format!( + " ✓ signed in to OS as {label} · capabilities skill active" + ))); + } + Err(error) => self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" login failed: {error}")), + ), + }, + + Msg::OsRefreshed(result) => { + self.os_refreshing = false; + match result { + Ok(session) => { + // Re-export the fresh token so the agent's $A3S_OS_TOKEN + // stays valid; no session rebuild needed (the skill reads + // the env var at call time). Stay quiet — it's routine. + crate::a3s_os::export_os_env(&session); + self.os_session = Some(session); + } + Err(_) => { + // Leave the existing session; the next BannerTick retries + // while it's still within the refresh window, and /login + // remains the fallback once it truly expires. + } + } + } + Msg::SideNote(text) => { if let Some((q, _)) = self.btw.take() { self.btw = Some((q, Some(text.trim().to_string()))); @@ -1543,8 +1818,8 @@ impl Model for App { if let Some(ide) = &self.ide { return self.render_ide(ide); } - if let Some(rows) = &self.top { - return self.render_top_panel(rows); + if self.top.is_some() { + return self.render_top_panel(); } let width = self.width as usize; let viewport_view = self.viewport.view(); @@ -1554,9 +1829,9 @@ impl Model for App { let (sym, icolor, border): (&str, Color, Color) = if self.shell_mode { ("!", Color::Rgb(255, 105, 180), Color::Rgb(255, 105, 180)) } else if inp.starts_with("/btw") { - ("❯", Color::Yellow, Color::Yellow) + ("❯", TN_YELLOW, TN_YELLOW) } else { - ("❯", ACCENT, Color::BrightBlack) + ("❯", ACCENT, TN_GRAY) }; // Brief rainbow ribbon on BOTH input borders right after picking // ultracode; otherwise plain bottom + effort-chip top. @@ -1604,7 +1879,7 @@ impl Model for App { "{}{} {}{} {}", " ".repeat(PAD), Style::new().fg(border).render(&"─".repeat(left)), - Style::new().fg(Color::BrightBlack).render(&ctxlabel), + Style::new().fg(TN_GRAY).render(&ctxlabel), Style::new().fg(ACCENT).bold().render(&elabel), Style::new().fg(border).render("──"), ) @@ -1637,11 +1912,12 @@ impl Model for App { let working = shimmer("Working…", self.blink_tick as usize); let mut tail = String::new(); if let Some(t0) = self.stream_started { - // Live token estimate: finalized total + ~chars/4 for the - // in-flight reasoning + answer (snaps to exact usage on End). - let est = self.total_tokens - + self.streaming.raw_content().chars().count() / 4 - + self.thinking.chars().count() / 4; + // Live output estimate: finalized output tokens + a + // CJK-aware estimate of the in-flight reasoning + answer + // (snaps to exact completion usage on End). + let est = self.output_tokens + + estimate_tokens(self.streaming.raw_content()) + + estimate_tokens(&self.thinking); tail.push_str(&format!(" ({}", fmt_elapsed(t0.elapsed()))); if est > 0 { tail.push_str(&format!(" · ↓ {} tokens", humanize(est))); @@ -1682,7 +1958,7 @@ impl Model for App { // Bottom status bar (Claude-style, two lines): // dir git:(branch) ( context) ctx:N% [+ live chips] // ⏵⏵ mode on (shift+tab to cycle) · … - let dim = |s: &str| Style::new().fg(Color::BrightBlack).render(s); + let dim = |s: &str| Style::new().fg(TN_GRAY).render(s); let dir = self.cwd.rsplit('/').next().unwrap_or(&self.cwd); let mut line1 = format!(" {}", Style::new().fg(ACCENT).bold().render(dir)); if let Some(b) = &self.branch { @@ -1707,9 +1983,20 @@ impl Model for App { } if self.context_limit > 0 { let pct = (self.last_prompt_tokens * 100 / self.context_limit as usize).min(100); - line1.push_str(&format!(" {}", dim(&format!("ctx:{pct}%")))); - } else if self.total_tokens > 0 { - line1.push_str(&format!(" {}", dim(&format!("{} tok", self.total_tokens)))); + // Color by fill so the approach to the ~85% auto-compact point is visible. + let c = if pct >= 85 { + TN_RED + } else if pct >= 70 { + TN_YELLOW + } else { + TN_GRAY + }; + line1.push_str(&format!( + " {}", + Style::new().fg(c).render(&format!("ctx:{pct}%")) + )); + } else if self.output_tokens > 0 { + line1.push_str(&format!(" {}", dim(&format!("{} tok", self.output_tokens)))); } // Live chips, only when active. if let Some(g) = &self.goal { @@ -1752,9 +2039,11 @@ impl Model for App { }; let tasks = self.task_lines(); let task_block = tasks.join("\n"); - // Plan/TODO panel + parallel-subagent tracker pinned above the input. + // Plan/TODO panel stays pinned above the input. let plan = self.plan_lines(); let plan_block = plan.join("\n"); + // Parallel-subagent tracker is pinned at the very bottom, below the + // status bar (not above the input) so it doesn't push the prompt around. let subs = self.subagent_lines(); let sub_block = subs.join("\n"); let composed = Layout::vertical() @@ -1762,12 +2051,12 @@ impl Model for App { .item(&spacer, Constraint::Fixed(1)) .item(&activity, Constraint::Fixed(1)) .item(&plan_block, Constraint::Fixed(plan.len() as u16)) - .item(&sub_block, Constraint::Fixed(subs.len() as u16)) .item(&top_separator, Constraint::Fixed(1)) .item(&input_view, Constraint::Fixed(self.input_height())) .item(&separator, Constraint::Fixed(1)) .item(&status1, Constraint::Fixed(1)) .item(&status2, Constraint::Fixed(1)) + .item(&sub_block, Constraint::Fixed(subs.len() as u16)) .item(&task_block, Constraint::Fixed(tasks.len() as u16)) .render(self.height); @@ -1806,9 +2095,9 @@ impl Model for App { { return None; } - // Below the input: separator + 2 status lines + the bottom task panel. - // The input itself spans `input_height` rows; the cursor sits on its row. - let below = 3 + self.task_lines().len() as u16; + // Below the input: separator + 2 status lines + the subagent panel + + // the task panel. The input spans `input_height` rows; cursor on its row. + let below = 3 + self.subagent_lines().len() as u16 + self.task_lines().len() as u16; let row = self.height.saturating_sub(below + self.input_height()) + self.textarea.cursor_row() as u16; let col = (PAD + 2) as u16 + self.textarea.cursor_display_col() as u16; // PAD + "❯ " @@ -1866,12 +2155,115 @@ impl App { let cmd0 = trimmed.split_whitespace().next().unwrap_or(""); if IDLE_ONLY.contains(&cmd0) { self.textarea.clear(); - self.push_line(&Style::new().fg(Color::Yellow).render(&format!( + self.push_line(&Style::new().fg(TN_YELLOW).render(&format!( " {cmd0} is unavailable while a turn is running — press Esc to stop first" ))); return None; } } + if let Some(rest) = trimmed.strip_prefix("/login") { + if !rest.is_empty() && !rest.starts_with(char::is_whitespace) { + // e.g. "/login-token" is not the /login command. + } else { + self.textarea.clear(); + let Some(os_config) = self.os_config.clone() else { + self.push_line(&format!( + "{}\n{}\n{}\n{}", + Style::new() + .fg(TN_YELLOW) + .render(" /login needs an OS endpoint, but none is configured."), + Style::new().fg(TN_GRAY).render( + " Add it to ~/.a3s/config.acl (or your project's .a3s/config.acl):" + ), + Style::new() + .fg(TN_CYAN) + .render(" os = \"https://your-os-host.example.com\""), + Style::new() + .fg(TN_GRAY) + .render(" then restart a3s code and run /login again."), + )); + return None; + }; + let token = rest.trim(); + if !token.is_empty() { + match crate::a3s_os::login_with_token(&os_config, token) { + Ok(session) => { + let label = session.display_label(); + crate::a3s_os::export_os_env(&session); + self.os_session = Some(session); + self.refresh_after_auth(); + self.push_line(&Style::new().fg(TN_GREEN).render(&format!( + " ✓ signed in to OS as {label} · capabilities skill active" + ))); + } + Err(error) => self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" login failed: {error}")), + ), + } + return None; + } + + // Already signed in (restored from a previous run) → no need to + // re-authenticate; tell the user how to switch instead. + if let Some(s) = &self.os_session { + self.push_line(&Style::new().fg(TN_GRAY).render(&format!( + " already signed in to OS as {} · /logout to switch accounts", + s.display_label() + ))); + return None; + } + + self.push_line( + &Style::new() + .fg(TN_GRAY) + .render(" opening OS login in your browser…"), + ); + return Some(cmd::cmd(move || async move { + let result = crate::a3s_os::login_via_browser(os_config) + .await + .map(|session| session.display_label()) + .map_err(|error| error.to_string()); + Msg::OsLogin(result) + })); + } + } + if trimmed == "/logout" { + self.textarea.clear(); + let Some(os_config) = self.os_config.clone() else { + self.push_line(&Style::new().fg(TN_YELLOW).render( + " configure `os = \"https://...\"` in .a3s/config.acl to enable /logout", + )); + return None; + }; + match crate::a3s_os::logout(&os_config) { + Ok(true) => { + self.os_session = None; + crate::a3s_os::remove_capability_skill_dir(); + crate::a3s_os::clear_os_env(); + self.refresh_after_auth(); + self.push_line( + &Style::new() + .fg(TN_GREEN) + .render(" ✓ signed out from OS · capabilities skill removed"), + ); + } + Ok(false) => { + self.os_session = None; + crate::a3s_os::remove_capability_skill_dir(); + crate::a3s_os::clear_os_env(); + self.refresh_after_auth(); + self.push_line(&Style::new().fg(TN_GRAY).render(" no OS login was stored")); + } + Err(error) => self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" logout failed: {error}")), + ), + } + return None; + } // `/btw ` runs a background side-thread (separate ephemeral // session, the main conversation as context) without disturbing the // current turn; its answer arrives as a side note. @@ -1879,11 +2271,7 @@ impl App { let q = rest.trim().to_string(); self.textarea.clear(); if q.is_empty() { - self.push_line( - &Style::new() - .fg(Color::BrightBlack) - .render(" usage: /btw "), - ); + self.push_line(&Style::new().fg(TN_GRAY).render(" usage: /btw ")); return None; } self.btw = Some((q.clone(), None)); @@ -1926,24 +2314,24 @@ impl App { if g.is_empty() { match &self.goal { Some(cur) => self.push_line(&gutter( - Color::Cyan, + TN_CYAN, &format!("🎯 goal: {cur} (/goal clear to remove)"), )), None => self.push_line( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(" usage: /goal "), ), } } else if g == "clear" { self.goal = None; - self.push_line(&Style::new().fg(Color::BrightBlack).render(" goal cleared")); + self.push_line(&Style::new().fg(TN_GRAY).render(" goal cleared")); return None; } else { // Set the persistent goal AND start working toward it now (the // goal is prepended to this and every later prompt). self.goal = Some(g.to_string()); - self.push_line(&gutter(Color::Cyan, &format!("🎯 goal set: {g}"))); + self.push_line(&gutter(TN_CYAN, &format!("🎯 goal set: {g}"))); return Some(cmd::msg(Msg::Submit(g.to_string()))); } return None; @@ -1954,7 +2342,7 @@ impl App { self.textarea.clear(); if task.is_empty() { self.push_line( - &Style::new().fg(Color::BrightBlack).render( + &Style::new().fg(TN_GRAY).render( " usage: /loop (auto-continues up to 8 turns; Esc stops)", ), ); @@ -1973,6 +2361,23 @@ impl App { self.queue.clear(); self.completed = 0; self.textarea.clear(); + // Actually reset the conversation, not just the screen: swap in a + // fresh session (new id, no history, no carried compact summary) + // and zero the token/ctx counters. /clear is idle-only (guarded + // above), so replacing the session is safe. Set the id first + // (rebuild_session keys off it) and revert it if the rebuild fails + // so id and session never desync. + let prev_id = std::mem::replace(&mut self.session_id, new_session_id()); + let model = self.model.clone(); + match self.rebuild_session(model.as_deref()) { + Ok((s, _)) => { + self.session = Arc::new(s); + self.compact_summary = None; + self.output_tokens = 0; + self.last_prompt_tokens = 0; + } + Err(_) => self.session_id = prev_id, + } self.relayout(); self.rebuild_viewport(); return None; @@ -2000,35 +2405,45 @@ impl App { if self.state != State::Idle { self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" finish the current turn before compacting"), ); return None; } let history = self.session.history(); if history.is_empty() { - self.push_line( - &Style::new() - .fg(Color::BrightBlack) - .render(" nothing to compact yet"), - ); + self.push_line(&Style::new().fg(TN_GRAY).render(" nothing to compact yet")); return None; } self.compacting = Some(Instant::now()); // progress bar + input lock let agent = self.agent.clone(); let workspace = self.cwd.clone(); + // Re-compacting must subsume the PRIOR summary — it lives in the + // system prompt, not in `history`, so without this everything + // before the last /compact would be dropped from the new summary. + let prompt = match &self.compact_summary { + Some(prev) => format!( + "An earlier part of this conversation was already condensed into this \ + summary:\n\n{prev}\n\nProduce a SINGLE updated summary that fully \ + incorporates the summary above AND the conversation history below, so a \ + fresh session can continue seamlessly: the goal, key decisions, \ + files/commands touched, current state, and the immediate next steps. Be \ + thorough but compact." + ), + None => "Summarize this conversation so a fresh session can continue \ + seamlessly: the goal, key decisions, files/commands touched, current \ + state, and the immediate next steps. Be thorough but compact." + .to_string(), + }; return Some(cmd::cmd(move || async move { let conf = a3s_code_core::hitl::ConfirmationPolicy::enabled() .with_timeout(500, TimeoutAction::Reject); - let prompt = "Summarize this conversation so a fresh session can continue \ - seamlessly: the goal, key decisions, files/commands touched, current \ - state, and the immediate next steps. Be thorough but compact."; let mut summary = String::new(); if let Ok(sess) = agent.session( workspace, Some(SessionOptions::new().with_confirmation_policy(conf)), ) { - if let Ok((mut rx, _j)) = sess.stream(prompt, Some(&history)).await { + if let Ok((mut rx, _j)) = sess.stream(&prompt, Some(&history)).await { while let Some(ev) = rx.recv().await { match ev { AgentEvent::TextDelta { text } => summary.push_str(&text), @@ -2070,7 +2485,7 @@ impl App { Some(p) => self.open_config_in_ide(&p), None => self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" could not locate a home directory for ~/.a3s/config.acl"), ), } @@ -2091,6 +2506,7 @@ impl App { self.top = Some(Vec::new()); self.top_scroll = 0; self.top_sel = 0; + self.top_focus = None; return Some(cmd::cmd(|| async { Msg::TopData(fetch_top().await) })); } "/ide" => { @@ -2109,7 +2525,7 @@ impl App { "/plugin" | "/plugins" => { self.textarea.clear(); if self.skills.is_empty() { - self.push_line(&Style::new().fg(Color::BrightBlack).render( + self.push_line(&Style::new().fg(TN_GRAY).render( " no skills/plugins found (~/.claude/skills, ~/.codex/skills, ~/.claude/plugins)", )); } else { @@ -2132,14 +2548,14 @@ impl App { } else { "🖱 wheel-scroll off — native text selection / copy enabled (PgUp/PgDn still scroll)" }; - self.push_line(&Style::new().fg(Color::Cyan).render(&format!(" {msg}"))); + self.push_line(&Style::new().fg(TN_CYAN).render(&format!(" {msg}"))); return None; } "/workflow" => { self.textarea.clear(); match self.last_workflow.clone() { Some(doc) => self.open_readonly_in_ide("dynamic-workflow.md", &doc), - None => self.push_line(&Style::new().fg(Color::BrightBlack).render( + None => self.push_line(&Style::new().fg(TN_GRAY).render( " no dynamic workflow yet — run an ultracode task that fans out via parallel_task", )), } @@ -2147,14 +2563,29 @@ impl App { } "/reload" => { self.textarea.clear(); - // Hot-reload: re-discover skill dirs + re-parse (new plugins show up). + // Hot-reload: re-discover skill dirs, refresh the UI catalog, + // and rebuild the session so the core skill registry and + // next Claude/system prompt see the same skills. let dirs = agent_skill_dirs(&self.cwd); self.skills = load_skills(&dirs); self.skill_count = count_skill_files(&dirs); - self.push_line(&Style::new().fg(Color::Green).render(&format!( - " ↻ reloaded — {} skills available in the / menu", - self.skills.len() - ))); + let model = self.model.clone(); + match self.rebuild_session(model.as_deref()) { + Ok((session, _)) => { + self.session = Arc::new(session); + self.push_line(&Style::new().fg(TN_GREEN).render(&format!( + " ↻ reloaded — {} skills available", + self.skills.len() + ))); + } + Err(error) => { + self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" reload failed: {error}")), + ); + } + } return None; } "/update" => { @@ -2225,7 +2656,7 @@ impl App { seq: self.seq, text: trimmed.to_string(), }); - self.push_line(&Style::new().fg(Color::BrightBlack).render(" ⋯ queued")); + self.push_line(&Style::new().fg(TN_GRAY).render(" ⋯ queued")); self.relayout(); None } @@ -2239,7 +2670,7 @@ impl App { if !clipboard_image_to(&dest) { self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" no image in clipboard (Ctrl+V pastes a copied/screenshot image)"), ); return; @@ -2265,43 +2696,63 @@ impl App { } fn start_stream(&mut self, prompt: String) -> Option> { + self.start_stream_inner(prompt.clone(), prompt, true, true, false) + } + + fn start_ultracode_synthesis( + &mut self, + prompt: String, + display_task: String, + ) -> Option> { + self.ultracode_synthesis_used = true; + self.push_line(&Style::new().fg(TN_GRAY).render(" ⇉ synthesizing results…")); + self.start_stream_inner(prompt, display_task, false, false, true) + } + + fn start_stream_inner( + &mut self, + prompt: String, + display_task: String, + clear_turn_artifacts: bool, + include_attachments: bool, + synthesis: bool, + ) -> Option> { self.streaming.clear(); self.got_delta = false; // track if this turn streamed any text deltas + self.turn_had_agent_activity = false; + self.turn_text_after_activity = false; + self.ultracode_synthesis_inflight = synthesis; + if !synthesis { + self.ultracode_synthesis_used = false; + } self.last_paint = None; // first delta of the turn paints immediately self.viewport.set_auto_scroll(true); // sending a message jumps to latest - self.plan.clear(); // fresh plan per turn; planning events refill it - self.running_task = Some(prompt.clone()); + if clear_turn_artifacts { + self.plan.clear(); // fresh plan per user turn; planning events refill it + self.subagents.clear(); // keep completed agents visible until the next user turn + } + self.running_task = Some(display_task); self.state = State::Streaming; self.relayout(); self.stream_started = Some(Instant::now()); self.spinner.start(); self.rebuild_viewport(); let session = self.session.clone(); - let atts = std::mem::take(&mut self.pending_images); + let atts = if include_attachments { + std::mem::take(&mut self.pending_images) + } else { + Vec::new() + }; // Keep the agent aligned with the standing goal (display stays clean). let prompt = match &self.goal { Some(g) => format!("[Ongoing goal: {g}]\n\n{prompt}"), None => prompt, }; - // ultracode: raise the dynamic-workflow steering to TURN-level salience. - // The system-prompt guideline alone gets ignored by some models (they - // fan out via direct parallel_task, or answer inline); a per-turn nudge - // makes them author a `program` workflow script — which now fans out for - // real (PTC dispatches on the multi-threaded runtime since core 4.2.6, - // the reason the original prefix was safe to bring back). Verified with a - // real LLM: the model emits a correct run(ctx) script calling parallel_task. - let prompt = if self.effort == ULTRACODE { - format!( - "{prompt}\n\n[ultracode] Tackle this as a dynamic workflow. For the \ - independent parts, call the `program` tool with a JavaScript script \ - whose `async function run(ctx, inputs)` fans them out via \ - `ctx.tool(\"parallel_task\", {{ tasks: [...] }})`, keeps all task/\ - parallel_task delegation INSIDE the script, then aggregates and \ - returns. After it runs, synthesize the results." - ) - } else { - prompt - }; + // ultracode no longer rewrites the user turn. Whether a turn plans and + // fans out is decided by the core's message-gated planning + // (PlanningMode::Auto) plus the `parallel_task` tool description — not an + // unconditional per-turn imperative, which made even "hi" trigger a plan + // and workspace exploration. Some(cmd::batch(vec![ cmd::cmd(move || async move { let res = if atts.is_empty() { @@ -2326,11 +2777,45 @@ impl App { self.start_stream(next.text) } + /// Shared turn-completion: count the turn, run any ultracode synthesis, go + /// idle, then either continue a `/loop` or drain the next queued message. + /// Called from BOTH the normal `AgentEvent::End` arm (the happy path, which + /// returns without re-pumping so `StreamEnded` never fires) and the + /// `StreamEnded` channel-closed arm — previously this lived only in + /// `StreamEnded`, so on success the queue never drained and `/loop` ran once. + fn complete_turn(&mut self) -> Option> { + if self.state == State::Streaming { + self.completed += 1; + } + let synthesis = self.prepare_ultracode_synthesis(); + self.finish(); + if let Some((prompt, display_task)) = synthesis { + return self.start_ultracode_synthesis(prompt, display_task); + } + // /loop: auto-continue until the agent says DONE, the cap is hit, or Esc. + // Queued user messages take priority. + if self.loop_remaining > 0 && self.queue.is_empty() { + self.loop_remaining -= 1; + let n = self.loop_remaining; + self.push_line( + &Style::new() + .fg(TN_GRAY) + .render(&format!(" ↻ loop ({n} left · Esc to stop)")), + ); + return Some(cmd::msg(Msg::Submit( + "Continue. If the task is fully complete, reply DONE and stop.".to_string(), + ))); + } + // Run the next queued message (submitted while busy), if any. + self.drain_queue() + } + fn on_agent_event(&mut self, event: AgentEvent) -> Option> { // After an interrupt, rx is cleared — ignore any late buffered events. self.rx.as_ref()?; match event { AgentEvent::TextDelta { text } => { + self.mark_assistant_text(&text); self.got_delta = true; self.streaming.push(&text); self.update_viewport_with_stream(); @@ -2342,6 +2827,7 @@ impl App { AgentEvent::ToolStart { name, .. } => { // Finalize any assistant text; show the tool live with a blinking // dot. The final "• action / └ result" lands on ToolEnd. + self.mark_agent_activity(); self.finalize_streaming(); self.tool_args.clear(); self.tool_output.clear(); @@ -2362,6 +2848,7 @@ impl App { metadata, .. } => { + self.mark_agent_activity(); self.running_tool = None; self.active_tools = self.active_tools.saturating_sub(1); let args: Option = serde_json::from_str(&self.tool_args).ok(); @@ -2385,6 +2872,7 @@ impl App { description, .. } => { + self.mark_agent_activity(); self.finalize_streaming(); self.active_agents += 1; // Track it in the live bottom panel instead of a transcript line. @@ -2393,23 +2881,29 @@ impl App { agent, description, started: Instant::now(), + ended: None, tokens: 0, done: false, + success: None, }); self.relayout(); } AgentEvent::SubagentProgress { task_id, metadata, .. } => { - // Pull a token count from the progress metadata, if present. + self.mark_agent_activity(); + // Per-child OUTPUT tokens for the panel's `↓`. Each child turn-end + // reports that turn's completion_tokens once, so SUM them across + // turns (tool-event progress carries no usage, so it won't add). + // The old code took max(total_tokens), i.e. the largest single + // turn's prompt+completion ≈ the child's context size, not output. let toks = metadata - .get("tokens") - .or_else(|| metadata.get("total_tokens")) - .or_else(|| metadata.pointer("/usage/total_tokens")) + .get("completion_tokens") + .or_else(|| metadata.pointer("/usage/completion_tokens")) .and_then(|v| v.as_u64()); if let Some(s) = self.subagents.iter_mut().find(|s| s.task_id == task_id) { if let Some(t) = toks { - s.tokens = s.tokens.max(t); + s.tokens += t; } } } @@ -2420,14 +2914,18 @@ impl App { success, .. } => { + self.mark_agent_activity(); self.active_agents = self.active_agents.saturating_sub(1); - // Drop it from the live panel; record the result in the transcript. - self.subagents.retain(|s| s.task_id != task_id); + if let Some(s) = self.subagents.iter_mut().find(|s| s.task_id == task_id) { + s.done = true; + s.success = Some(success); + s.ended = Some(Instant::now()); + } self.relayout(); let (mark, color) = if success { - ("✓", Color::Green) + ("✓", TN_GREEN) } else { - ("✗", Color::Red) + ("✗", TN_RED) }; let snippet = output.lines().next().unwrap_or("").trim(); let snippet = truncate(snippet, self.width.saturating_sub(20) as usize); @@ -2440,6 +2938,20 @@ impl App { } ))); } + AgentEvent::ContextCompacted { + before_messages, + after_messages, + percent_before, + .. + } => { + // The core auto-compacted mid-turn (pruned tool outputs + summarized + // old messages). The next turn's prompt reflects the smaller context, + // so ctx% self-corrects on the following End — just surface a note. + let pct = (percent_before * 100.0).round() as u32; + self.push_line(&Style::new().fg(TN_GRAY).italic().render(&format!( + " ✦ context auto-compacted at {pct}% · {before_messages} → {after_messages} messages" + ))); + } AgentEvent::ConfirmationRequired { tool_id, tool_name, @@ -2489,10 +3001,20 @@ impl App { // text: a mid-turn finalize (e.g. a tool call) empties the buffer, // so End.text (the full message) would be appended a second time. if !self.got_delta && !text.is_empty() { + self.mark_assistant_text(&text); self.streaming.push(&text); } self.finalize_streaming(); - self.total_tokens += usage.total_tokens; + // `↓` counts OUTPUT (generated) tokens. Summing total_tokens per + // turn re-counts the whole context every turn (the prompt is + // re-sent each round) and balloons far past what was generated. + // completion_tokens is the output; fall back to total-prompt if a + // provider omits it. + self.output_tokens += if usage.completion_tokens > 0 { + usage.completion_tokens + } else { + usage.total_tokens.saturating_sub(usage.prompt_tokens) + }; // Latest prompt size = how full the context window is (for ctx%). if usage.prompt_tokens > 0 { self.last_prompt_tokens = usage.prompt_tokens; @@ -2500,34 +3022,40 @@ impl App { if self.model.is_none() { self.model = meta.and_then(|m| m.response_model.or(m.request_model)); } - self.finish(); - return None; + // Count the turn, idle, then continue /loop or drain the queue. + return self.complete_turn(); } AgentEvent::Error { message } => { self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(&format!(" error: {message}")), ); + self.loop_remaining = 0; // a failed turn stops the /loop self.finish(); - return None; + // Don't strand messages queued while this turn was running. + return self.drain_queue(); } // Planning mode: capture the plan and live task-status updates for // the pinned TODO panel above the input. AgentEvent::PlanningEnd { plan, .. } => { + self.mark_agent_activity(); self.set_plan(&plan.steps); } AgentEvent::TaskUpdated { tasks, .. } => { + self.mark_agent_activity(); self.set_plan(&tasks); } // Per-step lifecycle also drives the panel, in case TaskUpdated is // sparse: a step turns ▶ on start and ✔/✗/⊘ on completion. AgentEvent::StepStart { step_id, .. } => { - self.set_task_status(&step_id, '▶', Color::Yellow); + self.mark_agent_activity(); + self.set_task_status(&step_id, '▶', TN_YELLOW); } AgentEvent::StepEnd { step_id, status, .. } => { + self.mark_agent_activity(); let (g, c) = task_status_style(status); self.set_task_status(&step_id, g, c); } @@ -2539,10 +3067,86 @@ impl App { self.rx.clone().map(pump) } + fn mark_agent_activity(&mut self) { + self.turn_had_agent_activity = true; + self.turn_text_after_activity = false; + } + + fn mark_assistant_text(&mut self, text: &str) { + if !text.trim().is_empty() { + self.turn_text_after_activity = true; + } + } + + fn prepare_ultracode_synthesis(&self) -> Option<(String, String)> { + if !needs_synthesis( + self.ultracode_synthesis_inflight, + self.ultracode_synthesis_used, + self.turn_had_agent_activity, + self.turn_text_after_activity, + ) { + return None; + } + + let user_task = self + .running_task + .as_deref() + .filter(|task| !task.trim().is_empty()) + .unwrap_or("the previous task"); + let mut prompt = format!( + "[synthesis]\n\ + The previous turn completed planning/tool/subagent work \ + but stopped without a final user-facing answer.\n\n\ + Original user task:\n{user_task}\n\n\ + Write the final answer now in the user's language. Synthesize the \ + completed work into a useful response. Do not call tools or start \ + more subagents unless it is strictly necessary to avoid an incorrect \ + answer. If a child run produced no text output, summarize the \ + available plan/status instead of exposing raw task metadata.\n" + ); + + if !self.plan.is_empty() { + prompt.push_str("\nPlan/status:\n"); + for (_, text, glyph, _) in &self.plan { + let status = match glyph { + '✔' => "done", + '▶' => "in progress", + '✗' => "failed", + _ => "pending", + }; + prompt.push_str(&format!("- [{status}] {text}\n")); + } + } + + if !self.subagents.is_empty() { + prompt.push_str("\nSubagents:\n"); + for agent in &self.subagents { + let status = match agent.success { + Some(true) => "done", + Some(false) => "failed", + None if agent.done => "done", + None => "unknown", + }; + prompt.push_str(&format!( + "- [{status}] {}: {}\n", + agent.agent, agent.description + )); + } + } + + if let Some(workflow) = &self.last_workflow { + prompt.push_str("\nLatest workflow artifact excerpt:\n"); + prompt.push_str(&truncate(workflow, 4000)); + prompt.push('\n'); + } + + Some((prompt, user_task.to_string())) + } + fn finalize_streaming(&mut self) { let rendered = self.streaming.view(); if !rendered.trim().is_empty() { - let block = gutter(Color::Green, &rendered); + let block = gutter(TN_GREEN, &rendered); // Safety net against duplicate output: skip if this exact block // already appeared in the last few messages (a re-finalize, or an // agent that re-emits earlier text — e.g. its preamble after a tool). @@ -2561,7 +3165,11 @@ impl App { self.running_task = None; self.active_tools = 0; self.active_agents = 0; + // Clear the parallel-subagent panel when the turn ends — it's a live + // progress tracker, so leaving completed agents pinned at the bottom once + // the work is done just clutters the idle screen. self.subagents.clear(); + self.ultracode_synthesis_inflight = false; self.relayout(); self.stream_started = None; self.spinner.stop(); @@ -2574,6 +3182,33 @@ impl App { self.rebuild_viewport(); } + /// Skill dirs for the session: the discovered Claude/Codex dirs plus the + /// login-gated built-in OS `a3s-os-capabilities` skill when signed in. + pub(crate) fn skill_dirs(&self) -> Vec { + let mut dirs = agent_skill_dirs(&self.cwd); + if self.os_session.is_some() { + if let Some(cfg) = &self.os_config { + if let Some(d) = crate::a3s_os::ensure_capability_skill_dir(cfg) { + dirs.push(d); + } + } + } + dirs + } + + /// After an OS login/logout, rebuild the session so the login-gated + /// skill loads/unloads immediately, and refresh the start-screen skill list. + fn refresh_after_auth(&mut self) { + if self.state == State::Idle { + if let Ok((s, _)) = self.rebuild_session(self.model.as_deref()) { + self.session = Arc::new(s); + } + } + let dirs = self.skill_dirs(); + self.skill_count = count_skill_files(&dirs); + self.skills = load_skills(&dirs); + } + /// Open `path` directly in the built-in IDE editor (tree rooted at its /// directory, file loaded, editor focused). Used by `/config` + first launch. fn open_config_in_ide(&mut self, path: &std::path::Path) { @@ -2611,55 +3246,8 @@ impl App { /// a readable plan of the fanned-out subtasks. Stored for `/workflow` and /// announced with a collapsed one-line message in the transcript. fn capture_workflow(&mut self, name: &str, args: Option<&serde_json::Value>) { - let (doc, label) = match name { - // The generated workflow script itself (the dynamic workflow). - "program" => { - let Some(src) = args - .and_then(|a| a.get("source")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - else { - return; - }; - ( - format!("# Dynamic workflow script\n\n```javascript\n{src}\n```\n"), - "dynamic workflow script · /workflow to view read-only".to_string(), - ) - } - // A direct fan-out (no script wrapper). - "parallel_task" | "task" => { - let Some(tasks) = args - .and_then(|a| a.get("tasks")) - .and_then(|t| t.as_array()) - .filter(|t| !t.is_empty()) - else { - return; - }; - let mut doc = format!( - "# Dynamic workflow\n\nFanned out {} parallel subagent task(s):\n\n", - tasks.len() - ); - for (i, t) in tasks.iter().enumerate() { - let desc = t - .get("description") - .and_then(|v| v.as_str()) - .unwrap_or("(task)"); - let prompt = t - .get("prompt") - .or_else(|| t.get("task")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - doc.push_str(&format!("## {}. {desc}\n\n{prompt}\n\n", i + 1)); - } - ( - doc, - format!( - "dynamic workflow · {} parallel tasks · /workflow to view read-only", - tasks.len() - ), - ) - } - _ => return, + let Some((doc, label)) = workflow_doc_for_tool(name, args) else { + return; }; self.last_workflow = Some(doc); // Collapsed indicator; the full artifact opens read-only via /workflow. @@ -2724,12 +3312,30 @@ impl App { self.last_paint = Some(Instant::now()); let mut blocks: Vec = self.messages.clone(); if !self.thinking.trim().is_empty() { - let body = indent(&format!("💭 {}", self.thinking.trim()), PAD); - blocks.push(Style::new().fg(Color::BrightBlack).italic().render(&body)); + // Lay out reasoning like every other message: pre-wrap to the content + // width and put the margin + "💭" OUTSIDE the dim style, one styled + // line at a time. The old `Style::render(&indent(…))` shoved the whole + // paragraph in as one line whose leading spaces sat *inside* the ANSI + // escape, so the viewport re-wrapped it to the screen edge (margins + // didn't line up) with uneven spacing. "💭 " is 3 display columns; + // continuation lines indent to match. + let dim = Style::new().fg(TN_GRAY).italic(); + let margin = " ".repeat(PAD); + let avail = (self.width as usize).saturating_sub(PAD + 3).max(8); + let body = wrap_words(self.thinking.trim(), avail) + .iter() + .enumerate() + .map(|(i, line)| { + let lead = if i == 0 { "💭 " } else { " " }; + format!("{margin}{}", dim.render(&format!("{lead}{line}"))) + }) + .collect::>() + .join("\n"); + blocks.push(body); } let rendered = self.streaming.view(); if !rendered.is_empty() { - blocks.push(gutter(Color::Green, &rendered)); + blocks.push(gutter(TN_GREEN, &rendered)); } // Currently-executing tool: "• Running …" with a blinking bullet. if let Some(name) = &self.running_tool { @@ -2741,11 +3347,7 @@ impl App { let arg = args.as_ref().and_then(arg_summary).unwrap_or_default(); let on = self.blink_tick % 8 < 4; // ~320ms on / 320ms off let dot = Style::new() - .fg(if on { - Color::Yellow - } else { - Color::BrightBlack - }) + .fg(if on { TN_YELLOW } else { TN_GRAY }) .bold() .render("•"); let m = " ".repeat(PAD); @@ -2758,12 +3360,12 @@ impl App { // Live stdout of the running tool — tail prefixed with "│" like Codex. if !self.tool_output.trim().is_empty() { let m = " ".repeat(PAD + 2); - let bar = Style::new().fg(Color::BrightBlack).render("│"); + let bar = Style::new().fg(TN_GRAY).render("│"); let tail: Vec<&str> = self.tool_output.lines().rev().take(12).collect(); let body = tail .into_iter() .rev() - .map(|l| format!("{m}{bar} {}", Style::new().fg(Color::BrightBlack).render(l))) + .map(|l| format!("{m}{bar} {}", Style::new().fg(TN_GRAY).render(l))) .collect::>() .join("\n"); blocks.push(body); @@ -2833,7 +3435,7 @@ impl App { let opts = ["Yes", "Yes, and don't ask again", "No"]; let mut menu = vec![pad_to( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .bold() .render(&format!(" ⏵ Allow {label}?")), width, @@ -2844,12 +3446,12 @@ impl App { menu.push(if i == self.approval_sel { Style::new().fg(Color::BrightWhite).bg(ACCENT).render(&raw) } else { - Style::new().fg(Color::White).render(&raw) + Style::new().fg(TN_FG).render(&raw) }); } menu.push(pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(" Enter select · ↑/↓ · 1–3 · Esc"), width, )); @@ -2922,6 +3524,7 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { let mut models: Vec = Vec::new(); let mut model_ctx: std::collections::HashMap = std::collections::HashMap::new(); let mut default_model: Option = None; + let mut os_config: Option = None; if let Ok(cfg) = a3s_code_core::config::CodeConfig::from_file(std::path::Path::new(&config_path)) { @@ -2931,12 +3534,14 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { models.push(id); } default_model = cfg.default_model.clone(); + os_config = cfg.os.clone(); } - let context_limit = default_model - .as_ref() - .and_then(|m| model_ctx.get(m)) - .copied() - .unwrap_or(0); + let context_limit = resolve_ctx_limit( + default_model + .as_ref() + .and_then(|m| model_ctx.get(m)) + .copied(), + ); // Persistent, resumable session: stored under /.a3s/tui-sessions and // keyed by a fixed id, so relaunching in the same directory continues the @@ -3002,20 +3607,56 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { .with_timeout(3_600_000, TimeoutAction::Reject); // Claude Code compatibility: load Claude/plugin SKILL.md skills alongside // a3s's own (they share the markdown + YAML-frontmatter format). - let claude_dirs = agent_skill_dirs(&workspace); + let mut claude_dirs = agent_skill_dirs(&workspace); + // Restore the persisted OS login *before* building the session, so its + // login-gated built-in `a3s-os-capabilities` skill is materialized and + // loaded from the first turn (only when signed in). + let os_session = os_config.as_ref().and_then(crate::a3s_os::current_session); + if let Some(s) = &os_session { + // Export endpoint + token so the agent's shell uses $A3S_OS_* directly + // instead of re-reading ~/.a3s/os-auth.json every call. + crate::a3s_os::export_os_env(s); + if let Some(dir) = os_config + .as_ref() + .and_then(crate::a3s_os::ensure_capability_skill_dir) + { + claude_dirs.push(dir); + } + } // Claude Code compatibility: inject CLAUDE.md (AGENTS.md is auto-loaded by // the core) into the system prompt via prompt slots. let instructions = project_instructions(&workspace); - let with_instr = |o: SessionOptions| match &instructions { - Some(i) => o.with_prompt_slots(SystemPromptSlots::default().with_extra(i.clone())), - None => o, + // When a persisted login is restored on launch, inject the OS-platform + // directive too (mirrors effort_session_opts) so the very first turn already + // routes OS questions through the progressive-API skill. + let os_address = os_session.as_ref().map(|s| s.address.clone()); + let with_instr = |o: SessionOptions| { + let mut parts: Vec = Vec::new(); + if let Some(i) = &instructions { + parts.push(i.clone()); + } + if let Some(addr) = &os_address { + parts.push(os_platform_guide(addr)); + } + if parts.is_empty() { + o + } else { + o.with_prompt_slots(SystemPromptSlots::default().with_extra(parts.join("\n\n"))) + } }; + let manifest_backend = ManifestWorkspaceBackend::new(std::path::PathBuf::from(&workspace)); + let workspace_manifest = manifest_backend.manifest(); + let initial_manifest = workspace_manifest.snapshot(); + let initial_files = initial_manifest.file_paths(); + let workspace_manifest_rx = Arc::new(Mutex::new(workspace_manifest.subscribe())); + let workspace_services = WorkspaceServices::local_with_manifest_backend(manifest_backend); let session = match agent.resume_session( session_id.as_str(), - with_instr( + with_instr(with_recent_workspace_context( SessionOptions::new() .with_session_store(store.clone()) .with_confirmation_policy(confirmation.clone()) + .with_workspace_backend(workspace_services.clone()) .with_skill_dirs(claude_dirs.clone()) .with_auto_save(true) .with_auto_compact(true) @@ -3025,16 +3666,18 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { .with_auto_delegation_enabled(true) .with_auto_parallel_delegation(true) .with_manual_delegation_enabled(true), - ), + &workspace_manifest, + )), ) { Ok(s) => s, Err(_) => agent.session( workspace.clone(), - Some(with_instr( + Some(with_instr(with_recent_workspace_context( SessionOptions::new() .with_session_store(store.clone()) .with_session_id(session_id.as_str()) .with_confirmation_policy(confirmation.clone()) + .with_workspace_backend(workspace_services.clone()) .with_skill_dirs(claude_dirs.clone()) .with_auto_save(true) .with_auto_compact(true) @@ -3044,15 +3687,16 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { .with_auto_delegation_enabled(true) .with_auto_parallel_delegation(true) .with_manual_delegation_enabled(true), - )), + &workspace_manifest, + ))), )?, }; let (width, height) = a3s_tui::terminal::Terminal::size().unwrap_or((80, 24)); // Seed the transcript with any resumed conversation (user + assistant text). - let initial_messages: Vec = session - .history() + let resumed = session.history(); + let mut initial_messages: Vec = resumed .iter() .filter_map(|m| { let text = m.text(); @@ -3065,12 +3709,33 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { "assistant" => { let mut md = StreamingMarkdown::new((width as usize).saturating_sub(PAD + 2)); md.push(&text); - Some(gutter(Color::Green, &md.view())) + Some(gutter(TN_GREEN, &md.view())) } _ => None, } }) .collect(); + // Seed ↑/↓ input recall with the user's prior prompts so resuming a session + // keeps its command history (tool-result `user` messages carry no text block, + // so the non-empty filter excludes them). + let history_seed: Vec = resumed + .iter() + .filter(|m| m.role == "user") + .map(|m| m.text().trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + // Quiet confirmation that the persisted login was restored (so the user + // isn't asked to /login again) and the login-gated skill is active. + if let Some(s) = &os_session { + initial_messages.insert( + 0, + Style::new().fg(TN_GRAY).render(&format!( + " ✓ signed in to OS as {} · capabilities skill active · /logout to sign out", + s.display_label() + )), + ); + } let session = Arc::new(session); @@ -3092,17 +3757,9 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { Action::ScrollDown, "Scroll down", ) - // Mac-friendly half-page scroll (no fn key needed). - .bind( - KeyBinding::ctrl(KeyCode::Char('u')), - Action::ScrollUp, - "Scroll up", - ) - .bind( - KeyBinding::ctrl(KeyCode::Char('d')), - Action::ScrollDown, - "Scroll down", - ) + // NB: Ctrl+U / Ctrl+D are intentionally NOT bound to scroll — they shadow + // readline line-editing (Ctrl+U = kill-to-start) in the input. PageUp/Down + // and Ctrl+Home/End cover scrolling. .bind( KeyBinding::ctrl(KeyCode::Home), Action::ScrollTop, @@ -3130,6 +3787,9 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { model_menu: None, model_tab: 0, llm_override: None, + os_config, + os_session, + os_refreshing: false, effort: 2, // high effort_panel: None, theme_panel: None, @@ -3145,7 +3805,14 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { active_tools: 0, active_agents: 0, subagents: Vec::new(), + turn_had_agent_activity: false, + turn_text_after_activity: false, + ultracode_synthesis_inflight: false, + ultracode_synthesis_used: false, instructions, + workspace_manifest, + workspace_manifest_rx, + workspace_services, rainbow_until: None, rainbow_frame: 0, effort_anim: None, @@ -3169,10 +3836,10 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { rx: None, pending_tool: None, approval_sel: 0, - history: Vec::new(), + history: history_seed, history_pos: None, model: default_model, - total_tokens: 0, + output_tokens: 0, tool_args: String::new(), tool_output: String::new(), stream_started: None, @@ -3187,6 +3854,7 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { top: None, top_scroll: 0, top_sel: 0, + top_focus: None, top_kill: None, ide: None, git: None, @@ -3194,7 +3862,7 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { completed: 0, branch: git_branch(&workspace), slash_sel: 0, - files: workspace_files(&workspace), + files: initial_files, at_expanded: std::collections::HashSet::new(), file_sel: 0, skill_count: count_skill_files(&claude_dirs), @@ -3272,6 +3940,124 @@ pub async fn run(args: Vec) -> anyhow::Result<()> { #[cfg(test)] mod tests { use super::*; + use a3s_code_core::llm::{ + ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition, + }; + use async_trait::async_trait; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + #[derive(Clone, Default)] + struct CapturedLlmTurn { + system: Option, + tools: Vec, + } + + struct CaptureLlmClient { + turns: Mutex>, + responses: Mutex>, + } + + #[async_trait] + impl LlmClient for CaptureLlmClient { + async fn complete( + &self, + _messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + ) -> anyhow::Result { + self.record(system, tools); + Ok(self.next_response()) + } + + async fn complete_streaming( + &self, + _messages: &[Message], + system: Option<&str>, + tools: &[ToolDefinition], + _cancel_token: CancellationToken, + ) -> anyhow::Result> { + self.record(system, tools); + let response = self.next_response(); + let (tx, rx) = mpsc::channel(2); + tokio::spawn(async move { + let _ = tx.send(StreamEvent::Done(response)).await; + }); + Ok(rx) + } + } + + impl CaptureLlmClient { + fn new(responses: Vec) -> Self { + Self { + turns: Mutex::new(Vec::new()), + responses: Mutex::new(responses.into()), + } + } + + fn record(&self, system: Option<&str>, tools: &[ToolDefinition]) { + self.turns.lock().unwrap().push(CapturedLlmTurn { + system: system.map(str::to_string), + tools: tools.iter().map(|tool| tool.name.clone()).collect(), + }); + } + + fn next_response(&self) -> LlmResponse { + self.responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(done_response) + } + + fn turns(&self) -> Vec { + self.turns.lock().unwrap().clone() + } + } + + fn tool_call_response(name: &str, input: serde_json::Value) -> LlmResponse { + LlmResponse { + message: Message { + role: "assistant".into(), + content: vec![ContentBlock::ToolUse { + id: "toolu_test".into(), + name: name.into(), + input, + }], + reasoning_content: None, + }, + usage: TokenUsage::default(), + stop_reason: Some("tool_use".into()), + meta: None, + } + } + + fn done_response() -> LlmResponse { + LlmResponse { + message: Message { + role: "assistant".into(), + content: vec![ContentBlock::Text { + text: "DONE".into(), + }], + reasoning_content: None, + }, + usage: TokenUsage::default(), + stop_reason: Some("stop".into()), + meta: None, + } + } + + fn test_config(path: &std::path::Path) { + std::fs::write( + path, + "default_model = \"openai/x\"\n\ + providers \"openai\" {\n apiKey = \"x\"\n baseUrl = \"http://127.0.0.1:1\"\n \ + models \"x\" { name = \"x\" }\n}\n", + ) + .unwrap(); + } /// Guard: the parallel/ultracode SessionOptions register `task` + /// `parallel_task` in the session tool surface (so fan-out has a tool to call). @@ -3280,13 +4066,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("a3s-ptask-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); let cfg = dir.join("config.acl"); - std::fs::write( - &cfg, - "default_model = \"openai/x\"\n\ - providers \"openai\" {\n apiKey = \"x\"\n baseUrl = \"http://127.0.0.1:1\"\n \ - models \"x\" { name = \"x\" }\n}\n", - ) - .unwrap(); + test_config(&cfg); let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string()) .await .unwrap(); @@ -3310,6 +4090,269 @@ mod tests { ); } + #[tokio::test] + async fn claude_session_surface_passes_system_tools_and_skills_to_llm() { + let dir = std::env::temp_dir().join(format!( + "a3s-claude-surface-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.acl"); + test_config(&cfg); + std::fs::write( + dir.join("CLAUDE.md"), + "Project rule: claude-session-surface-marker", + ) + .unwrap(); + let skill_dir = dir.join(".claude/skills/inspect-surface"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: inspect-surface\n\ + description: Inspect the Claude session surface\n\ + kind: instruction\n\ + allowed-tools:\n - Read\n---\n\ + Use this skill marker: inspect-surface-skill-marker\n", + ) + .unwrap(); + + let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string()) + .await + .unwrap(); + let llm = Arc::new(CaptureLlmClient::new(vec![done_response()])); + let opts = SessionOptions::new() + .with_llm_client(llm.clone()) + .with_prompt_slots( + SystemPromptSlots::default() + .with_extra(project_instructions(dir.to_str().unwrap()).unwrap()), + ) + .with_skill_dirs(agent_skill_dirs(dir.to_str().unwrap())) + .with_manual_delegation_enabled(true) + .with_auto_delegation_enabled(false) + .with_planning_mode(a3s_code_core::PlanningMode::Disabled); + let session = agent + .session(dir.to_string_lossy().to_string(), Some(opts)) + .unwrap(); + + let (mut rx, join) = session + .stream("Use available skills to inspect this project.", None) + .await + .unwrap(); + while let Some(event) = rx.recv().await { + if matches!(event, a3s_code_core::AgentEvent::End { .. }) { + break; + } + } + join.await.unwrap(); + let turns = llm.turns(); + let captured = turns.first().unwrap(); + let system = captured.system.as_deref().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert!( + system.contains("You are A3S Code"), + "core system prompt should reach the LLM" + ); + assert!( + system.contains("claude-session-surface-marker"), + "CLAUDE.md project instructions should reach the LLM" + ); + assert!( + system.contains("# Skills"), + "skill catalog guidance should reach the LLM system prompt" + ); + assert!( + captured.tools.iter().any(|name| name == "read") + && captured.tools.iter().any(|name| name == "Skill") + && captured.tools.iter().any(|name| name == "search_skills") + && captured.tools.iter().any(|name| name == "parallel_task"), + "a3s tools and skill tools should be model-visible; got {:?}", + captured.tools + ); + } + + #[tokio::test] + async fn claude_can_invoke_skill_and_child_run_receives_skill_prompt() { + let dir = std::env::temp_dir().join(format!( + "a3s-claude-skill-invoke-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = dir.join("config.acl"); + test_config(&cfg); + let skill_dir = dir.join(".claude/skills/inspect-surface"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: inspect-surface\n\ + description: Inspect the Claude session surface\n\ + kind: instruction\n\ + allowed-tools:\n - Read\n---\n\ + Use this skill marker: inspect-surface-skill-marker\n", + ) + .unwrap(); + + let agent = a3s_code_core::Agent::new(cfg.to_string_lossy().to_string()) + .await + .unwrap(); + let llm = Arc::new(CaptureLlmClient::new(vec![ + tool_call_response( + "Skill", + serde_json::json!({ + "skill_name": "inspect-surface", + "prompt": "Apply the inspect-surface skill." + }), + ), + done_response(), + done_response(), + ])); + let opts = SessionOptions::new() + .with_llm_client(llm.clone()) + .with_skill_dirs(agent_skill_dirs(dir.to_str().unwrap())) + .with_manual_delegation_enabled(true) + .with_auto_delegation_enabled(false) + .with_permission_policy( + a3s_code_core::permissions::PermissionPolicy::new().allow("Skill(*)"), + ) + .with_planning_mode(a3s_code_core::PlanningMode::Disabled) + .with_max_tool_rounds(5); + let session = agent + .session(dir.to_string_lossy().to_string(), Some(opts)) + .unwrap(); + + let result = session + .send("Use the inspect-surface skill.", None) + .await + .unwrap(); + let turns = llm.turns(); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(result.text.trim(), "DONE"); + let system_snippets = turns + .iter() + .enumerate() + .map(|(index, turn)| { + format!( + "#{index}: {}", + turn.system + .as_deref() + .unwrap_or("") + .chars() + .take(220) + .collect::() + ) + }) + .collect::>() + .join("\n"); + assert!( + turns + .iter() + .any(|turn| turn.system.as_deref().is_some_and(|system| { + system.contains("You are executing the 'inspect-surface' skill") + && system.contains("inspect-surface-skill-marker") + })), + "Skill tool should start a child LLM run with the skill prompt; turns: {}", + system_snippets + ); + } + + #[test] + fn workflow_doc_captures_single_task_dispatch() { + let args = serde_json::json!({ + "agent": "plan", + "description": "Design the rendering architecture", + "prompt": "Plan a layered renderer." + }); + let (doc, label) = workflow_doc_for_tool("task", Some(&args)).unwrap(); + + assert!(label.contains("delegated task"), "{label}"); + assert!(doc.contains("Design the rendering architecture")); + assert!(doc.contains("Agent: `plan`")); + assert!(doc.contains("Plan a layered renderer.")); + } + + #[test] + fn synthesis_requires_activity_without_followup_text() { + // Fires when a turn had agent activity but produced no final text — in + // ANY mode (no effort gate), so a high-effort fan-out that ends silently + // still gets a synthesized answer. + assert!(needs_synthesis(false, false, true, false)); + // No final answer needed if the turn already produced text after activity. + assert!(!needs_synthesis(false, false, true, true)); + // At most once per turn. + assert!(!needs_synthesis(false, true, true, false)); + // Nothing to synthesize if no work happened (e.g. a bare greeting). + assert!(!needs_synthesis(false, false, false, false)); + // Never while a synthesis turn is itself in flight. + assert!(!needs_synthesis(true, false, true, false)); + } + + #[test] + fn estimate_tokens_counts_cjk_heavier_than_ascii() { + assert_eq!(estimate_tokens("abcd"), 1); // ASCII ~4 chars/token + assert_eq!(estimate_tokens("书安操作系统"), 6); // CJK ~1 token/char (chars/4 would say 1) + assert_eq!(estimate_tokens("hi 书安"), 2); // mixed: 3 ASCII -> 0, 2 wide -> 2 + assert_eq!(estimate_tokens(""), 0); + } + + #[test] + fn ctx_limit_falls_back_when_undeclared() { + assert_eq!(resolve_ctx_limit(Some(200_000)), 200_000); // declared wins + assert_eq!(resolve_ctx_limit(Some(0)), DEFAULT_CONTEXT_LIMIT); // zero -> default + assert_eq!(resolve_ctx_limit(None), DEFAULT_CONTEXT_LIMIT); // missing -> default + } + + #[test] + fn auto_compact_threshold_scales_to_real_window() { + // 128k model: fire at 85% of 128k, i.e. 0.85*128/200 of the core's fixed 200k. + assert!((auto_compact_threshold_for(128_000) - 0.544).abs() < 0.001); + // 200k model == the core's own denominator: plain 0.85. + assert!((auto_compact_threshold_for(200_000) - 0.85).abs() < 0.001); + // Windows past ~235k clamp to 1.0 (trigger at the fixed 200k, never overflow). + assert_eq!(auto_compact_threshold_for(1_000_000), 1.0); + // Unknown window (0) falls back to the core default of 0.85. + assert!((auto_compact_threshold_for(0) - 0.85).abs() < 0.001); + } + + #[test] + fn task_tool_empty_child_output_renders_useful_summary() { + let args = serde_json::json!({ + "agent": "plan", + "description": "Plan subsystem boundaries", + "prompt": "Create the plan." + }); + let meta = serde_json::json!({ + "task_id": "task-abc123", + "session_id": "task-run-task-abc123", + "agent": "plan", + "success": true, + "output_bytes": 0, + "artifact_uri": "a3s://tasks/task-run-task-abc123/runs/task-abc123/output" + }); + let output = "Task completed: task-abc123\n\ + Agent: plan\n\ + Session: task-run-task-abc123\n\ + Task ID: task-abc123\n\ + Artifact ID: task-output:task-abc123\n\ + Artifact URI: a3s://tasks/task-run-task-abc123/runs/task-abc123/output\n\ + Output:\n"; + let out = render_tool_end("task", 0, output, Some(&meta), Some(&args), 100); + let plain = strip_ansi(&out); + + assert!(plain.contains("Explored")); + assert!(plain.contains("Task completed · plan · task-abc123")); + assert!(plain.contains("no child text output")); + assert!(plain.contains("artifact: a3s://tasks/task-run-task-abc123")); + } + #[test] fn edit_metadata_renders_colored_diff() { let meta = serde_json::json!({ @@ -3382,6 +4425,11 @@ mod tests { assert_eq!(arg_summary(&serde_json::json!({ "unknown": "x" })), None); } + #[test] + fn reload_is_idle_only_because_it_rebuilds_the_session() { + assert!(IDLE_ONLY.contains(&"/reload")); + } + // ---- image preview (/ide + paste) ---- #[test] diff --git a/src/tui/panels/banner.rs b/src/tui/panels/banner.rs index 2a23207..1f29216 100644 --- a/src/tui/panels/banner.rs +++ b/src/tui/panels/banner.rs @@ -61,15 +61,12 @@ impl App { } else { String::new() }; - let meta = Style::new().fg(Color::BrightBlack).render(&format!( + let meta = Style::new().fg(TN_GRAY).render(&format!( "{margin}a3s-code v{} · {model}{skills} · {}", env!("CARGO_PKG_VERSION"), self.cwd )); - let tips = Style::new() - .fg(Color::BrightBlack) - .italic() - .render(&format!( + let tips = Style::new().fg(TN_GRAY).italic().render(&format!( "{margin}Type a message · / for commands · Shift+Tab cycles mode · Ctrl+C twice to exit" )); let update = match &self.update_available { diff --git a/src/tui/panels/btw.rs b/src/tui/panels/btw.rs index ef57f28..d452a53 100644 --- a/src/tui/panels/btw.rs +++ b/src/tui/panels/btw.rs @@ -24,24 +24,21 @@ impl App { }; let mut lines = vec![pad_to( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .bold() .render(" ↘ by the way · Esc to close"), width, )]; for l in wrap(&format!("Q: {q}")) { lines.push(pad_to( - &Style::new() - .fg(Color::Yellow) - .bold() - .render(&format!(" {l}")), + &Style::new().fg(TN_YELLOW).bold().render(&format!(" {l}")), width, )); } let ans = a.as_deref().unwrap_or("thinking…"); for l in wrap(ans).into_iter().take(12) { lines.push(pad_to( - &Style::new().fg(Color::Yellow).render(&format!(" {l}")), + &Style::new().fg(TN_YELLOW).render(&format!(" {l}")), width, )); } diff --git a/src/tui/panels/effort.rs b/src/tui/panels/effort.rs index 8e693c7..4fd5123 100644 --- a/src/tui/panels/effort.rs +++ b/src/tui/panels/effort.rs @@ -52,7 +52,7 @@ impl App { String::new(), center( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render("planning a dynamic workflow · dispatching parallel subagents"), 61, ), @@ -85,12 +85,12 @@ impl App { // Level names centred under their tick, each in its own colour // (faster→smarter gradient; ultracode is magenta). let level_colors = [ - Color::Green, - Color::Cyan, - Color::Blue, - Color::Yellow, + TN_GREEN, + TN_CYAN, + ACCENT, + TN_YELLOW, Color::Rgb(255, 140, 0), - Color::Magenta, + TN_PURPLE, ]; let mut labels = String::new(); let mut vis = 0usize; @@ -116,12 +116,12 @@ impl App { } else { "higher effort = more reasoning tokens (slower, deeper). Use sparingly." }; - let dim = |s: &str| Style::new().fg(Color::BrightBlack).render(s); + let dim = |s: &str| Style::new().fg(TN_GRAY).render(s); let menu = vec![ pad_to(&Style::new().fg(ACCENT).bold().render(" Effort"), width), pad_to(&format!(" {}", dim(&faster_smarter)), width), pad_to( - &format!(" {}", Style::new().fg(Color::White).render(&track)), + &format!(" {}", Style::new().fg(TN_FG).render(&track)), width, ), pad_to(&format!(" {labels}"), width), diff --git a/src/tui/panels/files.rs b/src/tui/panels/files.rs index ec557d7..2724fae 100644 --- a/src/tui/panels/files.rs +++ b/src/tui/panels/files.rs @@ -104,6 +104,7 @@ impl App { } } else if let Some(at) = self.textarea.value().rfind('@') { let val = self.textarea.value(); + self.touch_workspace_file(&path); self.textarea.set_value(&format!("{}@{path} ", &val[..at])); self.file_sel = 0; } @@ -160,9 +161,9 @@ impl App { menu.push(if i == sel { Style::new().fg(Color::BrightWhite).bg(ACCENT).render(&raw) } else if *is_dir { - Style::new().fg(Color::Cyan).render(&raw) + Style::new().fg(TN_CYAN).render(&raw) } else { - Style::new().fg(Color::White).render(&raw) + Style::new().fg(TN_FG).render(&raw) }); } if total > max_rows { @@ -170,7 +171,7 @@ impl App { let down = if end < total { "↓" } else { " " }; menu.push(pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" {up}{down} {}/{total}", sel + 1)), width, )); diff --git a/src/tui/panels/git.rs b/src/tui/panels/git.rs index 90897cb..3b00f71 100644 --- a/src/tui/panels/git.rs +++ b/src/tui/panels/git.rs @@ -191,9 +191,7 @@ impl App { .bold() .render(&format!(" {label} ")) } else { - Style::new() - .fg(Color::BrightBlack) - .render(&format!(" {label} ")) + Style::new().fg(TN_GRAY).render(&format!(" {label} ")) } }; let logtab = if g.log.is_empty() { @@ -208,16 +206,11 @@ impl App { Style::new() .fg(ACCENT) .render("⇄ Tab to switch · commits in Log"), - Style::new().fg(Color::BrightBlack).render(&g.note) + Style::new().fg(TN_GRAY).render(&g.note) ); let mut out = vec![ pad_to(&header, width), - pad_to( - &Style::new() - .fg(Color::BrightBlack) - .render(&"─".repeat(width)), - width, - ), + pad_to(&Style::new().fg(TN_GRAY).render(&"─".repeat(width)), width), ]; let body = h.saturating_sub(3); @@ -228,10 +221,7 @@ impl App { } else { " loading commits…" }; - out.push(pad_to( - &Style::new().fg(Color::BrightBlack).render(msg), - width, - )); + out.push(pad_to(&Style::new().fg(TN_GRAY).render(msg), width)); out.truncate(h); while out.len() < h { out.push(String::new()); @@ -241,7 +231,7 @@ impl App { // Two columns: the commit list (selectable) + the selected commit's // details (`git show`) on the right. let tw = (width / 3).clamp(20, 46); - let sep = Style::new().fg(Color::BrightBlack).render(" │ "); + let sep = Style::new().fg(TN_GRAY).render(" │ "); // keep the selected commit visible let start = g.log_sel.saturating_sub(body.saturating_sub(1)); for i in 0..body { @@ -250,12 +240,12 @@ impl App { let (hash, rest) = line.split_once(' ').unwrap_or((line.as_str(), "")); let raw = pad_to(&truncate(&format!(" {hash} {rest}"), tw), tw); if ci == g.log_sel { - Style::new().fg(Color::Black).bg(Color::Yellow).render(&raw) + Style::new().fg(Color::Black).bg(TN_YELLOW).render(&raw) } else { format!( "{}{}", Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(&pad_to(&format!(" {hash} "), hash.len() + 2)), truncate(rest, tw.saturating_sub(hash.len() + 3)) ) @@ -265,15 +255,15 @@ impl App { }; let right = if let Some(line) = g.diff.get(g.diff_scroll + i) { let st = if line.starts_with("@@") { - Style::new().fg(Color::Cyan) + Style::new().fg(TN_CYAN) } else if line.starts_with("commit ") { - Style::new().fg(Color::Yellow).bold() + Style::new().fg(TN_YELLOW).bold() } else if line.starts_with('+') { - Style::new().fg(Color::Green) + Style::new().fg(TN_GREEN) } else if line.starts_with('-') { - Style::new().fg(Color::Red) + Style::new().fg(TN_RED) } else if line.starts_with("diff ") || line.starts_with("index ") { - Style::new().fg(Color::BrightBlack) + Style::new().fg(TN_GRAY) } else { Style::new() }; @@ -285,48 +275,48 @@ impl App { } } else { let tw = (width / 3).clamp(20, 46); - let sep = Style::new().fg(Color::BrightBlack).render(" │ "); + let sep = Style::new().fg(TN_GRAY).render(" │ "); + // Scroll the file list so the selection stays visible (mirrors the Log + // view); previously it rendered from index 0 and the highlight could + // scroll off the bottom and become unreachable. + let start = g.sel.saturating_sub(body.saturating_sub(1)); for i in 0..body { + let fi = start + i; // left: file list - let left = if let Some(f) = g.files.get(i) { + let left = if let Some(f) = g.files.get(fi) { let mark = format!("{}{}", f.x, f.y); let raw = pad_to(&truncate(&format!(" {mark} {}", f.path), tw), tw); let color = if f.untracked() { - Color::Red + TN_RED } else if f.staged() { - Color::Green + TN_GREEN } else { - Color::Yellow + TN_YELLOW }; - if i == g.sel { + if fi == g.sel { Style::new().fg(Color::Black).bg(color).render(&raw) } else { Style::new().fg(color).render(&raw) } - } else if i == 0 && g.files.is_empty() { - pad_to( - &Style::new() - .fg(Color::BrightBlack) - .render(" working tree clean"), - tw, - ) + } else if fi == 0 && g.files.is_empty() { + pad_to(&Style::new().fg(TN_GRAY).render(" working tree clean"), tw) } else { " ".repeat(tw) }; // right: diff let right = if let Some(line) = g.diff.get(g.diff_scroll + i) { let st = if line.starts_with("@@") { - Style::new().fg(Color::Cyan) + Style::new().fg(TN_CYAN) } else if line.starts_with('+') { - Style::new().fg(Color::Green) + Style::new().fg(TN_GREEN) } else if line.starts_with('-') { - Style::new().fg(Color::Red) + Style::new().fg(TN_RED) } else if line.starts_with("diff ") || line.starts_with("index ") || line.starts_with("--- ") || line.starts_with("+++ ") { - Style::new().fg(Color::BrightBlack) + Style::new().fg(TN_GRAY) } else { Style::new() }; @@ -340,11 +330,11 @@ impl App { // Bottom row: commit input, or the key hints. let bottom = if let Some(msg) = &g.commit_input { - Style::new().fg(Color::Yellow).bold().render(&format!( + Style::new().fg(TN_YELLOW).bold().render(&format!( " commit message: {msg}_ (Enter commit · Esc cancel)" )) } else { - Style::new().fg(Color::BrightBlack).render( + Style::new().fg(TN_GRAY).render( " ↑↓ select · Space/s stage · u unstage · a stage-all · c commit · Tab log · r refresh · Esc", ) }; diff --git a/src/tui/panels/help.rs b/src/tui/panels/help.rs index 8175c3d..dbd04fd 100644 --- a/src/tui/panels/help.rs +++ b/src/tui/panels/help.rs @@ -11,11 +11,8 @@ impl App { let row = |k: &str, d: &str| { format!( " {} {}", - Style::new() - .fg(Color::White) - .bold() - .render(&format!("{k:<16}")), - Style::new().fg(Color::BrightBlack).render(d) + Style::new().fg(TN_FG).bold().render(&format!("{k:<16}")), + Style::new().fg(TN_GRAY).render(d) ) }; let mut lines: Vec = vec![ @@ -55,7 +52,7 @@ impl App { row("auto", "auto-approves tools"), String::new(), Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(" Resume a past session: a3s code resume (printed on exit)"), ]; for l in &mut lines { diff --git a/src/tui/panels/ide.rs b/src/tui/panels/ide.rs index 5e740bc..dc3d1b2 100644 --- a/src/tui/panels/ide.rs +++ b/src/tui/panels/ide.rs @@ -22,6 +22,8 @@ impl App { } let h = self.height as usize; let w = self.width as usize; + let workspace_manifest = self.workspace_manifest.clone(); + let workspace = self.cwd.clone(); let ide = self.ide.as_mut().unwrap(); match key.code { // Editor focused: full text editing of the open file. @@ -40,6 +42,11 @@ impl App { match std::fs::write(&f.path, content) { Ok(()) => { f.dirty = false; + touch_workspace_file_path_for_manifest( + &workspace_manifest, + &workspace, + &f.path, + ); "✔ saved".to_string() } Err(e) => format!("✗ save failed: {e}"), @@ -183,6 +190,7 @@ impl App { let lines = render_image_file(&path, w.saturating_sub(tw + 4), h.saturating_sub(3)) .unwrap_or_else(|| vec!["".into()]); + touch_workspace_file_path_for_manifest(&workspace_manifest, &workspace, &path); ide.file = Some(IdeFile { path, lines, @@ -201,6 +209,7 @@ impl App { .lines() .map(String::from) .collect(); + touch_workspace_file_path_for_manifest(&workspace_manifest, &workspace, &path); ide.file = Some(IdeFile { path, lines: if lines.is_empty() { @@ -271,14 +280,9 @@ impl App { .render(&format!(" IDE — {fname} {hint}")), width, ), - pad_to( - &Style::new() - .fg(Color::BrightBlack) - .render(&"─".repeat(width)), - width, - ), + pad_to(&Style::new().fg(TN_GRAY).render(&"─".repeat(width)), width), ]; - let sep = Style::new().fg(Color::BrightBlack).render(" │ "); + let sep = Style::new().fg(TN_GRAY).render(" │ "); for i in 0..body { let left = if let Some(e) = ide.entries.get(ide.tree_scroll + i) { let icon = if e.is_dir { @@ -299,7 +303,7 @@ impl App { } else if e.is_dir { Style::new().fg(ACCENT).render(&plain) } else { - Style::new().fg(Color::White).render(&plain) + Style::new().fg(TN_FG).render(&plain) } } else { " ".repeat(tw) @@ -312,9 +316,9 @@ impl App { let lineno = f.scroll + i; let num = Style::new() .fg(if ide.focus_editor && lineno == f.row { - Color::Yellow + TN_YELLOW } else { - Color::BrightBlack + TN_GRAY }) .render(&format!("{:>4} ", lineno + 1)); // Truncate the plain line first, then syntax-highlight it. @@ -324,9 +328,7 @@ impl App { String::new() } } else if i == 0 { - Style::new() - .fg(Color::BrightBlack) - .render(" ← pick a file to view") + Style::new().fg(TN_GRAY).render(" ← pick a file to view") } else { String::new() }; diff --git a/src/tui/panels/login.rs b/src/tui/panels/login.rs index 3245a41..22ea7c5 100644 --- a/src/tui/panels/login.rs +++ b/src/tui/panels/login.rs @@ -1,10 +1,5 @@ //! Detect a local Claude Code / Codex login so `/model` can surface those //! accounts as tabs. -//! -//! NOTE: a3s-code only ships an OpenAI-Chat-Completions client, which can't -//! drive Anthropic's `/v1/messages` or the ChatGPT backend, so these accounts -//! can't actually run yet — the `/model` account tabs are informational and -//! point you at an API key in `config.acl`. Detection only (no token use). #[derive(Clone, Copy, PartialEq)] pub(crate) enum AuthProvider { @@ -12,66 +7,155 @@ pub(crate) enum AuthProvider { Codex, } -impl AuthProvider { - pub(crate) fn label(self) -> &'static str { - match self { - AuthProvider::Claude => "Claude Code", - AuthProvider::Codex => "Codex", +/// Claude models seen by the local Claude Code install. Project `"model"` +/// values are listed first, followed by recent usage stats. +pub(crate) fn claude_models() -> Vec { + let mut out = Vec::new(); + if let Some(home) = std::env::var_os("HOME") { + let home = std::path::Path::new(&home); + for path in [ + home.join(".claude.json"), + home.join(".claude").join("stats-cache.json"), + ] { + if let Ok(txt) = std::fs::read_to_string(path) { + if let Ok(value) = serde_json::from_str::(&txt) { + collect_claude_models(&value, &mut out); + } + } } } + if out.is_empty() { + out.push("claude-sonnet-4".to_string()); + } + out } -/// The Claude model(s) configured in the local Claude Code login -/// (`~/.claude.json`), found by walking the JSON for `"model": "claude-…"`. -pub(crate) fn claude_models() -> Vec { - fn walk(v: &serde_json::Value, out: &mut Vec) { - match v { +fn collect_claude_models(value: &serde_json::Value, out: &mut Vec) { + fn push_model(out: &mut Vec, model: &str) { + let model = crate::claude::canonical_model_name(model); + if model.starts_with("claude") && !out.iter().any(|m| m == &model) { + out.push(model); + } + } + + fn walk_model_values( + value: &serde_json::Value, + parent_key: Option<&str>, + out: &mut Vec, + ) { + match value { serde_json::Value::Object(map) => { - for (k, val) in map { - if k == "model" { - if let Some(s) = val.as_str() { - if s.starts_with("claude") && !out.iter().any(|m| m == s) { - out.push(s.to_string()); - } + for (key, child) in map { + if key == "model" { + if let Some(model) = child.as_str() { + push_model(out, model); } } - walk(val, out); + walk_model_values(child, Some(key), out); } } - serde_json::Value::Array(a) => a.iter().for_each(|x| walk(x, out)), + serde_json::Value::Array(items) => { + for child in items { + walk_model_values(child, parent_key, out); + } + } + serde_json::Value::String(model) if parent_key == Some("model") => { + push_model(out, model); + } _ => {} } } - let mut out = Vec::new(); - if let Some(home) = std::env::var_os("HOME") { - let path = std::path::Path::new(&home).join(".claude.json"); - if let Ok(txt) = std::fs::read_to_string(path) { - if let Ok(v) = serde_json::from_str::(&txt) { - walk(&v, &mut out); + + fn walk_usage_maps( + value: &serde_json::Value, + parent_key: Option<&str>, + target_key: &str, + out: &mut Vec, + ) { + match value { + serde_json::Value::Object(map) => { + if parent_key == Some(target_key) { + for key in map.keys() { + push_model(out, key); + } + } + for (key, child) in map { + walk_usage_maps(child, Some(key), target_key, out); + } + } + serde_json::Value::Array(items) => { + for child in items { + walk_usage_maps(child, parent_key, target_key, out); + } } + _ => {} } } - if out.is_empty() { - out.push("claude-sonnet-4".to_string()); - } - out + + walk_model_values(value, None, out); + walk_usage_maps(value, None, "lastModelUsage", out); + walk_usage_maps(value, None, "tokensByModel", out); } /// True when the local Claude Code / Codex CLI has a stored login. pub(crate) fn has_local_login(provider: AuthProvider) -> bool { - let Some(home) = std::env::var_os("HOME") else { - return false; - }; - let home = std::path::Path::new(&home); - let read = |rel: &str| -> Option { - serde_json::from_str(&std::fs::read_to_string(home.join(rel)).ok()?).ok() - }; match provider { - AuthProvider::Codex => read(".codex/auth.json") - .map(|v| v.pointer("/tokens/access_token").is_some() || v.get("access_token").is_some()) - .unwrap_or(false), - AuthProvider::Claude => read(".claude/.credentials.json") - .map(|v| v.pointer("/claudeAiOauth/accessToken").is_some()) - .unwrap_or(false), + AuthProvider::Claude => crate::claude::has_claude_login(), + AuthProvider::Codex => { + let Some(home) = std::env::var_os("HOME") else { + return false; + }; + let home = std::path::Path::new(&home); + let path = home.join(".codex/auth.json"); + std::fs::read_to_string(path) + .ok() + .and_then(|txt| serde_json::from_str::(&txt).ok()) + .map(|v| { + v.pointer("/tokens/access_token").is_some() || v.get("access_token").is_some() + }) + .unwrap_or(false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn collects_claude_models_from_model_values_and_usage_maps() { + let value = json!({ + "projects": { + "/repo": { + "model": "claude-sonnet-4-6", + "lastModelUsage": { + "claude-opus-4-8[1m]": {"inputTokens": 10}, + "claude-opus-4-8": {"inputTokens": 5}, + "not-claude": {"inputTokens": 1} + } + } + }, + "dailyModelTokens": [ + { + "tokensByModel": { + "claude-haiku-4-5-20251001": 5, + "MiniMax-M2.7-highspeed": 5 + } + } + ] + }); + let mut models = Vec::new(); + + collect_claude_models(&value, &mut models); + + assert_eq!( + models, + vec![ + "claude-sonnet-4-6", + "claude-opus-4-8", + "claude-haiku-4-5-20251001" + ] + ); } } diff --git a/src/tui/panels/menu.rs b/src/tui/panels/menu.rs index cc549a9..61d2029 100644 --- a/src/tui/panels/menu.rs +++ b/src/tui/panels/menu.rs @@ -10,6 +10,7 @@ impl App { let mut out: Vec<(String, String)> = slash_candidates(input) .into_iter() .filter(|(c, _)| idle || !IDLE_ONLY.contains(c)) + .filter(|(c, _)| self.os_config.is_some() || !matches!(*c, "/login" | "/logout")) .map(|(c, d)| (c.to_string(), d.to_string())) .collect(); for (name, desc) in &self.skills { @@ -130,7 +131,7 @@ impl App { if i == sel { Style::new().fg(Color::BrightWhite).bg(ACCENT).render(&raw) } else { - Style::new().fg(Color::BrightBlack).render(&raw) + Style::new().fg(TN_GRAY).render(&raw) } }) .collect(); @@ -140,7 +141,7 @@ impl App { let down = if end < total { "↓" } else { " " }; menu.push(pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" {up}{down} {}/{total}", sel + 1)), width, )); diff --git a/src/tui/panels/model.rs b/src/tui/panels/model.rs index b9f5016..3ccc583 100644 --- a/src/tui/panels/model.rs +++ b/src/tui/panels/model.rs @@ -11,37 +11,37 @@ struct ModelTab { provider: Option, // None = config.acl } +fn selected_model_location(tabs: &[ModelTab], current: Option<&str>) -> (usize, usize) { + let current = current.map(crate::claude::canonical_model_name); + current + .as_deref() + .and_then(|current| { + tabs.iter().enumerate().find_map(|(tab_idx, tab)| { + tab.models + .iter() + .position(|model| model == current) + .map(|model_idx| (tab_idx, model_idx)) + }) + }) + .unwrap_or((0, 0)) +} + // Per-source accents, tuned to the Tokyo Night palette (blue / orange / teal). const A3S_COLOR: Color = ACCENT; const CLAUDE_COLOR: Color = TN_ORANGE; const CODEX_COLOR: Color = Color::Rgb(115, 218, 202); // tokyo teal -/// Ultracode system-prompt steer: express the whole task as ONE generated -/// `program` workflow script that fans out via `parallel_task` inside it — a -/// hard rule (no top-level delegation) plus a copy-paste template, because a -/// soft "prefer the script" steer let the model just call parallel_task directly -/// (the PTC fans out child agents on the multi-threaded runtime since 4.2.6). +/// Ultracode system-prompt steer: keep the model focused on decomposition and +/// synthesis while the core planning runtime turns independent plan waves into +/// visible `parallel_task` subagents. const ULTRACODE_GUIDELINES: &str = "\ -[ultracode] Dynamic-workflow mode. Express ALL of your work as ONE generated, \ -executable workflow SCRIPT. Do NOT call `parallel_task` or `task` directly at \ -the top level — the script IS the workflow.\n\ -1. PLAN. Decompose the task into numbered steps; mark independent (concurrent) \ -vs dependent (sequential).\n\ -2. WRITE + RUN THE SCRIPT by calling the `program` tool with a JavaScript \ -`source` of this shape:\n\ - async function run(ctx, inputs) {\n\ - const results = await ctx.tool(\"parallel_task\", { tasks: [\n\ - { description: \"step A\", prompt: \"...\" },\n\ - { description: \"step B\", prompt: \"...\" }\n\ - ] });\n\ - return results;\n\ - }\n\ - Put EVERY task/parallel_task call INSIDE the script; add further ctx.tool(...) \ -calls for dependent steps and aggregate their outputs.\n\ -3. parallel_task inside the script fans out concurrent subagents on the \ -multi-threaded runtime. After it returns, synthesize the results into your \ -final answer.\n\ -4. Be exhaustive: pursue every thread to completion."; +[ultracode] Dynamic-workflow mode is available — you decide whether a turn needs \ +it. Match the effort to the task: answer trivial or conversational input (a \ +greeting, a single question, a one-step edit) directly, with no plan and no \ +fan-out. When a task genuinely splits into independent branches, decompose it, \ +run those branches as parallel background subagents via `parallel_task` (keep \ +each child prompt bounded and evidence-oriented), then synthesize their results \ +before continuing dependent work."; impl App { /// Tabs: a3s-code always; Claude Code / Codex appear when that local login @@ -78,20 +78,13 @@ impl App { if tabs.iter().all(|t| t.models.is_empty()) { self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(" no models configured in config.acl"), ); return; } - // The active model is always a config model (account tabs are - // informational), so open on the config tab at the current model. - self.model_tab = 0; - let cur = self.model.as_deref(); - let idx = tabs[0] - .models - .iter() - .position(|m| Some(m.as_str()) == cur) - .unwrap_or(0); + let (tab, idx) = selected_model_location(&tabs, self.model.as_deref()); + self.model_tab = tab; self.model_menu = Some(idx); } @@ -133,12 +126,16 @@ impl App { self.switch_model(&model); } } + Some(AuthProvider::Claude) => { + if let Some(model) = model { + self.sign_in_claude(&model); + } + } Some(AuthProvider::Codex) => { if let Some(model) = model { self.sign_in_codex(&model); } } - Some(p) => self.account_model_note(p), // Claude: still experimental } Some(None) } @@ -150,13 +147,57 @@ impl App { } } + /// Sign in with the local Claude Code login and switch to one of its models + /// by injecting the Claude account client (OAuth Bearer auth). + fn sign_in_claude(&mut self, model: &str) { + let model = crate::claude::canonical_model_name(model); + if self.state != State::Idle { + self.push_line( + &Style::new() + .fg(TN_YELLOW) + .render(" finish the current turn before switching models"), + ); + return; + } + match crate::claude::ClaudeClient::from_claude_login(&model) { + Ok(client) => { + self.llm_override = Some(Arc::new(client)); + self.model = Some(model.clone()); + match self.rebuild_session(Some(&model)) { + Ok((session, _)) => { + self.session = Arc::new(session); + self.context_limit = resolve_ctx_limit(self.model_ctx.get(&model).copied()); + self.push_line( + &Style::new() + .fg(TN_GREEN) + .render(&format!(" ⇄ Claude Code · {model}")), + ); + } + Err(error) => { + self.llm_override = None; + self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" failed to switch: {error}")), + ); + } + } + } + Err(error) => self.push_line( + &Style::new() + .fg(TN_RED) + .render(&format!(" Claude Code sign-in failed: {error}")), + ), + } + } + /// Sign in with the local Codex login and switch to one of its models by /// injecting the custom Codex client (talks to the ChatGPT backend). fn sign_in_codex(&mut self, model: &str) { if self.state != State::Idle { self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" finish the current turn before switching models"), ); return; @@ -192,52 +233,55 @@ impl App { } } - /// Claude account tab is informational for now: a3s-code can't drive the - /// Anthropic account API, so point the user at an API key in config.acl. - fn account_model_note(&mut self, provider: AuthProvider) { - self.push_line(&Style::new().fg(Color::Yellow).render(&format!( - " {} login detected, but a3s can't use it yet — add an API key in \ - config.acl and pick it from the a3s-code tab (/config to edit)", - provider.label() - ))); - } - /// Switch the active model by resuming the session under it (history kept). /// Base session options carrying the current effort. `ultracode` adds a - /// system-prompt steer + goal tracking + a wider tool-round budget so a turn - /// plans, then fans independent work out to parallel subagents via direct - /// `parallel_task` calls. + /// planning + goal tracking + a wider tool-round budget so a turn plans, + /// then fans independent work out to visible parallel subagents. pub(crate) fn effort_session_opts(&self, thinking: bool) -> SessionOptions { - let mut opts = SessionOptions::new() - .with_session_store(self.store.clone()) - .with_session_id(self.session_id.as_str()) - .with_confirmation_policy(self.confirmation.clone()) - .with_skill_dirs(agent_skill_dirs(&self.cwd)) - .with_auto_save(true) - // Auto-compact the context when it nears the window (Claude-style). - .with_auto_compact(true) - .with_auto_compact_threshold(0.85) - .with_file_memory(memory_dir()) - // Parallel fan-out available in every mode (not just ultracode). - .with_max_parallel_tasks(8) - .with_auto_delegation_enabled(true) - .with_auto_parallel_delegation(true) - // Pin manual delegation on so `parallel_task`/`task` stay registered - // even if config.acl disables them — else ultracode's fan-out calls - // an unregistered tool ("Unknown tool: parallel_task"). - .with_manual_delegation_enabled(true) - // Generous tool-round budget for every effort — Claude Code runs - // effectively unbounded; the old ~50 default cut real multi-step work - // (and many parallel subagents) short. ultracode widens it further. - .with_max_tool_rounds(200); + let mut opts = with_recent_workspace_context( + SessionOptions::new() + .with_session_store(self.store.clone()) + .with_session_id(self.session_id.as_str()) + .with_confirmation_policy(self.confirmation.clone()) + .with_workspace_backend(self.workspace_services.clone()) + // Includes the login-gated OS `a3s-os-capabilities` skill. + .with_skill_dirs(self.skill_dirs()) + .with_auto_save(true) + // Auto-compact the context when it nears the window (Claude-style). + // The threshold is scaled to THIS model's real window because the + // core triggers off a fixed 200k (see `auto_compact_threshold_for`). + .with_auto_compact(true) + .with_auto_compact_threshold(auto_compact_threshold_for(self.context_limit)) + .with_file_memory(memory_dir()) + // Parallel fan-out available in every mode (not just ultracode). + .with_max_parallel_tasks(8) + .with_auto_delegation_enabled(true) + .with_auto_parallel_delegation(true) + // Pin manual delegation on so `parallel_task`/`task` stay registered + // even if config.acl disables them — else ultracode's fan-out calls + // an unregistered tool ("Unknown tool: parallel_task"). + .with_manual_delegation_enabled(true) + // Generous tool-round budget for every effort — Claude Code runs + // effectively unbounded; the old ~50 default cut real multi-step work + // (and many parallel subagents) short. ultracode widens it further. + .with_max_tool_rounds(200), + &self.workspace_manifest, + ); // Keep project instructions (CLAUDE.md) + any /compact summary across - // model/effort/compact rebuilds, injected into the system prompt. - let extra = match (&self.instructions, &self.compact_summary) { - (Some(i), Some(s)) => Some(format!("{i}\n\n# Earlier conversation (compacted)\n\n{s}")), - (Some(i), None) => Some(i.clone()), - (None, Some(s)) => Some(format!("# Earlier conversation (compacted)\n\n{s}")), - (None, None) => None, - }; + // model/effort/compact rebuilds, injected into the system prompt. When + // signed in, also steer the model to the progressive-API skill for OS + // questions (else "OS" reads as the local operating system → `whoami`). + let mut extra_parts: Vec = Vec::new(); + if let Some(i) = &self.instructions { + extra_parts.push(i.clone()); + } + if let Some(s) = &self.compact_summary { + extra_parts.push(format!("# Earlier conversation (compacted)\n\n{s}")); + } + if let Some(s) = &self.os_session { + extra_parts.push(os_platform_guide(&s.address)); + } + let extra = (!extra_parts.is_empty()).then(|| extra_parts.join("\n\n")); let ultra = self.effort == ULTRACODE; if extra.is_some() || ultra { let mut slots = SystemPromptSlots::default(); @@ -254,14 +298,18 @@ impl App { opts = opts.with_thinking_budget(EFFORT_LEVELS[self.effort].1); } if ultra { - // Dynamic-workflow mode: the model generates a `program` script that - // fans out via `parallel_task` (PTC dispatches on the multi-threaded - // runtime since 4.2.6). Not planning mode (mutually exclusive with - // auto-parallel fan-out). Steering is in the system prompt; here we - // just track the goal + widen the budget further (exhaustive mode). - opts = opts.with_goal_tracking(true).with_max_tool_rounds(500); + // Dynamic-workflow mode: planning is message-gated (Auto), so a turn + // plans + fans out only when the core's pre-analysis judges the task to + // warrant it — a trivial "hi" stays a direct answer. `Enabled` forced a + // plan every turn, which is what made ultracode explore on a greeting. + // The core runtime still upgrades independent plan waves into + // `parallel_task` subagents when auto-parallel delegation is enabled. + opts = opts + .with_planning_mode(a3s_code_core::PlanningMode::Auto) + .with_goal_tracking(true) + .with_max_tool_rounds(500); } - // Signed in via the /model Codex tab → route through the account client. + // Signed in via a /model account tab → route through that account client. if let Some(client) = &self.llm_override { opts = opts.with_llm_client(client.clone()); } @@ -304,7 +352,7 @@ impl App { if self.state != State::Idle { self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" finish the current turn before switching models"), ); return; @@ -313,16 +361,16 @@ impl App { Ok((s, _)) => { self.session = Arc::new(s); self.model = Some(model.to_string()); - self.context_limit = self.model_ctx.get(model).copied().unwrap_or(0); + self.context_limit = resolve_ctx_limit(self.model_ctx.get(model).copied()); self.push_line( &Style::new() - .fg(Color::Green) + .fg(TN_GREEN) .render(&format!(" ⇄ switched to {model}")), ); } Err(e) => self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(&format!(" failed to switch model: {e}")), ), } @@ -333,7 +381,7 @@ impl App { if self.state != State::Idle { self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" finish the current turn before changing effort"), ); return; @@ -351,21 +399,21 @@ impl App { " ◆ ultracode — planning a dynamic workflow + parallel subagents (auto-approve on)", )); } else if dropped { - self.push_line(&Style::new().fg(Color::BrightBlack).render(&format!( + self.push_line(&Style::new().fg(TN_GRAY).render(&format!( " ◇ effort: {} (this model uses its default depth)", EFFORT_LEVELS[self.effort].0 ))); } else { self.push_line( &Style::new() - .fg(Color::Green) + .fg(TN_GREEN) .render(&format!(" ◇ effort: {}", EFFORT_LEVELS[self.effort].0)), ); } } Err(e) => self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(&format!(" failed to set effort: {e}")), ), } @@ -407,18 +455,67 @@ impl App { menu.push(pad_to(&bar, width)); } let models = &tabs[t].models; - let last = models.len().saturating_sub(1); - for (i, m) in models.iter().enumerate().take(12) { - // Only config-tab models can be the active model (account tabs are - // informational until a3s can drive those APIs). - let cur = Some(m.as_str()) == self.model.as_deref() && tabs[t].provider.is_none(); + let total = models.len(); + // Scroll a window around the selection so a pick past row 12 stays visible + // and reachable (the list used to render a fixed first-12 only). + let sel = sel.min(total.saturating_sub(1)); + let max_rows = (self.height as usize).saturating_sub(8).clamp(3, 12); + let start = if sel < max_rows { + 0 + } else { + sel + 1 - max_rows + }; + let end = (start + max_rows).min(total); + for (i, m) in models.iter().enumerate().take(end).skip(start) { + let cur = Some(m.as_str()) == self.model.as_deref(); let raw = pad_to(&format!(" {} {m}", if cur { "●" } else { " " }), width); - menu.push(if i == sel.min(last) { + menu.push(if i == sel { Style::new().fg(Color::BrightWhite).bg(ACCENT).render(&raw) } else { - Style::new().fg(Color::BrightBlack).render(&raw) + Style::new().fg(TN_GRAY).render(&raw) }); } + if total > max_rows { + menu.push(pad_to( + &Style::new() + .fg(TN_GRAY) + .render(&format!(" {}/{total}", sel + 1)), + width, + )); + } self.overlay_list(composed, &menu) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_model_location_finds_account_tab_model() { + let tabs = vec![ + ModelTab { + label: "a3s-code", + color: A3S_COLOR, + models: vec!["openai/gpt-5".into()], + provider: None, + }, + ModelTab { + label: "Claude Code", + color: CLAUDE_COLOR, + models: vec!["claude-sonnet-4".into()], + provider: Some(AuthProvider::Claude), + }, + ]; + + assert_eq!( + selected_model_location(&tabs, Some("claude-sonnet-4")), + (1, 0) + ); + assert_eq!( + selected_model_location(&tabs, Some("claude-sonnet-4[1m]")), + (1, 0) + ); + assert_eq!(selected_model_location(&tabs, Some("missing")), (0, 0)); + } +} diff --git a/src/tui/panels/plan.rs b/src/tui/panels/plan.rs index 4405f1b..66f6b54 100644 --- a/src/tui/panels/plan.rs +++ b/src/tui/panels/plan.rs @@ -19,14 +19,14 @@ impl App { let cap = width.saturating_sub(8); let mut lines = vec![pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" ─ tasks · ✓ {} done ────────", self.completed)), width, )]; if let Some(t) = running { lines.push(pad_to( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(&format!(" ⏳ {}", truncate(t, cap))), width, )); @@ -36,7 +36,7 @@ impl App { for item in q.iter().take(6) { lines.push(pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" ▱ {}", truncate(&item.text, cap))), width, )); @@ -91,7 +91,7 @@ impl App { '✔' => ('✔', TN_GRAY, true, false), '▶' => ('◼', TN_ORANGE, false, true), '✗' => ('✗', TN_RED, false, false), - _ => ('◻', Color::BrightBlack, false, false), + _ => ('◻', TN_GRAY, false, false), }; let text_style = if done { Style::new().fg(*color).strikethrough() @@ -114,44 +114,110 @@ impl App { lines } - /// Bottom tracker for running parallel subagents (Claude-style): one row per - /// task with the agent type, description, elapsed time, and tokens. + /// Bottom tracker for parallel subagents (Claude-style): a durable summary + /// row plus live rows for agents still running. pub(crate) fn subagent_lines(&self) -> Vec { if self.subagents.is_empty() { return Vec::new(); } let width = self.width as usize; - let mut out = vec![pad_to( - &Style::new().fg(Color::White).bold().render(" ⏺ main"), - width, + let now = Instant::now(); + let total = self.subagents.len(); + let done = self.subagents.iter().filter(|s| s.done).count(); + let tokens = self.subagents.iter().map(|s| s.tokens).sum::(); + let started = self + .subagents + .iter() + .map(|s| s.started) + .min() + .unwrap_or(now); + let ended = if done == total { + self.subagents + .iter() + .filter_map(|s| s.ended) + .max() + .unwrap_or(now) + } else { + now + }; + let elapsed = ended.saturating_duration_since(started); + let status = if done == total { + format!("{done}/{total} agents done") + } else { + format!( + "{} running · {done}/{total} done", + total.saturating_sub(done) + ) + }; + let right = if tokens > 0 { + format!( + "{status} · {} · ↓ {} tokens", + fmt_elapsed(elapsed), + fmt_tokens(tokens) + ) + } else { + format!("{status} · {}", fmt_elapsed(elapsed)) + }; + let task = self + .running_task + .as_deref() + .unwrap_or("parallel agents") + .trim(); + let slug = workflow_slug(task); + let rlen = a3s_tui::style::visible_len(&right); + let maxleft = width.saturating_sub(rlen + 3).max(8); + let left = truncate(&format!(" ◯ {slug} {task}"), maxleft); + let pad = width.saturating_sub(a3s_tui::style::visible_len(&left) + rlen + 1); + let mut out = vec![format!( + "{}{}{}", + Style::new().fg(ACCENT).bold().render(&left), + " ".repeat(pad), + Style::new().fg(TN_GRAY).render(&right), )]; - for s in &self.subagents { - let secs = s.started.elapsed().as_secs(); - let el = if secs >= 60 { - format!("{}m {}s", secs / 60, secs % 60) - } else { - format!("{secs}s") - }; + + for s in self.subagents.iter().filter(|s| !s.done).take(4) { + let el = fmt_elapsed(s.started.elapsed()); let right = if s.tokens > 0 { format!("{el} · ↓ {} tokens", fmt_tokens(s.tokens)) } else { el }; - let glyph = if s.done { '●' } else { '◯' }; let rlen = a3s_tui::style::visible_len(&right); let maxleft = width.saturating_sub(rlen + 3).max(8); - let left = truncate( - &format!(" {glyph} {} {}", s.agent, s.description), - maxleft, - ); + let left = truncate(&format!(" ◯ {} {}", s.agent, s.description), maxleft); let pad = width.saturating_sub(a3s_tui::style::visible_len(&left) + rlen + 1); out.push(format!( "{}{}{}", - Style::new().fg(Color::Magenta).render(&left), + Style::new().fg(TN_PURPLE).render(&left), " ".repeat(pad), - Style::new().fg(Color::BrightBlack).render(&right), + Style::new().fg(TN_GRAY).render(&right), )); } out } } + +fn workflow_slug(text: &str) -> String { + let mut slug = String::new(); + let mut last_dash = false; + for ch in text.chars().flat_map(|ch| ch.to_lowercase()) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_dash = false; + } else if !last_dash && !slug.is_empty() { + slug.push('-'); + last_dash = true; + } + if slug.len() >= 24 { + break; + } + } + while slug.ends_with('-') { + slug.pop(); + } + if slug.is_empty() { + "parallel-agents".to_string() + } else { + slug + } +} diff --git a/src/tui/panels/plugins.rs b/src/tui/panels/plugins.rs index ed240ca..d4c39f1 100644 --- a/src/tui/panels/plugins.rs +++ b/src/tui/panels/plugins.rs @@ -34,21 +34,19 @@ impl App { let on = !self.disabled_skills.contains(name); let marker = if i == sel { "▸" } else { " " }; let check = if on { - Style::new().fg(Color::Green).render("[✓]") + Style::new().fg(TN_GREEN).render("[✓]") } else { - Style::new().fg(Color::BrightBlack).render("[ ]") + Style::new().fg(TN_GRAY).render("[ ]") }; let nm_plain = format!("{:<16}", truncate(&format!("/{name}"), 16)); let nm = if on { - Style::new().fg(Color::Cyan).render(&nm_plain) + Style::new().fg(TN_CYAN).render(&nm_plain) } else { - Style::new().fg(Color::BrightBlack).render(&nm_plain) + Style::new().fg(TN_GRAY).render(&nm_plain) }; let raw = format!( " {marker} {check} {nm} {}", - Style::new() - .fg(Color::BrightBlack) - .render(&truncate(desc, descw)), + Style::new().fg(TN_GRAY).render(&truncate(desc, descw)), ); menu.push(pad_to(&raw, width)); } @@ -57,7 +55,7 @@ impl App { let down = if end < total { "↓" } else { " " }; menu.push(pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" {up}{down} {}/{total}", sel + 1)), width, )); diff --git a/src/tui/panels/relay.rs b/src/tui/panels/relay.rs index 1bc9d52..99e8c3d 100644 --- a/src/tui/panels/relay.rs +++ b/src/tui/panels/relay.rs @@ -67,11 +67,15 @@ impl App { (s.native_id.clone(), s.seed.clone(), s.agent) }; if let Some(id) = native_id { - let mut opts = SessionOptions::new() - .with_session_store(self.store.clone()) - .with_session_id(id.as_str()) - .with_confirmation_policy(self.confirmation.clone()) - .with_auto_save(true); + let mut opts = with_recent_workspace_context( + SessionOptions::new() + .with_session_store(self.store.clone()) + .with_session_id(id.as_str()) + .with_confirmation_policy(self.confirmation.clone()) + .with_workspace_backend(self.workspace_services.clone()) + .with_auto_save(true), + &self.workspace_manifest, + ); // Resume under the CURRENT model, not whatever the saved session used // (e.g. a smoke-test's gpt-4o that this config doesn't have). if let Some(m) = self.model.clone().or_else(|| self.models.first().cloned()) { @@ -93,20 +97,20 @@ impl App { "assistant" => { let mut md = StreamingMarkdown::new(w); md.push(&text); - self.messages.push(gutter(Color::Green, &md.view())); + self.messages.push(gutter(TN_GREEN, &md.view())); } _ => {} } } self.push_line( &Style::new() - .fg(Color::Green) + .fg(TN_GREEN) .render(&format!(" ⮌ resumed a3s-code session {id}")), ); } Err(e) => self.push_line( &Style::new() - .fg(Color::Red) + .fg(TN_RED) .render(&format!(" failed to resume: {e}")), ), } @@ -115,13 +119,13 @@ impl App { if self.state != State::Idle { self.push_line( &Style::new() - .fg(Color::Yellow) + .fg(TN_YELLOW) .render(" finish the current turn before relaying"), ); return None; } self.messages.push(gutter( - Color::Magenta, + TN_PURPLE, &format!("⮌ relaying from {agent}: {}", truncate(&seed, 60)), )); self.start_stream(format!( @@ -165,7 +169,7 @@ impl App { pad_to(&strip, width), pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(" ←/→ agent · ↑/↓ session · Enter continue · Esc"), width, ), @@ -176,23 +180,42 @@ impl App { if idxs.is_empty() { menu.push(pad_to( &Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" (no {active} sessions for this directory)")), width, )); } - for (row, &gi) in idxs.iter().enumerate().take(12) { + // Scroll a window around the selection so a session past row 12 stays + // visible and reachable (the list used to render a fixed first-12 only). + let total = idxs.len(); + let sel = sel.min(total.saturating_sub(1)); + let max_rows = (self.height as usize).saturating_sub(8).clamp(3, 12); + let start = if sel < max_rows { + 0 + } else { + sel + 1 - max_rows + }; + let end = (start + max_rows).min(total); + for (row, &gi) in idxs.iter().enumerate().take(end).skip(start) { let s = &self.relay[gi]; let raw = pad_to( &format!(" {}", truncate(&s.label, width.saturating_sub(4))), width, ); - menu.push(if row == sel.min(idxs.len().saturating_sub(1)) { + menu.push(if row == sel { Style::new().fg(Color::Black).bg(color).render(&raw) } else { Style::new().fg(color).render(&raw) }); } + if total > max_rows { + menu.push(pad_to( + &Style::new() + .fg(TN_GRAY) + .render(&format!(" {}/{total}", sel + 1)), + width, + )); + } self.overlay_list(composed, &menu) } } diff --git a/src/tui/panels/theme.rs b/src/tui/panels/theme.rs index c987f0a..ede44c7 100644 --- a/src/tui/panels/theme.rs +++ b/src/tui/panels/theme.rs @@ -22,13 +22,11 @@ impl App { menu.push(if i == sel { Style::new().fg(Color::BrightWhite).bg(ACCENT).render(&raw) } else { - Style::new().fg(Color::BrightBlack).render(&raw) + Style::new().fg(TN_GRAY).render(&raw) }); } menu.push(pad_to( - &Style::new() - .fg(Color::BrightBlack) - .render(" ── preview ──"), + &Style::new().fg(TN_GRAY).render(" ── preview ──"), width, )); let th = &THEMES[sel]; diff --git a/src/tui/panels/top.rs b/src/tui/panels/top.rs index 24c668c..26c707b 100644 --- a/src/tui/panels/top.rs +++ b/src/tui/panels/top.rs @@ -1,48 +1,66 @@ -//! `/top` process monitor panel; coding-agent rows are highlighted. +//! `/top` process monitor panel. Reuses `a3s top`'s shared process-table view +//! so the panel and the standalone monitor agree on columns, colours, agent +//! detection, and risk. Enter drills into a coding agent's process subtree. use super::super::*; impl App { - /// Full-screen `/top` process monitor; coding-agent rows are highlighted. - pub(crate) fn render_top_panel(&self, rows: &[ProcRow]) -> String { + /// Rows currently shown in `/top`: the focused agent's process subtree, or + /// all processes when not focused. + pub(crate) fn top_rows(&self) -> Vec { + let Some(all) = &self.top else { + return Vec::new(); + }; + match self.top_focus { + Some(root) => process_subtree(all, root), + None => all.clone(), + } + } + + /// Full-screen `/top` monitor; coding-agent rows are highlighted and can be + /// drilled into. The body is rendered by the shared `a3s top` renderer. + pub(crate) fn render_top_panel(&self) -> String { let width = self.width as usize; let h = self.height as usize; + let rows = self.top_rows(); let agents = rows.iter().filter(|r| r.agent.is_some()).count(); - let title = Style::new().fg(ACCENT).bold().render(&format!( - " /top — {} processes · {agents} coding agent(s) · Enter to kill", - rows.len() - )); - let mut out = vec![ - pad_to(&title, width), - pad_to( - &Style::new().fg(Color::BrightBlack).render( - " PID CPU% MEM% COMMAND Esc close · ↑/↓ select", - ), - width, - ), - ]; - let body = h.saturating_sub(3); - let start = self - .top_scroll - .min(rows.len().saturating_sub(body.min(rows.len()))); - for (i, r) in rows.iter().enumerate().skip(start).take(body) { - let cmd = truncate(&r.cmd, width.saturating_sub(44).max(10)); - let tag = r.agent.map(|a| format!(" ◀ {a}")).unwrap_or_default(); - let raw = pad_to( - &format!(" {:<7} {:>5.1} {:>5.1} {cmd}{tag}", r.pid, r.cpu, r.mem), - width, - ); - // Agent rows wear their brand colour; the selected row inverts it. - let color = r.agent.map(agent_color).unwrap_or(Color::White); - let styled = if i == self.top_sel { - Style::new().fg(Color::Black).bg(color).bold().render(&raw) - } else if r.agent.is_some() { - Style::new().fg(color).bold().render(&raw) - } else { - Style::new().fg(Color::White).render(&raw) - }; - out.push(styled); - } + + let title = match self.top_focus { + Some(pid) => { + let label = self + .top + .as_ref() + .and_then(|all| all.iter().find(|r| r.pid == pid)) + .and_then(|r| r.agent.map(|a| a.label())) + .unwrap_or("agent"); + Style::new().fg(ACCENT).bold().render(&format!( + " /top ▸ {label} (pid {pid}) — {} processes · Esc back · K kill", + rows.len() + )) + } + None => Style::new().fg(ACCENT).bold().render(&format!( + " /top — {} processes · {agents} agent(s) · Enter focus agent · K kill · Esc close", + rows.len() + )), + }; + + // Body via the shared renderer; the panel has no per-pid history, so the + // sparkline columns render blank (graceful degradation). + let hidden = HashSet::new(); + let table = render_process_table( + &rows, + &ProcessTableView { + selected: self.top_sel, + scroll: self.top_scroll, + width: self.width, + height: h.saturating_sub(1).max(1), + hidden: &hidden, + history: None, + }, + ); + + let mut out = vec![pad_to(&title, width)]; + out.extend(table.lines().map(str::to_string)); while out.len() < h { out.push(String::new()); } @@ -73,7 +91,7 @@ impl App { if let Some(slot) = out.get_mut(row0 + k) { let styled = Style::new() .fg(Color::BrightWhite) - .bg(Color::Red) + .bg(TN_RED) .bold() .render(line); *slot = format!("{}{styled}", " ".repeat(col0)); @@ -83,3 +101,26 @@ impl App { out.join("\n") } } + +/// All processes in `root`'s subtree (root + transitive children by ppid), +/// preserving the input order. +// ponytail: O(n²) fixpoint over the process list; n is small (host processes). +fn process_subtree(rows: &[ProcessRow], root: u32) -> Vec { + let mut included: HashSet = HashSet::from([root]); + loop { + let mut added = false; + for r in rows { + if !included.contains(&r.pid) && included.contains(&r.ppid) { + included.insert(r.pid); + added = true; + } + } + if !added { + break; + } + } + rows.iter() + .filter(|r| included.contains(&r.pid)) + .cloned() + .collect() +} diff --git a/src/tui/render.rs b/src/tui/render.rs index 769ffd9..1e97734 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -36,7 +36,7 @@ pub(crate) fn render_tool_end( // Header: "• Ran npm test" / "• Read src/main.rs" — Codex style, a past-tense // verb + the arg, the bullet colored by outcome. let dot = Style::new() - .fg(if ok { Color::Green } else { Color::Red }) + .fg(if ok { TN_GREEN } else { TN_RED }) .bold() .render("•"); let arg = args.and_then(arg_summary).unwrap_or_default(); @@ -49,7 +49,7 @@ pub(crate) fn render_tool_end( format!( "{margin}{dot} {} {}", Style::new().bold().render(tool_verb(name)), - Style::new().fg(Color::BrightBlack).render(&arg) + Style::new().fg(TN_GRAY).render(&arg) ) }; @@ -75,6 +75,12 @@ pub(crate) fn render_tool_end( } } + if matches!(name, "task" | "parallel_task") { + if let Some(summary) = render_task_tool_summary(name, output, meta, ok, width) { + return format!("{header}{summary}"); + } + } + // Show only the latest TAIL output lines under a "⎿" connector, with a // "… +N earlier lines" marker when there's more (keeps a noisy build tight). const TAIL: usize = 5; @@ -82,8 +88,8 @@ pub(crate) fn render_tool_end( if lines.is_empty() { return header; } - let body_color = if ok { Color::BrightBlack } else { Color::Red }; - let conn = Style::new().fg(Color::BrightBlack).render("⎿"); + let body_color = if ok { TN_GRAY } else { TN_RED }; + let conn = Style::new().fg(TN_GRAY).render("⎿"); let textw = width.saturating_sub(PAD + 7).max(20); let line_at = |i: usize, line: &str| -> String { let shown = truncate(line, textw); @@ -105,7 +111,7 @@ pub(crate) fn render_tool_end( out.push_str(&format!( "\n{margin} {conn} {}", Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!("… +{start} earlier lines")) )); for line in lines.iter().skip(start) { @@ -119,6 +125,153 @@ pub(crate) fn render_tool_end( out } +fn render_task_tool_summary( + name: &str, + output: &str, + meta: Option<&serde_json::Value>, + ok: bool, + width: usize, +) -> Option { + let meta = meta?; + match name { + "task" => render_single_task_summary(output, meta, ok, width), + "parallel_task" => render_parallel_task_summary(meta, ok, width), + _ => None, + } +} + +fn render_single_task_summary( + output: &str, + meta: &serde_json::Value, + ok: bool, + width: usize, +) -> Option { + let agent = meta + .get("agent") + .and_then(|v| v.as_str()) + .unwrap_or("agent"); + let task_id = meta.get("task_id").and_then(|v| v.as_str()).unwrap_or(""); + let success = meta.get("success").and_then(|v| v.as_bool()).unwrap_or(ok); + let status = if success { "completed" } else { "failed" }; + let output_bytes = meta.get("output_bytes").and_then(|v| v.as_u64()); + let artifact = meta.get("artifact_uri").and_then(|v| v.as_str()); + let mut rows = vec![format!( + "Task {status} · {agent}{}", + task_id_suffix(task_id) + )]; + if let Some(excerpt) = task_child_excerpt(output) { + rows.extend(excerpt.lines().map(str::to_string)); + } else if output_bytes == Some(0) { + rows.push("no child text output; using plan/status for synthesis".to_string()); + } else { + rows.push("child output stored in task artifact".to_string()); + } + if let Some(uri) = artifact { + rows.push(format!("artifact: {}", truncate(uri, 96))); + } + Some(render_task_rows(&rows, success, width)) +} + +fn render_parallel_task_summary( + meta: &serde_json::Value, + ok: bool, + width: usize, +) -> Option { + let results = meta.get("results").and_then(|v| v.as_array())?; + if results.is_empty() { + return None; + } + let done = results + .iter() + .filter(|r| r.get("success").and_then(|v| v.as_bool()).unwrap_or(ok)) + .count(); + let mut rows = vec![format!("{done}/{} agents done", results.len())]; + for result in results.iter().take(4) { + let success = result + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(ok); + let mark = if success { "✓" } else { "✗" }; + let agent = result + .get("agent") + .and_then(|v| v.as_str()) + .unwrap_or("agent"); + let task_id = result.get("task_id").and_then(|v| v.as_str()).unwrap_or(""); + let output_bytes = result.get("output_bytes").and_then(|v| v.as_u64()); + let formatted = result.get("output").and_then(|v| v.as_str()).unwrap_or(""); + let detail = if let Some(excerpt) = task_child_excerpt(formatted) { + truncate(&excerpt.replace('\n', " "), 120) + } else if output_bytes == Some(0) { + "no child text output".to_string() + } else { + "output stored in artifact".to_string() + }; + rows.push(format!( + "{mark} {agent}{} · {detail}", + task_id_suffix(task_id) + )); + } + let more = results.len().saturating_sub(4); + if more > 0 { + rows.push(format!("+{more} more agent result(s)")); + } + Some(render_task_rows(&rows, ok, width)) +} + +fn render_task_rows(rows: &[String], ok: bool, width: usize) -> String { + let margin = " ".repeat(PAD); + let conn = Style::new().fg(TN_GRAY).render("⎿"); + let body_color = if ok { TN_GRAY } else { TN_RED }; + let textw = width.saturating_sub(PAD + 7).max(20); + let mut out = String::new(); + for (i, row) in rows.iter().enumerate() { + let shown = truncate(row, textw); + if i == 0 { + out.push_str(&format!( + "\n{margin} {conn} {}", + Style::new().fg(body_color).render(&shown) + )); + } else { + out.push_str(&format!( + "\n{margin} {}", + Style::new().fg(body_color).render(&shown) + )); + } + } + out +} + +fn task_child_excerpt(formatted: &str) -> Option { + let tail = formatted + .split_once("Output:\n") + .map(|(_, tail)| tail) + .or_else(|| { + formatted + .split_once("Output excerpt:") + .map(|(_, tail)| tail) + })?; + let lines = tail + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .take(3) + .map(str::to_string) + .collect::>(); + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +fn task_id_suffix(task_id: &str) -> String { + if task_id.is_empty() { + String::new() + } else { + format!(" · {}", truncate(task_id, 24)) + } +} + /// Codex-style past-tense action verb for a completed tool call. pub(crate) fn tool_verb(name: &str) -> &str { match name { @@ -251,7 +404,7 @@ pub(crate) fn render_diff(path: &str, before: &str, after: &str, width: usize) - if gi > 0 { lines.push( Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!(" {} ⋮", " ".repeat(nw))), ); } @@ -284,7 +437,7 @@ pub(crate) fn render_diff(path: &str, before: &str, after: &str, width: usize) - change.old_index().map(|i| i + 1).unwrap_or(0), ' ', None, - Color::BrightBlack, + TN_GRAY, ), }; for (si, seg) in wrap_plain(raw, code_w).iter().enumerate() { @@ -314,18 +467,14 @@ pub(crate) fn render_diff(path: &str, before: &str, after: &str, width: usize) - } } if truncated { - lines.push( - Style::new() - .fg(Color::BrightBlack) - .render(" … (diff truncated)"), - ); + lines.push(Style::new().fg(TN_GRAY).render(" … (diff truncated)")); } let mut out = format!( " {} {} {}", - Style::new().fg(Color::Green).bold().render("•"), + Style::new().fg(TN_GREEN).bold().render("•"), Style::new().render(&format!("Edited {path}")), Style::new() - .fg(Color::BrightBlack) + .fg(TN_GRAY) .render(&format!("(+{adds} -{dels})")), ); if !lines.is_empty() { diff --git a/src/tui/util.rs b/src/tui/util.rs index 799e980..628b437 100644 --- a/src/tui/util.rs +++ b/src/tui/util.rs @@ -32,10 +32,7 @@ pub(crate) fn user_bubble(content: &str, width: usize) -> String { }; format!( "{margin}{}", - Style::new() - .fg(Color::White) - .bg(bg) - .render(&pad_to(&inner, bar)) + Style::new().fg(TN_FG).bg(bg).render(&pad_to(&inner, bar)) ) }) .collect::>() @@ -59,14 +56,63 @@ pub(crate) fn gutter(color: Color, content: &str) -> String { .join("\n") } -/// Indent every line of `content` by `cols` spaces (keeps blocks off the edge). -pub(crate) fn indent(content: &str, cols: usize) -> String { - let pad = " ".repeat(cols); - content - .lines() - .map(|l| format!("{pad}{l}")) - .collect::>() - .join("\n") +/// Greedy word-wrap of plain (unstyled) text to `width` display columns, with +/// blank lines dropped so a preview stays single-spaced. Used for the reasoning +/// ("thinking") block so it lays out like other messages instead of being one +/// giant line the viewport re-wraps badly. Input must be unstyled — width is +/// counted in chars, which only holds without ANSI escapes. +pub(crate) fn wrap_words(text: &str, width: usize) -> Vec { + // Widths are counted in DISPLAY COLUMNS, not chars — CJK runs (which + // `split_whitespace` keeps as one token) are 2 columns/char and would + // otherwise overflow when the hard-break took `width` chars. + use a3s_tui::style::visible_len as col; + if width == 0 { + return vec![text.to_string()]; + } + let mut out = Vec::new(); + for para in text.lines() { + if para.trim().is_empty() { + continue; // collapse blank lines — keep the preview compact + } + let mut line = String::new(); + for word in para.split_whitespace() { + if line.is_empty() { + line.push_str(word); + } else if col(&line) + 1 + col(word) <= width { + line.push(' '); + line.push_str(word); + } else { + out.push(std::mem::take(&mut line)); + line.push_str(word); + } + // Hard-break a token wider than the whole line, by column budget. + while col(&line) > width { + let mut head = String::new(); + let mut w = 0usize; + for ch in line.chars() { + let cw = col(&ch.to_string()).max(1); + if w + cw > width { + break; + } + w += cw; + head.push(ch); + } + if head.is_empty() { + break; // width too small for even one char — avoid a loop + } + let rest: String = line.chars().skip(head.chars().count()).collect(); + out.push(head); + line = rest; + } + } + if !line.is_empty() { + out.push(line); + } + } + if out.is_empty() { + out.push(String::new()); + } + out } /// Byte offset of the char at index `char_idx` (for in-place string edits). @@ -137,11 +183,72 @@ pub(crate) fn shimmer(text: &str, phase: usize) -> String { out } +/// Truncate to `max` DISPLAY COLUMNS (not chars) with an ellipsis. Callers pass +/// a column budget (panel widths), so counting chars overflowed the fixed-height +/// panels on CJK/wide text (every CJK char is 2 columns) and corrupted the +/// layout. Delegates to the width-aware, ANSI-preserving tui helper. pub(crate) fn truncate(s: &str, max: usize) -> String { - if s.chars().count() <= max { - s.to_string() - } else { - let head: String = s.chars().take(max).collect(); - format!("{head}…") + a3s_tui::style::truncate_visible(s, max) +} + +#[cfg(test)] +mod tests { + use super::{truncate, wrap_words}; + + #[test] + fn wraps_on_word_boundaries_without_splitting_words() { + let lines = wrap_words("the quick brown fox jumps", 9); + assert!(lines.iter().all(|l| l.chars().count() <= 9), "{lines:?}"); + // No word is broken: rejoining with spaces reproduces the input words. + assert_eq!( + lines.join(" ").split_whitespace().collect::>(), + vec!["the", "quick", "brown", "fox", "jumps"] + ); + } + + #[test] + fn collapses_blank_lines_to_stay_single_spaced() { + let lines = wrap_words("alpha\n\n\nbeta", 40); + assert_eq!(lines, vec!["alpha".to_string(), "beta".to_string()]); + } + + #[test] + fn hard_breaks_a_word_longer_than_width() { + let lines = wrap_words("supercalifragilistic", 5); + assert!(lines.iter().all(|l| l.chars().count() <= 5), "{lines:?}"); + assert_eq!(lines.concat(), "supercalifragilistic"); + } + + #[test] + fn never_returns_empty_for_blank_input() { + assert_eq!(wrap_words(" ", 10), vec![String::new()]); + } + + #[test] + fn wrap_words_counts_display_columns_for_cjk() { + // 6 CJK chars = 12 columns; CJK has no spaces so it's one token that + // must hard-break by COLUMN budget, never exceeding the width. + let lines = wrap_words("中文测试内容", 8); + for l in &lines { + assert!( + a3s_tui::style::visible_len(l) <= 8, + "line wider than 8 columns: {l:?}" + ); + } + assert_eq!(lines.concat(), "中文测试内容"); + } + + #[test] + fn truncate_budgets_display_columns_not_chars() { + // 5 CJK chars = 10 columns; a 6-column budget must fit (≤ 6 cols incl …), + // which char-counting would have overflowed to ~10 columns. + let out = truncate("一二三四五", 6); + assert!( + a3s_tui::style::visible_len(&out) <= 6, + "truncated string exceeds 6 columns: {out:?}" + ); + assert!(out.ends_with('…')); + // Fits-as-is when within budget. + assert_eq!(truncate("ok", 6), "ok"); } } diff --git a/src/update.rs b/src/update.rs index 1e7f15a..cb1507b 100644 --- a/src/update.rs +++ b/src/update.rs @@ -4,17 +4,75 @@ //! binary download** if brew or the tap is in any bad state — so an update can //! never be blocked again by a stale tap clone or a broken `brew upgrade`. -use std::path::PathBuf; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; use std::process::Command; -/// `[0,2,6] >= [0,2,5]` — `Vec` compares lexicographically = semver order. +struct CommandOutput { + success: bool, + stdout: Vec, +} + +trait CommandRunner { + fn output(&self, program: &OsStr, args: &[OsString]) -> Option; + fn status(&self, program: &OsStr, args: &[OsString]) -> bool; +} + +struct RealCommandRunner; + +impl CommandRunner for RealCommandRunner { + fn output(&self, program: &OsStr, args: &[OsString]) -> Option { + let out = Command::new(program).args(args).output().ok()?; + Some(CommandOutput { + success: out.status.success(), + stdout: out.stdout, + }) + } + + fn status(&self, program: &OsStr, args: &[OsString]) -> bool { + Command::new(program) + .args(args) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} + +fn args(items: &[&str]) -> Vec { + items.iter().map(OsString::from).collect() +} + +fn numeric_version_parts(s: &str) -> Vec { + let trimmed = s.trim().trim_start_matches('v'); + let core = trimmed.split(['-', '+']).next().unwrap_or(trimmed); + let mut parts = Vec::new(); + for part in core.split('.') { + let digits = part + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.is_empty() { + break; + } + match digits.parse::() { + Ok(n) => parts.push(n), + Err(_) => break, + } + } + parts +} + +/// Compare stable numeric version components with optional `v` prefixes. pub(crate) fn version_ge(a: &str, b: &str) -> bool { - let parse = |s: &str| { - s.split('.') - .filter_map(|x| x.parse::().ok()) - .collect::>() - }; - parse(a) >= parse(b) + let mut av = numeric_version_parts(a); + let mut bv = numeric_version_parts(b); + if av.is_empty() || bv.is_empty() { + return false; + } + let len = av.len().max(bv.len()); + av.resize(len, 0); + bv.resize(len, 0); + av >= bv } /// Latest release version tag from GitHub (no leading `v`), or `None` if the @@ -45,11 +103,24 @@ pub(crate) fn fetch_latest() -> Option { /// Extract `X.Y.Z` from a `…/releases/tag/vX.Y.Z` URL. fn version_from_release_url(url: &str) -> Option { url.trim() - .rsplit_once("/tag/v") - .map(|(_, v)| v.trim().to_string()) + .rsplit_once("/tag/") + .map(|(_, v)| v.trim().trim_start_matches('v').to_string()) .filter(|v| !v.is_empty()) } +fn version_from_output(text: &str) -> Option { + for token in text.split(|c: char| { + !(c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+' || c == 'v') + }) { + let token = token.trim().trim_start_matches('v'); + let core = token.split(['-', '+']).next().unwrap_or(token); + if numeric_version_parts(core).len() >= 2 { + return Some(core.to_string()); + } + } + None +} + /// GitHub release target triple for this platform, or `None` if unsupported /// (e.g. Windows) — those fall back to a manual download. pub(crate) fn release_target() -> Option<&'static str> { @@ -67,23 +138,34 @@ pub(crate) fn can_self_update() -> bool { release_target().is_some() } -fn brew_manages_a3s() -> bool { - Command::new("brew") - .args(["list", "--versions", "a3s"]) - .output() - .map(|o| o.status.success() && !o.stdout.is_empty()) +fn brew_manages_a3s(runner: &impl CommandRunner) -> bool { + runner + .output(OsStr::new("brew"), &args(&["list", "--versions", "a3s"])) + .map(|o| o.success && !o.stdout.is_empty()) .unwrap_or(false) } -fn brew_has_version(v: &str) -> bool { - Command::new("brew") - .args(["list", "--versions", "a3s"]) - .output() - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).contains(v)) +fn brew_has_version(runner: &impl CommandRunner, v: &str) -> bool { + runner + .output(OsStr::new("brew"), &args(&["list", "--versions", "a3s"])) + .map(|o| o.success && String::from_utf8_lossy(&o.stdout).contains(v)) .unwrap_or(false) } +fn verify_binary_version( + runner: &impl CommandRunner, + bin: impl AsRef, + latest: &str, +) -> Option { + let out = runner.output(bin.as_ref(), &[OsString::from("--version")])?; + if !out.success { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let version = version_from_output(&text)?; + version_ge(&version, latest).then_some(version) +} + /// Upgrade to `latest` in place. Returns the binary to exec on success — /// Homebrew repoints `a3s` on PATH (exec by name); a direct download swaps /// `current_exe` (exec that path) — or `None` if every path failed. @@ -91,74 +173,113 @@ fn brew_has_version(v: &str) -> bool { /// Run after the TUI has exited (terminal restored) so child stdio shows real /// download/upgrade progress. pub(crate) fn perform_upgrade(latest: &str) -> Option { - if brew_manages_a3s() { + let runner = RealCommandRunner; + let exe = std::env::current_exe().ok()?; + perform_upgrade_with(latest, &runner, exe) +} + +fn perform_upgrade_with( + latest: &str, + runner: &impl CommandRunner, + current_exe: PathBuf, +) -> Option { + if latest.trim().is_empty() { + return None; + } + + if brew_manages_a3s(runner) { // `brew upgrade` reads a *cached* formula — refresh the tap first, else // it no-ops with "already installed". Prefer a fast targeted git pull, // fall back to a full `brew update`. - let tap = Command::new("brew") - .args(["--repo", "a3s-lab/tap"]) - .output() - .ok() - .filter(|o| o.status.success()) + let tap = runner + .output(OsStr::new("brew"), &args(&["--repo", "a3s-lab/tap"])) + .filter(|o| o.success) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()); let pulled = tap .as_ref() .map(|r| { - Command::new("git") - .args(["-C", r, "pull", "--quiet", "--ff-only"]) - .status() - .map(|s| s.success()) - .unwrap_or(false) + runner.status( + OsStr::new("git"), + &[ + OsString::from("-C"), + OsString::from(r), + OsString::from("pull"), + OsString::from("--quiet"), + OsString::from("--ff-only"), + ], + ) }) .unwrap_or(false); if !pulled { - let _ = Command::new("brew").arg("update").status(); + let _ = runner.status(OsStr::new("brew"), &args(&["update"])); } println!("\n⬇ upgrading a3s {latest} via Homebrew…\n"); - let _ = Command::new("brew").args(["upgrade", "a3s"]).status(); - // Verify (brew exits 0 on a no-op). If it didn't take, don't give up — - // fall through to a direct binary download. - if brew_has_version(latest) { + let _ = runner.status(OsStr::new("brew"), &args(&["upgrade", "a3s"])); + let brew_bin = PathBuf::from("a3s"); + if verify_binary_version(runner, brew_bin.as_os_str(), latest).is_some() { return Some(PathBuf::from("a3s")); } - eprintln!("\n⚠ Homebrew didn't pick up {latest} — falling back to a direct download…"); + + // Homebrew metadata can claim the new version while PATH still runs an + // older binary (stale link, failed pour, or partial tap refresh). Reinstall + // once before falling back to the standalone updater. + if brew_has_version(runner, latest) { + eprintln!( + "\n⚠ Homebrew metadata says {latest}, but `a3s --version` did not — reinstalling…" + ); + let _ = runner.status(OsStr::new("brew"), &args(&["reinstall", "a3s"])); + if verify_binary_version(runner, brew_bin.as_os_str(), latest).is_some() { + return Some(PathBuf::from("a3s")); + } + } + + eprintln!("\n⚠ Homebrew didn't install a3s {latest} — falling back to a direct download…"); } - standalone_upgrade(latest) + standalone_upgrade_with(latest, runner, current_exe) } -/// Download the release tarball for this platform and swap it over the running -/// binary (works for curl/manual installs, and as the brew fallback). -fn standalone_upgrade(latest: &str) -> Option { +fn standalone_upgrade_with( + latest: &str, + runner: &impl CommandRunner, + exe: PathBuf, +) -> Option { let target = release_target()?; - let exe = std::env::current_exe().ok()?; let url = format!( "https://github.com/A3S-Lab/Cli/releases/download/v{latest}/a3s-v{latest}-{target}.tar.gz" ); - let tmp = std::env::temp_dir().join(format!("a3s-update-{}", std::process::id())); - let _ = std::fs::create_dir_all(&tmp); + let tmp = unique_update_dir(); + if std::fs::create_dir_all(&tmp).is_err() { + return None; + } let tarball = tmp.join("a3s.tar.gz"); println!("\n⬇ downloading a3s {latest}…\n"); - let dl = Command::new("curl") - .args(["-fL", "--progress-bar", "-o"]) - .arg(&tarball) - .arg(&url) - .status() - .map(|s| s.success()) - .unwrap_or(false); + let dl = runner.status( + OsStr::new("curl"), + &[ + OsString::from("-fL"), + OsString::from("--progress-bar"), + OsString::from("-o"), + tarball.as_os_str().to_os_string(), + OsString::from(&url), + ], + ); if !dl { + let _ = std::fs::remove_dir_all(&tmp); return None; } - let extracted = Command::new("tar") - .arg("xzf") - .arg(&tarball) - .arg("-C") - .arg(&tmp) - .status() - .map(|s| s.success()) - .unwrap_or(false); + let extracted = runner.status( + OsStr::new("tar"), + &[ + OsString::from("xzf"), + tarball.as_os_str().to_os_string(), + OsString::from("-C"), + tmp.as_os_str().to_os_string(), + ], + ); let new_bin = tmp.join("a3s"); if !extracted || !new_bin.exists() { + let _ = std::fs::remove_dir_all(&tmp); return None; } #[cfg(unix)] @@ -166,16 +287,103 @@ fn standalone_upgrade(latest: &str) -> Option { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(&new_bin, std::fs::Permissions::from_mode(0o755)); } - // Rename works over a running binary on unix; fall back to copy across FS. - std::fs::rename(&new_bin, &exe) - .or_else(|_| std::fs::copy(&new_bin, &exe).map(|_| ())) - .is_ok() - .then_some(exe) + if verify_binary_version(runner, new_bin.as_os_str(), latest).is_none() { + eprintln!("\n✗ downloaded a3s did not report version {latest}"); + let _ = std::fs::remove_dir_all(&tmp); + return None; + } + let result = match swap_binary_and_verify(runner, &new_bin, &exe, latest) { + Ok(()) => Some(exe), + Err(err) => { + eprintln!("\n✗ failed to install downloaded a3s: {err}"); + None + } + }; + let _ = std::fs::remove_dir_all(&tmp); + result +} + +fn unique_update_dir() -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + std::env::temp_dir().join(format!("a3s-update-{}-{nanos}", std::process::id())) +} + +fn sibling_temp_path(target: &Path, suffix: &str) -> Option { + let dir = target.parent()?; + let name = target.file_name()?.to_string_lossy(); + Some(dir.join(format!( + ".{name}.a3s-update-{}.{suffix}", + std::process::id() + ))) +} + +fn copy_executable(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::copy(src, dst).map(|_| ())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dst, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +fn swap_binary_and_verify( + runner: &impl CommandRunner, + new_bin: &Path, + target: &Path, + latest: &str, +) -> Result<(), String> { + let staging = sibling_temp_path(target, "new") + .ok_or_else(|| format!("cannot derive staging path for {}", target.display()))?; + let backup = sibling_temp_path(target, "bak") + .ok_or_else(|| format!("cannot derive backup path for {}", target.display()))?; + + let _ = std::fs::remove_file(&staging); + let _ = std::fs::remove_file(&backup); + + copy_executable(new_bin, &staging) + .map_err(|e| format!("copy {} to {}: {e}", new_bin.display(), staging.display()))?; + + std::fs::hard_link(target, &backup) + .or_else(|_| std::fs::copy(target, &backup).map(|_| ())) + .map_err(|e| format!("backup {} to {}: {e}", target.display(), backup.display()))?; + + if let Err(err) = std::fs::rename(&staging, target) { + let _ = std::fs::remove_file(&staging); + let _ = std::fs::remove_file(&backup); + return Err(format!( + "rename {} over {}: {err}", + staging.display(), + target.display() + )); + } + + if verify_binary_version(runner, target.as_os_str(), latest).is_some() { + let _ = std::fs::remove_file(&backup); + return Ok(()); + } + + std::fs::rename(&backup, target).map_err(|e| { + format!( + "restore {} from {}: {e}", + target.display(), + backup.display() + ) + })?; + Err(format!( + "{} did not report version {latest} after replacement", + target.display() + )) } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; #[test] fn version_ordering() { @@ -183,6 +391,9 @@ mod tests { assert!(version_ge("0.5.5", "0.5.5")); assert!(!version_ge("0.5.4", "0.5.5")); assert!(version_ge("1.0.0", "0.9.9")); + assert!(version_ge("v0.5.11", "0.5.9")); + assert!(version_ge("1.0", "1.0.0")); + assert!(!version_ge("1.0.0", "1.0.1")); } #[test] @@ -191,6 +402,8 @@ mod tests { assert_eq!(v.as_deref(), Some("0.5.6")); let v = version_from_release_url("https://github.com/A3S-Lab/Cli/releases/tag/v1.2.30\n"); assert_eq!(v.as_deref(), Some("1.2.30")); + let v = version_from_release_url("https://github.com/A3S-Lab/Cli/releases/tag/1.2.31"); + assert_eq!(v.as_deref(), Some("1.2.31")); // No redirect to a tag (e.g. the bare releases page) → None, not garbage. assert_eq!( version_from_release_url("https://github.com/A3S-Lab/Cli/releases"), @@ -198,6 +411,19 @@ mod tests { ); } + #[test] + fn parse_version_from_binary_output() { + assert_eq!( + version_from_output("a3s 0.5.11\n").as_deref(), + Some("0.5.11") + ); + assert_eq!( + version_from_output("a3s-code v0.6.0 (release)\n").as_deref(), + Some("0.6.0") + ); + assert_eq!(version_from_output("not a version\n"), None); + } + #[test] fn target_is_known_on_this_host() { // CI runs on macOS + Linux, both supported. @@ -206,4 +432,234 @@ mod tests { assert!(can_self_update()); } } + + #[derive(Default)] + struct FakeRunner { + commands: Mutex>, + version_checks: AtomicUsize, + } + + impl FakeRunner { + fn commands(&self) -> Vec { + self.commands.lock().unwrap().clone() + } + + fn record(&self, program: &OsStr, args: &[OsString]) -> String { + let mut line = program.to_string_lossy().to_string(); + for arg in args { + line.push(' '); + line.push_str(&arg.to_string_lossy()); + } + self.commands.lock().unwrap().push(line.clone()); + line + } + } + + impl CommandRunner for FakeRunner { + fn output(&self, program: &OsStr, args: &[OsString]) -> Option { + let line = self.record(program, args); + let stdout = match line.as_str() { + "brew list --versions a3s" => b"a3s 9.9.9\n".to_vec(), + "brew --repo a3s-lab/tap" => b"/tmp/a3s-tap\n".to_vec(), + "a3s --version" => { + if self.version_checks.fetch_add(1, Ordering::SeqCst) == 0 { + b"a3s 0.1.0\n".to_vec() + } else { + b"a3s 9.9.9\n".to_vec() + } + } + _ => return None, + }; + Some(CommandOutput { + success: true, + stdout, + }) + } + + fn status(&self, program: &OsStr, args: &[OsString]) -> bool { + self.record(program, args); + true + } + } + + #[test] + fn brew_upgrade_reinstalls_when_metadata_is_new_but_binary_is_old() { + let runner = FakeRunner::default(); + let result = perform_upgrade_with("9.9.9", &runner, PathBuf::from("/unused/a3s")); + + assert_eq!(result.as_deref(), Some(Path::new("a3s"))); + let commands = runner.commands(); + assert!(commands.iter().any(|c| c == "brew upgrade a3s")); + assert!(commands.iter().any(|c| c == "brew reinstall a3s")); + assert_eq!(runner.version_checks.load(Ordering::SeqCst), 2); + } + + #[cfg(unix)] + struct TempDir { + root: PathBuf, + } + + #[cfg(unix)] + impl TempDir { + fn new(name: &str) -> Self { + static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "a3s-update-test-{name}-{}-{id}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Self { root } + } + + fn path(&self, name: &str) -> PathBuf { + self.root.join(name) + } + } + + #[cfg(unix)] + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + #[cfg(unix)] + fn write_executable(path: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, format!("#!/bin/sh\nprintf 'a3s {version}\\n'\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[test] + #[cfg(unix)] + fn standalone_swap_replaces_target_and_verifies_new_version() { + let tmp = TempDir::new("swap-success"); + let target = tmp.path("a3s"); + let new_bin = tmp.path("downloaded-a3s"); + write_executable(&target, "0.1.0"); + write_executable(&new_bin, "9.9.9"); + + let runner = RealCommandRunner; + swap_binary_and_verify(&runner, &new_bin, &target, "9.9.9").unwrap(); + + let out = Command::new(&target).arg("--version").output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout), "a3s 9.9.9\n"); + assert!(!sibling_temp_path(&target, "new").unwrap().exists()); + assert!(!sibling_temp_path(&target, "bak").unwrap().exists()); + } + + #[test] + #[cfg(unix)] + fn standalone_swap_restores_target_when_new_binary_reports_wrong_version() { + let tmp = TempDir::new("swap-restore"); + let target = tmp.path("a3s"); + let new_bin = tmp.path("downloaded-a3s"); + write_executable(&target, "0.1.0"); + write_executable(&new_bin, "0.2.0"); + + let runner = RealCommandRunner; + let err = swap_binary_and_verify(&runner, &new_bin, &target, "9.9.9").unwrap_err(); + + assert!(err.contains("did not report version 9.9.9")); + let out = Command::new(&target).arg("--version").output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout), "a3s 0.1.0\n"); + } + + #[cfg(unix)] + #[derive(Default)] + struct FakeStandaloneRunner { + commands: Mutex>, + } + + #[cfg(unix)] + impl FakeStandaloneRunner { + fn commands(&self) -> Vec { + self.commands.lock().unwrap().clone() + } + + fn record(&self, program: &OsStr, args: &[OsString]) -> String { + let mut line = program.to_string_lossy().to_string(); + for arg in args { + line.push(' '); + line.push_str(&arg.to_string_lossy()); + } + self.commands.lock().unwrap().push(line.clone()); + line + } + } + + #[cfg(unix)] + impl CommandRunner for FakeStandaloneRunner { + fn output(&self, program: &OsStr, args: &[OsString]) -> Option { + let line = self.record(program, args); + if line == "brew list --versions a3s" { + return Some(CommandOutput { + success: false, + stdout: Vec::new(), + }); + } + RealCommandRunner.output(program, args) + } + + fn status(&self, program: &OsStr, args: &[OsString]) -> bool { + self.record(program, args); + match program.to_string_lossy().as_ref() { + "curl" => { + let out = args + .windows(2) + .find(|pair| pair[0] == "-o") + .map(|pair| PathBuf::from(&pair[1])); + if let Some(out) = out { + std::fs::write(out, "fake tarball\n").is_ok() + } else { + false + } + } + "tar" => { + let dest = args + .windows(2) + .find(|pair| pair[0] == "-C") + .map(|pair| PathBuf::from(&pair[1])); + if let Some(dest) = dest { + write_executable(&dest.join("a3s"), "9.9.9"); + true + } else { + false + } + } + _ => false, + } + } + } + + #[test] + #[cfg(unix)] + fn standalone_upgrade_fallback_downloads_installs_and_verifies() { + let Some(target) = release_target() else { + return; + }; + + let tmp = TempDir::new("standalone-upgrade"); + let current = tmp.path("a3s"); + write_executable(¤t, "0.1.0"); + + let runner = FakeStandaloneRunner::default(); + let result = standalone_upgrade_with("9.9.9", &runner, current.clone()); + + assert_eq!(result.as_deref(), Some(current.as_path())); + let out = Command::new(¤t).arg("--version").output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout), "a3s 9.9.9\n"); + + let commands = runner.commands(); + assert!(commands + .iter() + .any(|c| c.contains(&format!("a3s-v9.9.9-{target}.tar.gz")))); + assert!(commands.iter().any(|c| c.starts_with("tar xzf "))); + } } diff --git a/tests/box_command.rs b/tests/box_command.rs new file mode 100644 index 0000000..c773deb --- /dev/null +++ b/tests/box_command.rs @@ -0,0 +1,116 @@ +#![cfg(unix)] + +mod support; + +use std::process::Command; + +use support::{ + a3s_bin, host_supports_standalone_box_asset, install_fake_download_tools, make_executable, + sh_quote, TempWorkspace, +}; + +#[test] +fn box_command_delegates_to_configured_a3s_box() { + let tmp = TempWorkspace::new("delegate"); + let bin_dir = tmp.path("bin"); + let args_log = tmp.path("args.log"); + make_executable( + &bin_dir.join("a3s-box"), + &format!( + r#"#!/bin/sh +printf '%s\n' "$@" > {} +printf 'delegated:%s\n' "$*" +exit 0 +"#, + sh_quote(&args_log) + ), + ); + + let output = Command::new(a3s_bin()) + .args(["box", "ps", "--format", "json"]) + .env("A3S_BOX_INSTALL_DIR", &bin_dir) + .env("PATH", "") + .output() + .expect("failed to run a3s box"); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "delegated:ps --format json\n" + ); + assert_eq!( + std::fs::read_to_string(args_log).expect("args log should be written"), + "ps\n--format\njson\n" + ); + assert!(String::from_utf8_lossy(&output.stderr).is_empty()); +} + +#[test] +fn box_command_propagates_a3s_box_exit_status() { + let tmp = TempWorkspace::new("exit-status"); + let bin_dir = tmp.path("bin"); + make_executable( + &bin_dir.join("a3s-box"), + r#"#!/bin/sh +printf 'failing-box:%s\n' "$*" +exit 7 +"#, + ); + + let output = Command::new(a3s_bin()) + .args(["box", "run", "bad"]) + .env("A3S_BOX_INSTALL_DIR", &bin_dir) + .env("PATH", "") + .output() + .expect("failed to run a3s box"); + + assert_eq!(output.status.code(), Some(7)); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "failing-box:run bad\n" + ); +} + +#[test] +fn box_command_auto_installs_with_download_progress() { + if !host_supports_standalone_box_asset() { + eprintln!("skipping standalone install test on unsupported host target"); + return; + } + + let tmp = TempWorkspace::new("auto-install"); + let bin_dir = tmp.path("install-bin"); + let home_dir = tmp.path("home"); + let tool_dir = tmp.path("tools"); + let curl_log = tmp.path("curl.log"); + install_fake_download_tools(&tool_dir, &curl_log, None); + + let output = Command::new(a3s_bin()) + .args(["box", "version"]) + .env("A3S_BOX_INSTALL_DIR", &bin_dir) + .env("HOME", &home_dir) + .env("PATH", &tool_dir) + .output() + .expect("failed to run a3s box"); + + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "installed-box:version\n" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("a3s: a3s-box is not installed; installing it now...")); + assert!(stderr.contains("a3s: downloading a3s-box 2.5.2")); + assert!(stderr.contains("#### download progress 100.0%")); + assert!(stderr.contains("a3s: extracting a3s-box 2.5.2...")); + assert!(stderr.contains("a3s: installing a3s-box into")); + assert!(stderr.contains("a3s: installed a3s-box to")); + + assert!(bin_dir.join("a3s-box").is_file()); + let curl_invocations = std::fs::read_to_string(curl_log).expect("curl log should be written"); + assert!(curl_invocations.contains("/releases/latest")); + assert!(curl_invocations.contains("--progress-bar")); + assert!(curl_invocations.contains("--show-error")); + assert!(curl_invocations.contains("a3s-box-v2.5.2-")); +} diff --git a/tests/box_command_soak.rs b/tests/box_command_soak.rs new file mode 100644 index 0000000..0046cda --- /dev/null +++ b/tests/box_command_soak.rs @@ -0,0 +1,68 @@ +#![cfg(unix)] + +mod support; + +use std::process::Command; + +use support::{ + a3s_bin, host_supports_standalone_box_asset, install_fake_download_tools, TempWorkspace, +}; + +#[test] +#[ignore = "soak test; run with `cargo test --test box_command_soak -- --ignored`"] +fn box_command_soak_installs_once_then_reuses_binary() { + if !host_supports_standalone_box_asset() { + eprintln!("skipping standalone install soak test on unsupported host target"); + return; + } + + let tmp = TempWorkspace::new("soak"); + let bin_dir = tmp.path("install-bin"); + let home_dir = tmp.path("home"); + let tool_dir = tmp.path("tools"); + let curl_log = tmp.path("curl.log"); + let args_log = tmp.path("installed-args.log"); + install_fake_download_tools(&tool_dir, &curl_log, Some(&args_log)); + + for index in 0..50 { + let output = Command::new(a3s_bin()) + .args(["box", "run", "--iteration"]) + .arg(index.to_string()) + .env("A3S_BOX_INSTALL_DIR", &bin_dir) + .env("HOME", &home_dir) + .env("PATH", &tool_dir) + .output() + .expect("failed to run a3s box soak iteration"); + + assert!( + output.status.success(), + "iteration {index} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + format!("installed-box:run --iteration {index}\n") + ); + } + + let curl_invocations = std::fs::read_to_string(curl_log).expect("curl log should be written"); + assert_eq!( + curl_invocations + .lines() + .filter(|line| line.contains("/releases/latest")) + .count(), + 1 + ); + assert_eq!( + curl_invocations + .lines() + .filter(|line| line.contains("--progress-bar")) + .count(), + 1 + ); + + let delegated_args = std::fs::read_to_string(args_log).expect("args log should be written"); + assert_eq!(delegated_args.lines().count(), 50 * 3); + assert!(delegated_args.contains("--iteration")); + assert!(bin_dir.join("a3s-box").is_file()); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..eabe633 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,136 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + +pub struct TempWorkspace { + root: PathBuf, +} + +impl TempWorkspace { + pub fn new(name: &str) -> Self { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("a3s-cli-{name}-{}-{id}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap_or_else(|err| { + panic!("failed to create temp workspace {}: {err}", root.display()) + }); + Self { root } + } + + pub fn path(&self, name: &str) -> PathBuf { + self.root.join(name) + } +} + +impl Drop for TempWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +pub fn a3s_bin() -> &'static str { + env!("CARGO_BIN_EXE_a3s") +} + +pub fn host_supports_standalone_box_asset() -> bool { + cfg!(all(target_os = "macos", target_arch = "aarch64")) + || cfg!(all(target_os = "linux", target_arch = "aarch64")) + || cfg!(all(target_os = "linux", target_arch = "x86_64")) +} + +pub fn make_executable(path: &Path, body: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap_or_else(|err| { + panic!( + "failed to create parent directory {}: {err}", + parent.display() + ) + }); + } + std::fs::write(path, body) + .unwrap_or_else(|err| panic!("failed to write executable {}: {err}", path.display())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .unwrap_or_else(|err| panic!("failed to chmod executable {}: {err}", path.display())); + } +} + +pub fn sh_quote(path: &Path) -> String { + format!("'{}'", path.display().to_string().replace('\'', "'\\''")) +} + +pub fn install_fake_download_tools( + tool_dir: &Path, + curl_log: &Path, + installed_args_log: Option<&Path>, +) { + let curl_log = sh_quote(curl_log); + make_executable( + &tool_dir.join("curl"), + &format!( + r#"#!/bin/sh +printf 'curl:%s\n' "$*" >> {curl_log} +case "$*" in + *"/releases/latest"*) + printf '%s\n' 'https://github.com/A3S-Lab/Box/releases/tag/v2.5.2' + exit 0 + ;; +esac + +out='' +prev='' +for arg in "$@"; do + if [ "$prev" = '-o' ] || [ "$prev" = '--output' ]; then + out="$arg" + fi + prev="$arg" +done + +if [ -z "$out" ]; then + printf 'missing curl output path\n' >&2 + exit 2 +fi + +printf '#### download progress 100.0%%\n' >&2 +printf 'fake tarball\n' > "$out" +exit 0 +"# + ), + ); + + let installed_log_line = installed_args_log + .map(|path| format!("printf '%s\\n' \"$@\" >> {}\n", sh_quote(path))) + .unwrap_or_default(); + make_executable( + &tool_dir.join("tar"), + &format!( + r#"#!/bin/sh +dest='' +prev='' +for arg in "$@"; do + if [ "$prev" = '-C' ]; then + dest="$arg" + fi + prev="$arg" +done + +if [ -z "$dest" ]; then + printf 'missing tar destination\n' >&2 + exit 2 +fi + +/bin/mkdir -p "$dest" +/bin/cat > "$dest/a3s-box" <<'A3S_BOX_SCRIPT' +#!/bin/sh +{installed_log_line}printf 'installed-box:%s\n' "$*" +exit 0 +A3S_BOX_SCRIPT +/bin/chmod +x "$dest/a3s-box" +exit 0 +"# + ), + ); +}