From 677b466aca11d8ca73597eb97614bb8176c5dd9d Mon Sep 17 00:00:00 2001 From: Edmondo Porcu Date: Wed, 18 Mar 2026 11:25:31 -0400 Subject: [PATCH] feat: add arbor-tui terminal dashboard with agent metadata support Add a new ratatui-based TUI crate (arbor-tui) that provides a terminal dashboard for monitoring Claude Code agents. Features include: - Live agent table with configurable header columns - Metadata panel showing agent-published key/value pairs - Auto-discovery of metadata columns from agent data - Terminal pane capture via tmux integration - Input forwarding to agent terminal panes - Detail overlay popup for full agent inspection - Collapsible table and metadata panels - Help overlay with keybinding reference - Configurable keybindings, field colors, and status icons - TOML-based configuration with sensible defaults - SVG screenshot generation for documentation - Insta snapshot tests for all UI states Also fixes formatting in arbor-daemon-client/types.rs. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 216 +++- Cargo.toml | 1 + crates/arbor-daemon-client/src/types.rs | 5 +- crates/arbor-tui/Cargo.toml | 23 + crates/arbor-tui/README.md | 325 +++++ crates/arbor-tui/config/default.toml | 64 + .../screenshots/collapsed-panels.svg | 1 + .../arbor-tui/screenshots/detail-overlay.svg | 1 + crates/arbor-tui/screenshots/help-overlay.svg | 1 + crates/arbor-tui/screenshots/main-view.svg | 1 + .../arbor-tui/screenshots/terminal-input.svg | 1 + crates/arbor-tui/src/app.rs | 93 ++ crates/arbor-tui/src/capture.rs | 149 +++ crates/arbor-tui/src/client.rs | 86 ++ crates/arbor-tui/src/event.rs | 46 + crates/arbor-tui/src/header.rs | 267 ++++ crates/arbor-tui/src/hooks.rs | 613 +++++++++ crates/arbor-tui/src/main.rs | 187 +++ crates/arbor-tui/src/tabs/agents.rs | 1152 +++++++++++++++++ crates/arbor-tui/src/tabs/mod.rs | 1 + ...sts__snapshot_auto_discovered_columns.snap | 25 + ...gents__tests__snapshot_both_collapsed.snap | 25 + ...__snapshot_detail_overlay_no_metadata.snap | 30 + ...snapshot_detail_overlay_with_metadata.snap | 35 + ...__agents__tests__snapshot_empty_state.snap | 15 + ..._agents__tests__snapshot_help_overlay.snap | 30 + ..._tests__snapshot_hidden_only_metadata.snap | 25 + ...bs__agents__tests__snapshot_input_bar.snap | 30 + ...gents__tests__snapshot_meta_collapsed.snap | 10 + ...ents__tests__snapshot_multiple_agents.snap | 30 + ...tests__snapshot_rich_metadata_columns.snap | 30 + ...ts__snapshot_single_agent_no_metadata.snap | 25 + ...__snapshot_single_agent_with_metadata.snap | 25 + ...ents__tests__snapshot_table_collapsed.snap | 25 + ...s__tests__snapshot_with_terminal_pane.snap | 30 + crates/arbor-tui/src/widgets/list_detail.rs | 85 ++ crates/arbor-tui/src/widgets/mod.rs | 2 + crates/arbor-tui/src/widgets/status_bar.rs | 100 ++ 38 files changed, 3805 insertions(+), 5 deletions(-) create mode 100644 crates/arbor-tui/Cargo.toml create mode 100644 crates/arbor-tui/README.md create mode 100644 crates/arbor-tui/config/default.toml create mode 100644 crates/arbor-tui/screenshots/collapsed-panels.svg create mode 100644 crates/arbor-tui/screenshots/detail-overlay.svg create mode 100644 crates/arbor-tui/screenshots/help-overlay.svg create mode 100644 crates/arbor-tui/screenshots/main-view.svg create mode 100644 crates/arbor-tui/screenshots/terminal-input.svg create mode 100644 crates/arbor-tui/src/app.rs create mode 100644 crates/arbor-tui/src/capture.rs create mode 100644 crates/arbor-tui/src/client.rs create mode 100644 crates/arbor-tui/src/event.rs create mode 100644 crates/arbor-tui/src/header.rs create mode 100644 crates/arbor-tui/src/hooks.rs create mode 100644 crates/arbor-tui/src/main.rs create mode 100644 crates/arbor-tui/src/tabs/agents.rs create mode 100644 crates/arbor-tui/src/tabs/mod.rs create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_auto_discovered_columns.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_both_collapsed.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_no_metadata.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_with_metadata.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_empty_state.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_help_overlay.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_hidden_only_metadata.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_input_bar.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_meta_collapsed.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_multiple_agents.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_rich_metadata_columns.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_no_metadata.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_with_metadata.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_table_collapsed.snap create mode 100644 crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_with_terminal_pane.snap create mode 100644 crates/arbor-tui/src/widgets/list_detail.rs create mode 100644 crates/arbor-tui/src/widgets/mod.rs create mode 100644 crates/arbor-tui/src/widgets/status_bar.rs diff --git a/Cargo.lock b/Cargo.lock index a31a3a05..8fc55d1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ dependencies = [ "rustix-openpty", "serde", "signal-hook 0.3.18", - "unicode-width", + "unicode-width 0.2.0", "vte", "windows-sys 0.59.0", ] @@ -100,6 +100,19 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi-to-tui" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom 7.1.3", + "ratatui", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] + [[package]] name = "anstream" version = "0.6.21" @@ -368,6 +381,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "arbor-tui" +version = "0.1.0" +dependencies = [ + "ansi-to-tui", + "anyhow", + "arbor-core", + "arbor-daemon-client", + "clap", + "crossterm", + "insta", + "ratatui", + "serde_json", + "toml 0.8.23", +] + [[package]] name = "arbor-web-ui" version = "0.1.0" @@ -1219,6 +1248,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1448,7 +1492,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -1553,6 +1597,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.37" @@ -1606,6 +1664,18 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1886,6 +1956,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook 0.3.18", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -2378,6 +2473,12 @@ dependencies = [ "winreg 0.55.0", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -4332,6 +4433,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -4794,6 +4897,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -4804,6 +4916,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "instant" version = "0.1.13" @@ -5266,6 +5403,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -5499,6 +5645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -6794,6 +6941,27 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -7767,6 +7935,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook 0.3.18", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -7802,6 +7981,18 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -8983,6 +9174,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-vo" version = "0.1.0" @@ -8991,9 +9193,15 @@ checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index d6362781..2db75bcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/arbor-ssh", "crates/arbor-symphony", "crates/arbor-terminal-emulator", + "crates/arbor-tui", "crates/arbor-web-ui", ] resolver = "2" diff --git a/crates/arbor-daemon-client/src/types.rs b/crates/arbor-daemon-client/src/types.rs index ed9daaf6..311a0aca 100644 --- a/crates/arbor-daemon-client/src/types.rs +++ b/crates/arbor-daemon-client/src/types.rs @@ -355,7 +355,10 @@ mod tests { metadata: None, }; let json = serde_json::to_value(&dto).expect("should serialize"); - assert!(json.get("metadata").is_none(), "metadata should be omitted when None"); + assert!( + json.get("metadata").is_none(), + "metadata should be omitted when None" + ); } #[test] diff --git a/crates/arbor-tui/Cargo.toml b/crates/arbor-tui/Cargo.toml new file mode 100644 index 00000000..a7d2453d --- /dev/null +++ b/crates/arbor-tui/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "arbor-tui" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +arbor-core = { path = "../arbor-core" } +arbor-daemon-client = { path = "../arbor-daemon-client" } +ansi-to-tui = "7" +anyhow = { workspace = true } +clap = { workspace = true } +crossterm = "0.28" +ratatui = "0.29" +serde_json = { workspace = true } +toml = { workspace = true } + +[dev-dependencies] +insta = "1" + +[lints] +workspace = true diff --git a/crates/arbor-tui/README.md b/crates/arbor-tui/README.md new file mode 100644 index 00000000..973ed98d --- /dev/null +++ b/crates/arbor-tui/README.md @@ -0,0 +1,325 @@ +# arbor-tui + +Terminal dashboard for monitoring the [Arbor](https://github.com/penso/arbor) daemon. +Displays agent activity with configurable columns, collapsible panels, +and optional live tmux output using [ratatui](https://ratatui.rs). + +All screenshots below are generated by automated tests (`cargo test`) +with Catppuccin Mocha colors. + +### Main view -- agents table with metadata columns + +![Main view](screenshots/main-view.svg) + +### Agent detail popup (Enter) + +![Detail overlay](screenshots/detail-overlay.svg) + +### Terminal pane with input bar (i) + +![Terminal with input](screenshots/terminal-input.svg) + +### Collapsed panels (t/m toggles) + +![Collapsed panels](screenshots/collapsed-panels.svg) + +### Help overlay (?) + +![Help overlay](screenshots/help-overlay.svg) + +## Quick start + +### 1. Start the Arbor daemon + +```bash +cargo run -p arbor-httpd +``` + +### 2. Start arbor-tui + +```bash +cargo run -p arbor-tui + +# Or specify a custom port +cargo run -p arbor-tui -- --port 9000 +``` + +### 3. Set up Claude Code hooks + +For the daemon to detect agent activity, add hooks to your +Claude Code settings (`~/.claude/settings.json` or project +`.claude/settings.json`). + +Claude Code hooks receive a JSON payload on **stdin** with +`session_id` and `cwd`. The hook script reads that, enriches +it with metadata from the environment, and POSTs to the daemon. + +#### Example hook: basic (no metadata) + +The simplest hook just forwards session info: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +EVENT_NAME="${1:-}" +[ -z "$EVENT_NAME" ] && exit 0 + +PAYLOAD=$(cat) +SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty') +CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty') +[ -z "$SESSION_ID" ] && exit 0 + +curl -s -X POST "http://127.0.0.1:8787/api/v1/agent/notify" \ + -H 'Content-Type: application/json' \ + -d "{\"hook_event_name\":\"$EVENT_NAME\",\"session_id\":\"$SESSION_ID\",\"cwd\":\"$CWD\"}" \ + >/dev/null 2>&1 & +``` + +#### Example hook: with metadata + +A richer hook that auto-detects project context from the +working directory. Metadata fields appear as columns in the +agents table and in the detail overlay (Enter key). + +```bash +#!/usr/bin/env bash +# ~/.local/bin/arbor-agent-notify +set -euo pipefail + +EVENT_NAME="${1:-}" +[ -z "$EVENT_NAME" ] && exit 0 + +PAYLOAD=$(cat) +SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty') +CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty') +[ -z "$SESSION_ID" ] && exit 0 + +# Start with PID of the Claude Code process +META_JSON=$(jq -nc --arg pid "$PPID" '{pid: $pid}') + +# Auto-detect VCS context from CWD +if [ -n "$CWD" ]; then + # jj: workspace name and project + if JJ_ROOT=$(jj --ignore-working-copy --repository "$CWD" workspace root 2>/dev/null); then + JJ_WS=$(jj --ignore-working-copy --repository "$CWD" workspace list 2>/dev/null \ + | head -1 | awk '{print $1}') + META_JSON=$(echo "$META_JSON" | jq -c \ + --arg project "$(basename "$JJ_ROOT")" \ + '. + {project: $project}') + [ -n "$JJ_WS" ] && META_JSON=$(echo "$META_JSON" | jq -c \ + --arg ws "$JJ_WS" '. + {workspace: $ws}') + + # git: branch and project + elif GIT_ROOT=$(git -C "$CWD" rev-parse --show-toplevel 2>/dev/null); then + GIT_BRANCH=$(git -C "$CWD" branch --show-current 2>/dev/null) + META_JSON=$(echo "$META_JSON" | jq -c \ + --arg project "$(basename "$GIT_ROOT")" \ + '. + {project: $project}') + [ -n "$GIT_BRANCH" ] && META_JSON=$(echo "$META_JSON" | jq -c \ + --arg branch "$GIT_BRANCH" '. + {branch: $branch}') + fi +fi + +# Tmux terminal info (enables live pane capture in arbor-tui) +if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then + TMUX_SERVER=$(echo "$TMUX" | cut -d, -f1 | xargs basename 2>/dev/null || echo "default") + META_JSON=$(echo "$META_JSON" | jq -c \ + --arg s "$TMUX_SERVER" --arg p "$TMUX_PANE" \ + '. + {terminal: {type: "tmux", server: $s, pane_id: $p}}') +fi + +curl -s -X POST "http://127.0.0.1:8787/api/v1/agent/notify" \ + -H 'Content-Type: application/json' \ + -d "{\"hook_event_name\":\"$EVENT_NAME\",\"session_id\":\"$SESSION_ID\",\"cwd\":\"$CWD\",\"metadata\":$META_JSON}" \ + >/dev/null 2>&1 & +``` + +This sends metadata like: + +```json +{ + "pid": "12345", + "project": "myapp", + "branch": "feat/new-api", + "terminal": { "type": "tmux", "server": "default", "pane_id": "%3" } +} +``` + +Any scalar metadata field (string, number, boolean) automatically +appears as a column in the agents table. Object fields like +`terminal` are hidden from the table but visible in the detail +overlay. + +#### Register the hook + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.local/bin/arbor-agent-notify UserPromptSubmit" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "~/.local/bin/arbor-agent-notify Stop" + } + ] + } + ] + } +} +``` + +The daemon recognizes two hook events: +- **`UserPromptSubmit`** -- marks agent as "working" +- **`Stop`** -- marks agent as "waiting" + +## Default keybindings + +| Key | Action | +|-----|--------| +| `q` / `Ctrl-c` | Quit | +| `j` / `Down` | Navigate down | +| `k` / `Up` | Navigate up | +| `Enter` | Agent detail popup | +| `t` | Toggle agents table | +| `m` | Toggle metadata panel | +| `+` / `=` | Grow panel | +| `-` | Shrink panel | +| `r` | Refresh | +| `i` | Send input to terminal pane | +| `?` | Help overlay | + +All keybindings are configurable. See [Configuration](#configuration). + +## Configuration + +arbor-tui loads a default config embedded in the binary, then merges +with `~/.arbor/tui.toml` if it exists. Copy the default config to +customize: + +```bash +mkdir -p ~/.arbor +cp crates/arbor-tui/config/default.toml ~/.arbor/tui.toml +``` + +### Agent header (table columns) + +The `agent_header` format string controls which columns appear +in the agents table. Fields use `${name:width}` syntax where +negative width is left-aligned and positive is right-aligned. + +Built-in fields: `session_id`, `cwd`, `status`, `elapsed`. +Any metadata key sent by your hook is also available. + +```toml +[tui] +# Show project and branch from metadata alongside built-in fields +agent_header = "${session_id:8} ${cwd:-24} ${project:-16} ${branch:-16} ${status:-8} ${elapsed:>6}" +``` + +Metadata fields not listed in the header are auto-discovered +and appended as columns. To hide auto-discovered fields: + +```toml +[tui] +hidden_columns = ["pid", "message"] +``` + +### Field colors + +Color metadata values conditionally or statically: + +```toml +[tui.field_colors] +# Map: different color per value +status = { working = "green", idle = "yellow", blocked = "red" } + +# Static: same color for all values +project = "cyan" +branch = "blue" +``` + +### Status icons + +```toml +[tui.status_icons] +working = "●" +idle = "○" +other = "◌" +``` + +### Custom action hooks + +Action hooks are custom keybindings that run shell commands on the +selected agent. They receive `ARBOR_*` environment variables: + +```toml +[actions.open_finder] +key = "o" +command = "open $ARBOR_CWD" + +[actions.open_terminal] +key = "g" +command = "open -a Ghostty $ARBOR_CWD" +``` + +Available environment variables: + +| Variable | Description | +|----------|-------------| +| `ARBOR_SESSION_ID` | Session identifier | +| `ARBOR_CWD` | Working directory | +| `ARBOR_STATE` | Agent state (working, waiting) | + +## Architecture + +``` +Claude Code --hooks--> arbor-httpd (daemon) <--poll-- arbor-tui + | | + port 8787 tmux capture-pane + | | + +--------+--------+ +------+------+ + | Agent state | | Live pane | + | + metadata | | output | + +-----------------+ +-------------+ +``` + +- **No tokio** -- the TUI uses `std::thread` + `mpsc` channels +- **Two polling threads** -- main poll (configurable, default 2s) for + agents, and a capture poll for the selected agent's live tmux pane output + +## Tmux Integration + +When Claude Code agents run inside tmux, arbor-tui can display their +live terminal output in the detail panel. + +### How it works + +1. The hook script detects `$TMUX` and `$TMUX_PANE` environment + variables when running inside a tmux session +2. It sends terminal metadata (`type`, `server`, `pane_id`) alongside + the usual `session_id` and `cwd` in the notification to the daemon +3. The daemon stores the metadata with the agent session +4. arbor-tui reads the metadata and calls + `tmux capture-pane -p -t ` to get the current pane content +5. The captured output is rendered with ANSI color support + +### Requirements + +- **tmux** must be installed and available on `$PATH` +- Claude Code agents must be started **inside a tmux session** (the + hook script auto-detects this; no extra configuration needed) +- The arbor-tui instance must have access to the same tmux server diff --git a/crates/arbor-tui/config/default.toml b/crates/arbor-tui/config/default.toml new file mode 100644 index 00000000..79ee4661 --- /dev/null +++ b/crates/arbor-tui/config/default.toml @@ -0,0 +1,64 @@ +# arbor-tui default configuration +# Config file locations (checked in order): +# 1. $XDG_CONFIG_HOME/arbor/tui.toml (default: ~/.config/arbor/tui.toml) +# 2. ~/.arbor/tui.toml +# User config is merged on top of these defaults. + +# ── TUI settings ──────────────────────────────────────── + +[tui] +tick_rate_ms = 250 +poll_interval_ms = 2000 + +# Agent header: format string rendered above the detail panel. +# Fields: ${name} or ${name:width} (negative=left-align, positive=right-align) +# Built-in: session_id, cwd, status, elapsed +# Metadata: any top-level key from agent metadata JSON +agent_header = "${session_id:8} ${cwd:-24} ${status:-8} ${elapsed:>6}" +column_header_color = "white" +hidden_columns = [] + +# Status icons shown in the agent list +[tui.status_icons] +working = "●" +idle = "○" +other = "◌" + +# Colors for header fields. Either a static color or a value→color map. +[tui.field_colors] +status = { working = "green", blocked = "red", idle = "yellow", waiting = "gray" } + +# ── Keybindings ───────────────────────────────────────── +# Format: "key" where key is a single character, or a special name. +# Special keys: Tab, Enter, Esc, Up, Down, Left, Right +# Modifier: "C-c" for Ctrl+C + +[keys] +quit = "q" +quit_alt = "C-c" +nav_down = "j" +nav_down_alt = "Down" +nav_up = "k" +nav_up_alt = "Up" +refresh = "r" +toggle_table = "t" +toggle_meta = "m" +toggle_help = "?" +show_detail = "Enter" +enter_input = "i" + +# ── Action hooks ──────────────────────────────────────── +# Custom keybindings that run commands on the selected agent. +# Each action has: key, command. +# Environment variables available: +# ARBOR_SESSION_ID - session ID of selected agent +# ARBOR_CWD - working directory of selected agent +# ARBOR_STATE - agent state (working, waiting) + +# [actions.open_finder] +# key = "o" +# command = "open $ARBOR_CWD" + +# [actions.open_terminal] +# key = "g" +# command = "open -a Ghostty $ARBOR_CWD" diff --git a/crates/arbor-tui/screenshots/collapsed-panels.svg b/crates/arbor-tui/screenshots/collapsed-panels.svg new file mode 100644 index 00000000..c7527670 --- /dev/null +++ b/crates/arbor-tui/screenshots/collapsed-panels.svg @@ -0,0 +1 @@ +Agents [t to expand]────────────────────────────────────────────────────────────Metadata [m to expand]──────────────────────────────────────────────────────────┌Terminal (i=input)────────────────────────────────────────────────────────────┐│$ make build ││Compiling arbor-tui v0.1.0 ││ Finished dev profile ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/crates/arbor-tui/screenshots/detail-overlay.svg b/crates/arbor-tui/screenshots/detail-overlay.svg new file mode 100644 index 00000000..34f0b313 --- /dev/null +++ b/crates/arbor-tui/screenshots/detail-overlay.svg @@ -0,0 +1 @@ +┌Agents────────────────────────────────────────────────────────────────────────┐ project status branch pid myapp working feat/new-api 12345 └───┌ Agent Detail (Enter to close) ───────────────────────────────────────┐───┘┌Metsession_id s1 ───┐bracwd /home/user/myapp pidstate working proupdated_at 18446744073709551615 ── Metadata ── branch feat/new-api pid 12345 project myapp terminal { "pane_id": "%3", "server": "default", "type": "tmux" } └──────────────────────────────────────────────────────────────────────┘│ ││ │└──────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/crates/arbor-tui/screenshots/help-overlay.svg b/crates/arbor-tui/screenshots/help-overlay.svg new file mode 100644 index 00000000..11acea92 --- /dev/null +++ b/crates/arbor-tui/screenshots/help-overlay.svg @@ -0,0 +1 @@ +┌Agents────────────────────────────────────────────────────────────────────────┐ project status myapp working └──────────────────────────────────────────────────────────────────────────────┘┌Metadata─────────┌ Help (? to close) ───────────────────────┐─────────────────┐project Keybindings: q / C-c Quit j / Down Navigate down k / Up Navigate up + / = Grow panel - Shrink panel t Toggle agents table m Toggle metadata panel Enter Agent detail popup r Refresh i Send input to terminal pane ? Toggle this help └──────────────────────────────────────────┘│ ││ ││ │└──────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/crates/arbor-tui/screenshots/main-view.svg b/crates/arbor-tui/screenshots/main-view.svg new file mode 100644 index 00000000..9987ff30 --- /dev/null +++ b/crates/arbor-tui/screenshots/main-view.svg @@ -0,0 +1 @@ +┌Agents────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ pid session_id cwd project branch status elapsed blocked_on 9001 abc12345 /home/user/frontend frontend main working 0s 9002 def67890 /home/user/backend backend feat/api idle 0s │ 9003 ghi11111 /home/user/infra infra fix/deploy working 0s review │└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘┌Metadata──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐branch main │pid 9001 │project frontend ││ ││ ││ ││ ││ ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/crates/arbor-tui/screenshots/terminal-input.svg b/crates/arbor-tui/screenshots/terminal-input.svg new file mode 100644 index 00000000..7c9f3257 --- /dev/null +++ b/crates/arbor-tui/screenshots/terminal-input.svg @@ -0,0 +1 @@ +┌Agents────────────────────────────────────────────────────────────────────────┐ project status myproject working └──────────────────────────────────────────────────────────────────────────────┘┌Metadata──────────────────────────────────────────────────────────────────────┐project myproject ││ ││ ││ ││ ││ │└──────────────────────────────────────────────────────────────────────────────┘┌Terminal (i=input)────────────────────────────────────────────────────────────┐│$ cargo test ││running 12 tests ││test parse ... ok ││test build ... ok ││test lint ... ok ││ ││ ││ │└──────────────────────────────────────────────────────────────────────────────┘┌ Send to pane (Esc to cancel) ────────────────────────────────────────────────┐│› make deploy └──────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/crates/arbor-tui/src/app.rs b/crates/arbor-tui/src/app.rs new file mode 100644 index 00000000..ac3e799c --- /dev/null +++ b/crates/arbor-tui/src/app.rs @@ -0,0 +1,93 @@ +use { + crate::{ + hooks::{ActionTab, Config}, + widgets::list_detail::ListDetailState, + }, + arbor_daemon_client::AgentSessionDto, + std::time::Instant, +}; + +pub struct App { + pub running: bool, + pub connected: bool, + pub last_poll: Option, + pub agents: Vec, + pub agents_state: ListDetailState, + pub config: Config, + pub pane_output: Option, + pub input_mode: bool, + pub input_buffer: String, + pub table_collapsed: bool, + pub meta_collapsed: bool, + pub show_help: bool, + pub show_detail: bool, +} + +impl App { + pub fn new() -> Self { + let config = Config::load(); + Self { + running: true, + connected: false, + last_poll: None, + agents: Vec::new(), + agents_state: ListDetailState::new(), + config, + pane_output: None, + input_mode: false, + input_buffer: String::new(), + table_collapsed: false, + meta_collapsed: false, + show_help: false, + show_detail: false, + } + } + + pub fn apply_daemon_data(&mut self, data: Vec) { + use crate::client::DaemonData; + for d in data { + match d { + DaemonData::Health(ok) => { + self.connected = ok; + self.last_poll = Some(Instant::now()); + }, + DaemonData::Agents(new_agents) => { + self.agents_state.set_count(new_agents.len()); + self.agents = new_agents; + }, + DaemonData::PaneOutput(output) => { + self.pane_output = output; + }, + } + } + } + + pub fn last_poll_secs(&self) -> Option { + self.last_poll.map(|t| t.elapsed().as_secs()) + } + + pub fn current_list_state_mut(&mut self) -> &mut ListDetailState { + &mut self.agents_state + } + + pub fn current_action_tab(&self) -> ActionTab { + ActionTab::Agents + } + + pub fn selected_env_vars(&self) -> Vec<(&str, String)> { + if let Some(agent) = self.agents.get(self.agents_state.selected) { + vec![ + ("ARBOR_SESSION_ID", agent.session_id.clone()), + ("ARBOR_CWD", agent.cwd.clone()), + ("ARBOR_STATE", agent.state.clone()), + ("ARBOR_TAB", "agents".to_owned()), + ] + } else { + Vec::new() + } + } + + pub fn quit(&mut self) { + self.running = false; + } +} diff --git a/crates/arbor-tui/src/capture.rs b/crates/arbor-tui/src/capture.rs new file mode 100644 index 00000000..7004fb7d --- /dev/null +++ b/crates/arbor-tui/src/capture.rs @@ -0,0 +1,149 @@ +use {arbor_daemon_client::AgentSessionDto, std::process::Command}; + +pub trait TerminalCapture: Send + Sync { + fn capture(&self, agent: &AgentSessionDto) -> Option; + fn send_keys(&self, agent: &AgentSessionDto, text: &str) -> bool; +} + +/// Returns the appropriate capture backend based on the agent's +/// `metadata.terminal.type` field, or `None` if no backend matches. +pub fn capture_for(agent: &AgentSessionDto) -> Option> { + let terminal = agent.metadata.as_ref()?.get("terminal")?; + let terminal_type = terminal.get("type")?.as_str()?; + match terminal_type { + "tmux" => Some(Box::new(TmuxCapture)), + _ => None, + } +} + +struct TmuxTarget<'a> { + server: &'a str, + pane_id: &'a str, +} + +fn extract_tmux_target(agent: &AgentSessionDto) -> Option> { + let terminal = agent.metadata.as_ref()?.get("terminal")?; + Some(TmuxTarget { + server: terminal.get("server")?.as_str()?, + pane_id: terminal.get("pane_id")?.as_str()?, + }) +} + +struct TmuxCapture; + +impl TerminalCapture for TmuxCapture { + fn capture(&self, agent: &AgentSessionDto) -> Option { + let target = extract_tmux_target(agent)?; + let output = Command::new("tmux") + .args([ + "-L", + target.server, + "capture-pane", + "-p", + "-e", + "-t", + target.pane_id, + ]) + .output() + .ok()?; + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + None + } + } + + fn send_keys(&self, agent: &AgentSessionDto, text: &str) -> bool { + let Some(target) = extract_tmux_target(agent) else { + return false; + }; + Command::new("tmux") + .args([ + "-L", + target.server, + "send-keys", + "-t", + target.pane_id, + text, + "Enter", + ]) + .output() + .is_ok_and(|o| o.status.success()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn agent_with_tmux_metadata() -> AgentSessionDto { + AgentSessionDto { + session_id: "test-1".to_owned(), + cwd: "/tmp/test".to_owned(), + state: "working".to_owned(), + updated_at_unix_ms: 0, + metadata: Some(serde_json::json!({ + "terminal": { "type": "tmux", "server": "proj", "pane_id": "%1" } + })), + } + } + + fn agent_without_metadata() -> AgentSessionDto { + AgentSessionDto { + session_id: "test-2".to_owned(), + cwd: "/tmp/test".to_owned(), + state: "idle".to_owned(), + updated_at_unix_ms: 0, + metadata: None, + } + } + + #[test] + fn capture_for_returns_backend_for_tmux() { + let agent = agent_with_tmux_metadata(); + assert!(capture_for(&agent).is_some()); + } + + #[test] + fn capture_for_returns_none_for_unknown_type() { + let mut agent = agent_with_tmux_metadata(); + agent.metadata = Some(serde_json::json!({ + "terminal": { "type": "kitty" } + })); + assert!(capture_for(&agent).is_none()); + } + + #[test] + fn capture_for_returns_none_without_metadata() { + assert!(capture_for(&agent_without_metadata()).is_none()); + } + + #[test] + fn capture_for_returns_none_without_terminal_key() { + let mut agent = agent_with_tmux_metadata(); + agent.metadata = Some(serde_json::json!({"git": {"branch": "main"}})); + assert!(capture_for(&agent).is_none()); + } + + #[test] + fn capture_returns_none_without_metadata() { + let capturer = TmuxCapture; + assert!(capturer.capture(&agent_without_metadata()).is_none()); + } + + #[test] + fn extract_target_returns_server_and_pane() -> Result<(), Box> { + let agent = agent_with_tmux_metadata(); + let target = extract_tmux_target(&agent).ok_or("expected Some target")?; + assert_eq!(target.server, "proj"); + assert_eq!(target.pane_id, "%1"); + Ok(()) + } + + #[test] + fn extract_target_returns_none_for_missing_fields() { + let mut agent = agent_with_tmux_metadata(); + agent.metadata = Some(serde_json::json!({"terminal": {"type": "tmux"}})); + assert!(extract_tmux_target(&agent).is_none()); + } +} diff --git a/crates/arbor-tui/src/client.rs b/crates/arbor-tui/src/client.rs new file mode 100644 index 00000000..51d90642 --- /dev/null +++ b/crates/arbor-tui/src/client.rs @@ -0,0 +1,86 @@ +use { + crate::capture, + arbor_daemon_client::{AgentSessionDto, DaemonClient}, + std::{ + sync::{Arc, Mutex, mpsc}, + time::Duration, + }, +}; + +pub enum DaemonData { + Health(bool), + Agents(Vec), + PaneOutput(Option), +} + +pub struct DaemonPoller { + rx: mpsc::Receiver, + capture_request: Arc>>, +} + +impl DaemonPoller { + pub fn start(port: u16, poll_interval: Duration) -> Self { + let (tx, rx) = mpsc::channel(); + let base_url = format!("http://127.0.0.1:{}", port); + let capture_request: Arc>> = Arc::new(Mutex::new(None)); + + let tx_daemon = tx.clone(); + std::thread::spawn(move || { + let client = DaemonClient::new(&base_url); + loop { + let connected = client.health().is_ok(); + let _ = tx_daemon.send(DaemonData::Health(connected)); + + if connected && let Ok(agents) = client.list_agent_activity() { + let _ = tx_daemon.send(DaemonData::Agents(agents)); + } + + std::thread::sleep(poll_interval); + } + }); + + let capture_request_clone = Arc::clone(&capture_request); + let capture_interval = Duration::from_millis(500); + std::thread::spawn(move || { + loop { + let agent = capture_request_clone + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + + let result = agent.and_then(|a| { + let backend = capture::capture_for(&a)?; + backend.capture(&a) + }); + let _ = tx.send(DaemonData::PaneOutput(result)); + + std::thread::sleep(capture_interval); + } + }); + + Self { + rx, + capture_request, + } + } + + pub fn request_capture(&self, agent: &AgentSessionDto) { + if let Ok(mut guard) = self.capture_request.lock() { + *guard = Some(agent.clone()); + } + } + + pub fn clear_capture(&self) { + if let Ok(mut guard) = self.capture_request.lock() { + *guard = None; + } + } + + pub fn drain(&self) -> Vec { + let mut data = Vec::new(); + while let Ok(d) = self.rx.try_recv() { + data.push(d); + } + data + } +} diff --git a/crates/arbor-tui/src/event.rs b/crates/arbor-tui/src/event.rs new file mode 100644 index 00000000..cc621fe4 --- /dev/null +++ b/crates/arbor-tui/src/event.rs @@ -0,0 +1,46 @@ +use { + crossterm::event::{self, Event as CrosstermEvent, KeyEvent}, + std::time::Duration, +}; + +pub enum Event { + Key(KeyEvent), + Tick, +} + +pub struct EventHandler { + rx: std::sync::mpsc::Receiver, +} + +impl EventHandler { + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = std::sync::mpsc::channel(); + let tick_tx = tx.clone(); + + std::thread::spawn(move || { + loop { + if event::poll(tick_rate).unwrap_or(false) + && let Ok(CrosstermEvent::Key(key)) = event::read() + && tx.send(Event::Key(key)).is_err() + { + break; + } + } + }); + + std::thread::spawn(move || { + loop { + std::thread::sleep(tick_rate); + if tick_tx.send(Event::Tick).is_err() { + break; + } + } + }); + + Self { rx } + } + + pub fn next(&self) -> Result { + self.rx.recv() + } +} diff --git a/crates/arbor-tui/src/header.rs b/crates/arbor-tui/src/header.rs new file mode 100644 index 00000000..281113c8 --- /dev/null +++ b/crates/arbor-tui/src/header.rs @@ -0,0 +1,267 @@ +use {arbor_daemon_client::AgentSessionDto, ratatui::prelude::*, std::collections::HashMap}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + Literal(String), + Field { name: String, width: Option }, +} + +pub fn parse_format(fmt: &str) -> Vec { + let mut segments = Vec::new(); + let mut rest = fmt; + + while let Some(start) = rest.find("${") { + if start > 0 { + segments.push(Segment::Literal(rest[..start].to_owned())); + } + let after = &rest[start + 2..]; + if let Some(end) = after.find('}') { + let inner = &after[..end]; + let (name, width) = if let Some(colon) = inner.find(':') { + let width_str = &inner[colon + 1..]; + let w = if let Some(rest) = width_str.strip_prefix('>') { + rest.parse::().ok() + } else { + width_str.parse::().ok() + }; + (inner[..colon].to_owned(), w) + } else { + (inner.to_owned(), None) + }; + segments.push(Segment::Field { name, width }); + rest = &after[end + 1..]; + } else { + segments.push(Segment::Literal(rest[start..].to_owned())); + rest = ""; + } + } + + if !rest.is_empty() { + segments.push(Segment::Literal(rest.to_owned())); + } + + segments +} + +pub fn resolve_field(agent: &AgentSessionDto, name: &str) -> Option { + match name { + "session_id" => Some(agent.session_id.clone()), + "cwd" => Some(agent.cwd.clone()), + "status" => Some(agent.state.clone()), + "elapsed" => { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let elapsed_secs = now_ms.saturating_sub(agent.updated_at_unix_ms) / 1000; + Some(format_elapsed(elapsed_secs)) + }, + _ => { + let meta = agent.metadata.as_ref()?; + let val = meta.get(name)?; + match val { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Null => None, + _ => Some(val.to_string()), + } + }, + } +} + +fn format_elapsed(secs: u64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m", secs / 60) + } else { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } +} + +#[derive(Debug, Clone)] +pub enum FieldColor { + Static(Color), + Map(HashMap), +} + +pub fn resolve_color( + field_colors: &HashMap, + field_name: &str, + value: &str, +) -> Option { + match field_colors.get(field_name)? { + FieldColor::Static(c) => Some(*c), + FieldColor::Map(m) => m.get(value).copied(), + } +} + +pub fn parse_color(name: &str) -> Option { + match name { + "red" => Some(Color::Red), + "green" => Some(Color::Green), + "yellow" => Some(Color::Yellow), + "blue" => Some(Color::Blue), + "cyan" => Some(Color::Cyan), + "magenta" => Some(Color::Magenta), + "gray" => Some(Color::Gray), + "dark_gray" => Some(Color::DarkGray), + "white" => Some(Color::White), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_agent(state: &str, updated_ms: u64) -> AgentSessionDto { + AgentSessionDto { + session_id: "sess-1".to_owned(), + cwd: "/home/user/project".to_owned(), + state: state.to_owned(), + updated_at_unix_ms: updated_ms, + metadata: Some(serde_json::json!({ + "project": "arbor", + "branch": "feat/tmux", + "blocked_on": "tool_use", + "terminal": { "type": "tmux", "server": "default", "pane_id": "%0" } + })), + } + } + + #[test] + fn parse_format_simple() { + let segs = parse_format("${status:-10} ${elapsed:>8}"); + assert_eq!(segs, vec![ + Segment::Field { + name: "status".to_owned(), + width: Some(-10) + }, + Segment::Literal(" ".to_owned()), + Segment::Field { + name: "elapsed".to_owned(), + width: Some(8) + }, + ]); + } + + #[test] + fn parse_format_no_width() { + let segs = parse_format("${project} | ${branch}"); + assert_eq!(segs, vec![ + Segment::Field { + name: "project".to_owned(), + width: None + }, + Segment::Literal(" | ".to_owned()), + Segment::Field { + name: "branch".to_owned(), + width: None + }, + ]); + } + + #[test] + fn parse_format_literal_only() { + let segs = parse_format("hello world"); + assert_eq!(segs, vec![Segment::Literal("hello world".to_owned())]); + } + + #[test] + fn resolve_builtin_fields() { + let agent = make_agent("working", 0); + assert_eq!( + resolve_field(&agent, "session_id"), + Some("sess-1".to_owned()) + ); + assert_eq!( + resolve_field(&agent, "cwd"), + Some("/home/user/project".to_owned()) + ); + assert_eq!(resolve_field(&agent, "status"), Some("working".to_owned())); + } + + #[test] + fn resolve_metadata_fields() { + let agent = make_agent("working", 0); + assert_eq!(resolve_field(&agent, "project"), Some("arbor".to_owned())); + assert_eq!( + resolve_field(&agent, "branch"), + Some("feat/tmux".to_owned()) + ); + assert_eq!( + resolve_field(&agent, "blocked_on"), + Some("tool_use".to_owned()) + ); + } + + #[test] + fn resolve_missing_field() { + let agent = make_agent("working", 0); + assert_eq!(resolve_field(&agent, "nonexistent"), None); + } + + #[test] + fn resolve_no_metadata() { + let agent = AgentSessionDto { + session_id: "s1".to_owned(), + cwd: "/tmp".to_owned(), + state: "idle".to_owned(), + updated_at_unix_ms: 0, + metadata: None, + }; + assert_eq!(resolve_field(&agent, "project"), None); + assert_eq!(resolve_field(&agent, "status"), Some("idle".to_owned())); + } + + #[test] + fn resolve_elapsed() -> Result<(), Box> { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let agent = make_agent("working", now_ms - 125_000); + let elapsed = resolve_field(&agent, "elapsed").ok_or("expected Some elapsed")?; + assert_eq!(elapsed, "2m"); + Ok(()) + } + + #[test] + fn format_elapsed_values() { + assert_eq!(format_elapsed(0), "0s"); + assert_eq!(format_elapsed(45), "45s"); + assert_eq!(format_elapsed(120), "2m"); + assert_eq!(format_elapsed(3661), "1h1m"); + } + + #[test] + fn color_static() { + let mut colors = HashMap::new(); + colors.insert("project".to_owned(), FieldColor::Static(Color::Cyan)); + assert_eq!( + resolve_color(&colors, "project", "anything"), + Some(Color::Cyan) + ); + } + + #[test] + fn color_map_match() { + let mut colors = HashMap::new(); + colors.insert( + "status".to_owned(), + FieldColor::Map([("working".to_owned(), Color::Green)].into_iter().collect()), + ); + assert_eq!( + resolve_color(&colors, "status", "working"), + Some(Color::Green) + ); + assert_eq!(resolve_color(&colors, "status", "unknown"), None); + } + + #[test] + fn color_missing_field() { + let colors = HashMap::new(); + assert_eq!(resolve_color(&colors, "nope", "val"), None); + } +} diff --git a/crates/arbor-tui/src/hooks.rs b/crates/arbor-tui/src/hooks.rs new file mode 100644 index 00000000..80c97a66 --- /dev/null +++ b/crates/arbor-tui/src/hooks.rs @@ -0,0 +1,613 @@ +use { + crate::header::{self, FieldColor, Segment}, + crossterm::event::{KeyCode, KeyModifiers}, + ratatui::prelude::Color, + std::{collections::HashMap, path::PathBuf, process::Command, time::Duration}, +}; + +const DEFAULT_CONFIG: &str = include_str!("../config/default.toml"); + +#[derive(Debug, Clone)] +pub struct Config { + pub tui: TuiSettings, + pub keys: KeyBindings, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusIcons { + pub working: String, + pub idle: String, + pub other: String, +} + +impl Default for StatusIcons { + fn default() -> Self { + Self { + working: "●".to_owned(), + idle: "○".to_owned(), + other: "◌".to_owned(), + } + } +} + +#[derive(Debug, Clone)] +pub struct TuiSettings { + pub tick_rate: Duration, + pub poll_interval: Duration, + pub agent_header: Vec, + pub field_colors: HashMap, + pub status_icons: StatusIcons, + pub column_header_color: Color, + pub hidden_columns: Vec, +} + +impl Default for TuiSettings { + fn default() -> Self { + Self { + tick_rate: Duration::from_millis(250), + poll_interval: Duration::from_millis(2000), + agent_header: header::parse_format( + "${session_id:8} ${cwd:-20} ${status:-10} ${elapsed:>8}", + ), + field_colors: HashMap::new(), + status_icons: StatusIcons::default(), + column_header_color: Color::White, + hidden_columns: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct KeyBindings(Vec<(KeySpec, BuiltinAction)>); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeySpec { + pub code: KeyCode, + pub modifiers: KeyModifiers, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BuiltinAction { + Quit, + NavDown, + NavUp, + Refresh, + ToggleTable, + ToggleMeta, + ToggleHelp, + ShowDetail, + EnterInput, +} + +#[derive(Debug, Clone)] +pub struct ActionHook { + pub name: String, + pub key: char, + pub command: String, + pub tab: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActionTab { + Agents, + Repos, +} + +impl Config { + pub fn load() -> Self { + let default = parse_toml(DEFAULT_CONFIG); + let user_path = config_path(); + if !user_path.exists() { + return default; + } + match std::fs::read_to_string(&user_path) { + Ok(content) => merge_user_config(default, &content), + Err(_) => default, + } + } + + pub fn lookup_builtin(&self, code: KeyCode, modifiers: KeyModifiers) -> Option { + self.keys.0.iter().find_map(|(spec, action)| { + if spec.code == code && spec.modifiers == modifiers { + Some(*action) + } else { + None + } + }) + } + + pub fn find_action(&self, key: char, tab: &ActionTab) -> Option<&ActionHook> { + self.actions + .iter() + .find(|a| a.key == key && a.tab.as_ref().is_none_or(|t| t == tab)) + } + + pub fn action_hints(&self, tab: &ActionTab) -> Vec<(char, &str)> { + self.actions + .iter() + .filter(|a| a.tab.as_ref().is_none_or(|t| t == tab)) + .map(|a| (a.key, a.name.as_str())) + .collect() + } +} + +pub fn run_command(command: &str, env_vars: &[(&str, &str)]) { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(command); + for (key, val) in env_vars { + cmd.env(key, val); + } + if let Ok(mut child) = cmd.spawn() { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } +} + +fn config_path() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + let xdg_path = PathBuf::from(xdg).join("arbor").join("tui.toml"); + if xdg_path.exists() { + return xdg_path; + } + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned()); + let xdg_default = PathBuf::from(&home) + .join(".config") + .join("arbor") + .join("tui.toml"); + if xdg_default.exists() { + return xdg_default; + } + PathBuf::from(home).join(".arbor").join("tui.toml") +} + +fn merge_user_config(default: Config, user_content: &str) -> Config { + let table = match user_content.parse::() { + Ok(t) => t, + Err(_) => return default, + }; + + let user_keys = parse_keys(table.get("keys").and_then(|v| v.as_table())); + let keys = if user_keys.0.is_empty() { + default.keys + } else { + user_keys + }; + + let overrides = parse_tui_overrides(table.get("tui").and_then(|v| v.as_table())); + let tui = apply_tui_overrides(default.tui, overrides); + + let mut actions = default.actions; + actions.extend(parse_actions( + table.get("actions").and_then(|v| v.as_table()), + )); + + Config { tui, keys, actions } +} + +struct ParsedTuiOverrides { + tick_rate: Option, + poll_interval: Option, + agent_header: Option>, + field_colors: Option>, + status_icons: Option, + column_header_color: Option, + hidden_columns: Option>, +} + +fn apply_tui_overrides(base: TuiSettings, overrides: ParsedTuiOverrides) -> TuiSettings { + TuiSettings { + tick_rate: overrides.tick_rate.unwrap_or(base.tick_rate), + poll_interval: overrides.poll_interval.unwrap_or(base.poll_interval), + agent_header: overrides.agent_header.unwrap_or(base.agent_header), + field_colors: overrides.field_colors.unwrap_or(base.field_colors), + status_icons: overrides.status_icons.unwrap_or(base.status_icons), + column_header_color: overrides + .column_header_color + .unwrap_or(base.column_header_color), + hidden_columns: overrides.hidden_columns.unwrap_or(base.hidden_columns), + } +} + +fn parse_toml(content: &str) -> Config { + let table = match content.parse::() { + Ok(t) => t, + Err(_) => { + return Config { + tui: TuiSettings::default(), + keys: KeyBindings(Vec::new()), + actions: Vec::new(), + }; + }, + }; + + let tui = parse_tui_settings(table.get("tui").and_then(|v| v.as_table())); + let keys = parse_keys(table.get("keys").and_then(|v| v.as_table())); + let actions = parse_actions(table.get("actions").and_then(|v| v.as_table())); + + Config { tui, keys, actions } +} + +fn parse_ms(table: &toml::Table, key: &str) -> Option { + table + .get(key) + .and_then(|v| v.as_integer()) + .map(|v| Duration::from_millis(v.clamp(0, i64::from(u32::MAX)) as u64)) +} + +fn parse_tui_settings(table: Option<&toml::Table>) -> TuiSettings { + let Some(table) = table else { + return TuiSettings::default(); + }; + let d = TuiSettings::default(); + TuiSettings { + tick_rate: parse_ms(table, "tick_rate_ms").unwrap_or(d.tick_rate), + poll_interval: parse_ms(table, "poll_interval_ms").unwrap_or(d.poll_interval), + agent_header: table + .get("agent_header") + .and_then(|v| v.as_str()) + .map(header::parse_format) + .unwrap_or(d.agent_header), + field_colors: parse_field_colors(table.get("field_colors").and_then(|v| v.as_table())), + status_icons: parse_status_icons(table.get("status_icons").and_then(|v| v.as_table())), + column_header_color: table + .get("column_header_color") + .and_then(|v| v.as_str()) + .and_then(header::parse_color) + .unwrap_or(d.column_header_color), + hidden_columns: table + .get("hidden_columns") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_owned())) + .collect() + }) + .unwrap_or_default(), + } +} + +fn parse_tui_overrides(table: Option<&toml::Table>) -> ParsedTuiOverrides { + let Some(table) = table else { + return ParsedTuiOverrides { + tick_rate: None, + poll_interval: None, + agent_header: None, + field_colors: None, + status_icons: None, + column_header_color: None, + hidden_columns: None, + }; + }; + + let field_colors = table + .get("field_colors") + .and_then(|v| v.as_table()) + .map(|t| parse_field_colors(Some(t))) + .filter(|m| !m.is_empty()); + + let status_icons_table = table.get("status_icons").and_then(|v| v.as_table()); + let status_icons = status_icons_table.map(|t| parse_status_icons(Some(t))); + + let hidden_columns = table + .get("hidden_columns") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_owned())) + .collect() + }); + + ParsedTuiOverrides { + tick_rate: parse_ms(table, "tick_rate_ms"), + poll_interval: parse_ms(table, "poll_interval_ms"), + agent_header: table + .get("agent_header") + .and_then(|v| v.as_str()) + .map(header::parse_format), + field_colors, + status_icons, + column_header_color: table + .get("column_header_color") + .and_then(|v| v.as_str()) + .and_then(header::parse_color), + hidden_columns, + } +} + +fn parse_status_icons(table: Option<&toml::Table>) -> StatusIcons { + let d = StatusIcons::default(); + let Some(table) = table else { + return d; + }; + let str_val = |key: &str, default: &str| -> String { + table + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or(default) + .to_owned() + }; + StatusIcons { + working: str_val("working", &d.working), + idle: str_val("idle", &d.idle), + other: str_val("other", &d.other), + } +} + +fn parse_field_colors(table: Option<&toml::Table>) -> HashMap { + let Some(table) = table else { + return HashMap::new(); + }; + + let mut colors = HashMap::new(); + for (field, value) in table { + if let Some(color_name) = value.as_str() { + if let Some(color) = header::parse_color(color_name) { + colors.insert(field.clone(), FieldColor::Static(color)); + } + } else if let Some(map) = value.as_table() { + let mut color_map = HashMap::new(); + for (val, color_name) in map { + if let Some(cn) = color_name.as_str() + && let Some(color) = header::parse_color(cn) + { + color_map.insert(val.clone(), color); + } + } + if !color_map.is_empty() { + colors.insert(field.clone(), FieldColor::Map(color_map)); + } + } + } + colors +} + +fn parse_keys(table: Option<&toml::Table>) -> KeyBindings { + let Some(table) = table else { + return KeyBindings(Vec::new()); + }; + + let binding_names: &[(&str, BuiltinAction)] = &[ + ("quit", BuiltinAction::Quit), + ("quit_alt", BuiltinAction::Quit), + ("nav_down", BuiltinAction::NavDown), + ("nav_down_alt", BuiltinAction::NavDown), + ("nav_up", BuiltinAction::NavUp), + ("nav_up_alt", BuiltinAction::NavUp), + ("refresh", BuiltinAction::Refresh), + ("toggle_table", BuiltinAction::ToggleTable), + ("toggle_meta", BuiltinAction::ToggleMeta), + ("toggle_help", BuiltinAction::ToggleHelp), + ("show_detail", BuiltinAction::ShowDetail), + ("enter_input", BuiltinAction::EnterInput), + ]; + + let mut bindings = Vec::new(); + for (name, action) in binding_names { + if let Some(key_str) = table.get(*name).and_then(|v| v.as_str()) + && let Some(spec) = parse_key_spec(key_str) + { + bindings.push((spec, *action)); + } + } + + KeyBindings(bindings) +} + +fn parse_key_spec(s: &str) -> Option { + if let Some(c) = s.strip_prefix("C-") { + let ch = c.chars().next()?; + return Some(KeySpec { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::CONTROL, + }); + } + + match s { + "Tab" => Some(KeySpec { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + }), + "Enter" => Some(KeySpec { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + }), + "Esc" => Some(KeySpec { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + }), + "Up" => Some(KeySpec { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + }), + "Down" => Some(KeySpec { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + }), + "Left" => Some(KeySpec { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + }), + "Right" => Some(KeySpec { + code: KeyCode::Right, + modifiers: KeyModifiers::NONE, + }), + s if s.len() == 1 => { + let ch = s.chars().next()?; + Some(KeySpec { + code: KeyCode::Char(ch), + modifiers: KeyModifiers::NONE, + }) + }, + _ => None, + } +} + +fn parse_actions(table: Option<&toml::Table>) -> Vec { + let Some(table) = table else { + return Vec::new(); + }; + + let mut actions = Vec::new(); + for (name, value) in table { + let key = value + .get("key") + .and_then(|v| v.as_str()) + .and_then(|s| s.chars().next()); + let command = value + .get("command") + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()); + let tab = value + .get("tab") + .and_then(|v| v.as_str()) + .and_then(|s| match s { + "agents" => Some(ActionTab::Agents), + "repos" => Some(ActionTab::Repos), + _ => None, + }); + + if let (Some(key), Some(command)) = (key, command) { + actions.push(ActionHook { + name: name.clone(), + key, + command, + tab, + }); + } + } + + actions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_default_config_loads_all_keybindings() { + let config = parse_toml(DEFAULT_CONFIG); + assert_eq!(config.keys.0.len(), 12); + } + + #[test] + fn test_parse_default_config_loads_tui_settings() { + let config = parse_toml(DEFAULT_CONFIG); + assert_eq!(config.tui.tick_rate, Duration::from_millis(250)); + assert_eq!(config.tui.poll_interval, Duration::from_millis(2000)); + } + + #[test] + fn test_parse_key_spec_single_char() -> Result<(), Box> { + let spec = parse_key_spec("q").ok_or("should parse")?; + assert_eq!(spec.code, KeyCode::Char('q')); + assert_eq!(spec.modifiers, KeyModifiers::NONE); + Ok(()) + } + + #[test] + fn test_parse_key_spec_ctrl_modifier() -> Result<(), Box> { + let spec = parse_key_spec("C-c").ok_or("should parse")?; + assert_eq!(spec.code, KeyCode::Char('c')); + assert_eq!(spec.modifiers, KeyModifiers::CONTROL); + Ok(()) + } + + #[test] + fn test_parse_key_spec_special_keys() -> Result<(), Box> { + assert_eq!(parse_key_spec("Tab").ok_or("Tab")?.code, KeyCode::Tab); + assert_eq!(parse_key_spec("Enter").ok_or("Enter")?.code, KeyCode::Enter); + assert_eq!(parse_key_spec("Esc").ok_or("Esc")?.code, KeyCode::Esc); + assert_eq!(parse_key_spec("Up").ok_or("Up")?.code, KeyCode::Up); + assert_eq!(parse_key_spec("Down").ok_or("Down")?.code, KeyCode::Down); + Ok(()) + } + + #[test] + fn test_parse_key_spec_invalid_returns_none() { + assert!(parse_key_spec("InvalidKey").is_none()); + assert!(parse_key_spec("").is_none()); + } + + #[test] + fn test_lookup_builtin_finds_bound_key() { + let config = parse_toml(DEFAULT_CONFIG); + let action = config.lookup_builtin(KeyCode::Char('q'), KeyModifiers::NONE); + assert_eq!(action, Some(BuiltinAction::Quit)); + } + + #[test] + fn test_lookup_builtin_returns_none_for_unbound_key() { + let config = parse_toml(DEFAULT_CONFIG); + let action = config.lookup_builtin(KeyCode::Char('z'), KeyModifiers::NONE); + assert!(action.is_none()); + } + + #[test] + fn test_merge_user_keys_override_defaults() { + let default = parse_toml(DEFAULT_CONFIG); + let merged = merge_user_config(default, "[keys]\nquit = \"x\"\n"); + let action = merged.lookup_builtin(KeyCode::Char('x'), KeyModifiers::NONE); + assert_eq!(action, Some(BuiltinAction::Quit)); + assert!( + merged + .lookup_builtin(KeyCode::Char('q'), KeyModifiers::NONE) + .is_none() + ); + } + + #[test] + fn test_merge_user_actions_are_additive() { + let default = parse_toml(DEFAULT_CONFIG); + let merged = merge_user_config( + default, + "[actions.test]\nkey = \"g\"\ncommand = \"echo hi\"\ntab = \"agents\"\n", + ); + assert_eq!(merged.actions.len(), 1); + assert_eq!(merged.actions[0].key, 'g'); + assert_eq!(merged.actions[0].tab, Some(ActionTab::Agents)); + } + + #[test] + fn test_merge_user_tui_settings_override() { + let default = parse_toml(DEFAULT_CONFIG); + let merged = merge_user_config(default, "[tui]\npoll_interval_ms = 5000\n"); + assert_eq!(merged.tui.poll_interval, Duration::from_millis(5000)); + assert_eq!(merged.tui.tick_rate, Duration::from_millis(250)); + } + + #[test] + fn test_find_action_matches_tab_filter() { + let config = + parse_toml("[actions.go]\nkey = \"g\"\ncommand = \"echo\"\ntab = \"agents\"\n"); + assert!(config.find_action('g', &ActionTab::Agents).is_some()); + assert!(config.find_action('g', &ActionTab::Repos).is_none()); + } + + #[test] + fn test_find_action_global_matches_any_tab() { + let config = parse_toml("[actions.go]\nkey = \"g\"\ncommand = \"echo\"\n"); + assert!(config.find_action('g', &ActionTab::Agents).is_some()); + assert!(config.find_action('g', &ActionTab::Repos).is_some()); + } + + #[test] + fn test_action_hints_filters_by_tab() { + let config = parse_toml( + "[actions.go]\nkey = \"g\"\ncommand = \"echo\"\ntab = \"agents\"\n\n[actions.open]\nkey = \"o\"\ncommand = \"open\"\n", + ); + let hints = config.action_hints(&ActionTab::Agents); + assert_eq!(hints.len(), 2); + let hints = config.action_hints(&ActionTab::Repos); + assert_eq!(hints.len(), 1); + } + + #[test] + fn test_invalid_toml_returns_empty_config() { + let config = parse_toml("this is not valid toml {{{"); + assert!(config.keys.0.is_empty()); + assert!(config.actions.is_empty()); + } +} diff --git a/crates/arbor-tui/src/main.rs b/crates/arbor-tui/src/main.rs new file mode 100644 index 00000000..b635ed9b --- /dev/null +++ b/crates/arbor-tui/src/main.rs @@ -0,0 +1,187 @@ +mod app; +mod capture; +mod client; +mod event; +mod header; +mod hooks; +mod tabs; +mod widgets; + +use { + app::App, + clap::Parser, + crossterm::{ + event::KeyEventKind, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + }, + hooks::BuiltinAction, + ratatui::prelude::*, + std::io, +}; + +#[derive(Parser)] +#[command(name = "arbor-tui", about = "Terminal dashboard for Arbor")] +struct Args { + /// Daemon port + #[arg(long, default_value = "8787")] + port: u16, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = restore_terminal(); + original_hook(panic_info); + })); + + enable_raw_mode()?; + crossterm::execute!(io::stdout(), EnterAlternateScreen)?; + + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + let events = event::EventHandler::new(app.config.tui.tick_rate); + let poller = client::DaemonPoller::start(args.port, app.config.tui.poll_interval); + + while app.running { + terminal.draw(|frame| ui(frame, &mut app))?; + + match events.next() { + Ok(event::Event::Key(key)) if key.kind == KeyEventKind::Press => { + if app.input_mode { + match key.code { + crossterm::event::KeyCode::Esc => { + app.input_mode = false; + app.input_buffer.clear(); + }, + crossterm::event::KeyCode::Enter => { + if let Some(agent) = app.agents.get(app.agents_state.selected) + && let Some(backend) = capture::capture_for(agent) + { + backend.send_keys(agent, &app.input_buffer); + } + app.input_buffer.clear(); + }, + crossterm::event::KeyCode::Backspace => { + app.input_buffer.pop(); + }, + crossterm::event::KeyCode::Char(c) => { + app.input_buffer.push(c); + }, + _ => {}, + } + } else if let Some(action) = app.config.lookup_builtin(key.code, key.modifiers) { + match action { + BuiltinAction::Quit => app.quit(), + BuiltinAction::NavDown => app.current_list_state_mut().select_next(), + BuiltinAction::NavUp => app.current_list_state_mut().select_prev(), + BuiltinAction::Refresh => {}, + BuiltinAction::ToggleTable => { + app.table_collapsed = !app.table_collapsed; + }, + BuiltinAction::ToggleMeta => { + app.meta_collapsed = !app.meta_collapsed; + }, + BuiltinAction::ToggleHelp => { + app.show_help = !app.show_help; + }, + BuiltinAction::ShowDetail => { + app.show_detail = !app.show_detail; + }, + BuiltinAction::EnterInput => { + if app.pane_output.is_some() { + app.input_mode = true; + } + }, + } + } else if let crossterm::event::KeyCode::Char(c) = key.code { + let tab = app.current_action_tab(); + if let Some(action_hook) = app.config.find_action(c, &tab) { + let env_vars = app.selected_env_vars(); + let env_refs: Vec<(&str, &str)> = + env_vars.iter().map(|(k, v)| (*k, v.as_str())).collect(); + hooks::run_command(&action_hook.command, &env_refs); + } + } + }, + Ok(_) => {}, + Err(_) => { + app.quit(); + }, + } + + let data = poller.drain(); + if !data.is_empty() { + app.apply_daemon_data(data); + } + + { + if let Some(agent) = app.agents.get(app.agents_state.selected) { + if agent + .metadata + .as_ref() + .and_then(|m| m.get("terminal")) + .is_some() + { + poller.request_capture(agent); + } else { + poller.clear_capture(); + app.pane_output = None; + } + } else { + poller.clear_capture(); + app.pane_output = None; + } + } + } + + restore_terminal()?; + Ok(()) +} + +fn restore_terminal() -> anyhow::Result<()> { + disable_raw_mode()?; + crossterm::execute!(io::stdout(), LeaveAlternateScreen)?; + Ok(()) +} + +fn ui(frame: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(frame.area()); + + let props = tabs::agents::AgentsTabProps { + state: &app.agents_state, + agents: &app.agents, + pane_output: app.pane_output.as_deref(), + input_text: if app.input_mode { + Some(app.input_buffer.as_str()) + } else { + None + }, + header_segments: &app.config.tui.agent_header, + field_colors: &app.config.tui.field_colors, + status_icons: &app.config.tui.status_icons, + column_header_color: app.config.tui.column_header_color, + hidden_columns: &app.config.tui.hidden_columns, + table_collapsed: app.table_collapsed, + meta_collapsed: app.meta_collapsed, + show_help: app.show_help, + show_detail: app.show_detail, + }; + tabs::agents::render_agents_tab(frame, chunks[0], &props); + + let action_tab = app.current_action_tab(); + let action_hints = app.config.action_hints(&action_tab); + widgets::status_bar::render_status_bar( + frame, + chunks[1], + app.connected, + app.last_poll_secs(), + &action_hints, + ); +} diff --git a/crates/arbor-tui/src/tabs/agents.rs b/crates/arbor-tui/src/tabs/agents.rs new file mode 100644 index 00000000..ad19ba32 --- /dev/null +++ b/crates/arbor-tui/src/tabs/agents.rs @@ -0,0 +1,1152 @@ +use { + crate::{ + header::{FieldColor, Segment, resolve_color, resolve_field}, + hooks::StatusIcons, + widgets::list_detail::ListDetailState, + }, + ansi_to_tui::IntoText, + arbor_daemon_client::AgentSessionDto, + ratatui::{ + prelude::*, + widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}, + }, + std::collections::HashMap, +}; + +const STATUS_WORKING: &str = "working"; +const STATUS_IDLE: &str = "idle"; +const TABLE_TITLE: &str = "Agents"; +const META_TITLE: &str = "Metadata"; +const PANE_TITLE: &str = "Terminal (i=input)"; +const INPUT_BAR_TITLE: &str = " Send to pane (Esc to cancel) "; +const EMPTY_STATE_MSG: &str = + "No agents detected.\n\nAgents appear when Claude Code hooks\nPOST to /api/v1/agent/notify."; +const NO_META_MSG: &str = "This agent is not publishing metadata via hooks."; +const MAX_TABLE_ROWS: u16 = 12; +const TABLE_CHROME: u16 = 3; +const COLLAPSED_HEIGHT: u16 = 1; +const META_ROWS: u16 = 6; +const META_CHROME: u16 = 2; +const LABEL_WIDTH: usize = 16; +const ICON_COL_WIDTH: u16 = 2; +const AUTO_COLUMN_WIDTH: u16 = 10; +const HELP_POPUP_WIDTH: u16 = 44; +const DETAIL_WIDTH_PCT: u16 = 90; +const DETAIL_HEIGHT_PCT: u16 = 80; +const INPUT_BAR_HEIGHT: u16 = 3; +const MIN_PANE_HEIGHT: u16 = 4; +const DEFAULT_FIELD_WIDTH: u16 = 12; +const HIDDEN_META_KEYS: &[&str] = &["terminal"]; + +const HELP_TEXT: &str = "\ +Keybindings: + q / C-c Quit + j / Down Navigate down + k / Up Navigate up + t Toggle agents table + m Toggle metadata panel + Enter Agent detail popup + r Refresh + i Send input to terminal pane + ? Toggle this help"; + +pub struct AgentsTabProps<'a> { + pub state: &'a ListDetailState, + pub agents: &'a [AgentSessionDto], + pub pane_output: Option<&'a str>, + pub input_text: Option<&'a str>, + pub header_segments: &'a [Segment], + pub field_colors: &'a HashMap, + pub status_icons: &'a StatusIcons, + pub column_header_color: Color, + pub hidden_columns: &'a [String], + pub table_collapsed: bool, + pub meta_collapsed: bool, + pub show_help: bool, + pub show_detail: bool, +} + +struct TableColumn { + name: String, + width: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MetaState { + None, + HiddenOnly, + HasFields, +} + +fn is_visible_meta_key(key: &str) -> bool { + !HIDDEN_META_KEYS.contains(&key) +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + Rect::new( + area.x + area.width.saturating_sub(w) / 2, + area.y + area.height.saturating_sub(h) / 2, + w, + h, + ) +} + +fn label_line(label: &str, value: &str, label_style: Style) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{: Line<'static> { + Line::from(vec![ + Span::styled(format!("{: Option { + match val { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +/// Recursively sort object keys in a JSON value so output is deterministic +/// regardless of whether `serde_json/preserve_order` is enabled. +fn sort_json_keys(val: &serde_json::Value) -> serde_json::Value { + match val { + serde_json::Value::Object(map) => { + let mut sorted: serde_json::Map = serde_json::Map::new(); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for k in keys { + if let Some(v) = map.get(k) { + sorted.insert(k.clone(), sort_json_keys(v)); + } + } + serde_json::Value::Object(sorted) + }, + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().map(sort_json_keys).collect()) + }, + other => other.clone(), + } +} + +fn classify_metadata(agent: Option<&AgentSessionDto>) -> MetaState { + match agent + .and_then(|a| a.metadata.as_ref()) + .and_then(|m| m.as_object()) + { + None => MetaState::None, + Some(obj) if obj.keys().any(|k| is_visible_meta_key(k)) => MetaState::HasFields, + Some(_) => MetaState::HiddenOnly, + } +} + +fn status_icon<'a>(state: &str, icons: &'a StatusIcons) -> &'a str { + match state { + STATUS_WORKING => icons.working.as_str(), + STATUS_IDLE => icons.idle.as_str(), + _ => icons.other.as_str(), + } +} + +fn status_color(state: &str) -> Color { + match state { + STATUS_WORKING => Color::Green, + STATUS_IDLE => Color::DarkGray, + _ => Color::Red, + } +} + +fn extract_columns( + segments: &[Segment], + agents: &[AgentSessionDto], + hidden: &[String], +) -> Vec { + let mut columns: Vec = segments + .iter() + .filter_map(|seg| match seg { + Segment::Field { name, width } => { + let w = width + .map(|w| w.unsigned_abs() as u16) + .unwrap_or(DEFAULT_FIELD_WIDTH); + Some(TableColumn { + name: name.clone(), + width: w, + }) + }, + Segment::Literal(_) => None, + }) + .collect(); + + let known: std::collections::HashSet<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + let mut extra: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for agent in agents { + if let Some(obj) = agent.metadata.as_ref().and_then(|m| m.as_object()) { + for (key, val) in obj { + if !known.contains(key.as_str()) + && is_visible_meta_key(key) + && !hidden.iter().any(|h| h == key) + && !val.is_object() + && !val.is_array() + { + extra.insert(key.clone()); + } + } + } + } + + columns.extend(extra.into_iter().map(|name| TableColumn { + name, + width: AUTO_COLUMN_WIDTH, + })); + columns +} + +fn build_layout(area: Rect, props: &AgentsTabProps<'_>, has_terminal: bool) -> std::rc::Rc<[Rect]> { + let table_h = if props.table_collapsed { + COLLAPSED_HEIGHT + } else { + (props.agents.len() as u16).min(MAX_TABLE_ROWS) + TABLE_CHROME + }; + + let meta_h = if props.meta_collapsed { + COLLAPSED_HEIGHT + } else if has_terminal { + META_ROWS + META_CHROME + } else { + 0 + }; + + let mut constraints = vec![Constraint::Length(table_h)]; + + if has_terminal { + constraints.push(Constraint::Length(meta_h)); + constraints.push(Constraint::Min(MIN_PANE_HEIGHT)); + } else if props.meta_collapsed { + constraints.push(Constraint::Length(COLLAPSED_HEIGHT)); + } else { + constraints.push(Constraint::Min(MIN_PANE_HEIGHT)); + } + + if props.input_text.is_some() && has_terminal { + constraints.push(Constraint::Length(INPUT_BAR_HEIGHT)); + } + + Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area) +} + +fn render_collapsed_bar(frame: &mut Frame, area: Rect, title: &str, key: char) { + let bar = Paragraph::new("").block( + Block::default() + .borders(Borders::TOP) + .title(format!("{title} [{key} to expand]")), + ); + frame.render_widget(bar, area); +} + +pub fn render_agents_tab(frame: &mut Frame, area: Rect, props: &AgentsTabProps<'_>) { + if props.agents.is_empty() { + let msg = Paragraph::new(EMPTY_STATE_MSG) + .block(Block::default().borders(Borders::ALL).title(TABLE_TITLE)); + frame.render_widget(msg, area); + return; + } + + let columns = extract_columns(props.header_segments, props.agents, props.hidden_columns); + let selected_agent = props.agents.get(props.state.selected); + let meta_state = classify_metadata(selected_agent); + let has_terminal = props.pane_output.is_some(); + let chunks = build_layout(area, props, has_terminal); + + if props.table_collapsed { + render_collapsed_bar(frame, chunks[0], TABLE_TITLE, 't'); + } else { + render_agent_table(frame, chunks[0], props, &columns); + } + + if props.meta_collapsed { + render_collapsed_bar(frame, chunks[1], META_TITLE, 'm'); + } else { + render_metadata_panel( + frame, + chunks[1], + selected_agent, + meta_state, + props.field_colors, + ); + } + + if has_terminal { + render_terminal_pane(frame, chunks[2], props); + if let Some(buf) = props.input_text { + render_input_bar(frame, chunks[3], buf); + } + } + + if let Some(agent) = selected_agent.filter(|_| props.show_detail) { + render_detail_overlay(frame, area, agent); + } + + if props.show_help { + render_help_overlay(frame, area); + } +} + +fn render_agent_table( + frame: &mut Frame, + area: Rect, + props: &AgentsTabProps<'_>, + columns: &[TableColumn], +) { + let header_cells: Vec> = + std::iter::once(Cell::from("").style(Style::default().bold())) + .chain(columns.iter().map(|col| { + Cell::from(col.name.clone()) + .style(Style::default().bold().fg(props.column_header_color)) + })) + .collect(); + + let rows: Vec> = props + .agents + .iter() + .enumerate() + .map(|(i, agent)| { + let icon = status_icon(&agent.state, props.status_icons); + let color = status_color(&agent.state); + let mut cells: Vec> = + vec![Cell::from(icon.to_owned()).style(Style::default().fg(color))]; + for col in columns { + let value = resolve_field(agent, &col.name).unwrap_or_default(); + let style = resolve_color(props.field_colors, &col.name, &value) + .map(|c| Style::default().fg(c)) + .unwrap_or_default(); + cells.push(Cell::from(value).style(style)); + } + let row = Row::new(cells); + if i == props.state.selected { + row.style(Style::default().bg(Color::DarkGray).fg(Color::White)) + } else { + row + } + }) + .collect(); + + let widths: Vec = std::iter::once(Constraint::Length(ICON_COL_WIDTH)) + .chain(columns.iter().map(|col| Constraint::Min(col.width))) + .collect(); + + let table = Table::new(rows, &widths) + .header(Row::new(header_cells).height(1)) + .block(Block::default().borders(Borders::ALL).title(TABLE_TITLE)) + .column_spacing(1); + + frame.render_widget(table, area); +} + +fn render_metadata_panel( + frame: &mut Frame, + area: Rect, + agent: Option<&AgentSessionDto>, + meta_state: MetaState, + field_colors: &HashMap, +) { + let block = Block::default().borders(Borders::ALL).title(META_TITLE); + let label_style = Style::default().bold(); + + let Some(agent) = agent else { + frame.render_widget(Paragraph::new("No agent selected").block(block), area); + return; + }; + + match meta_state { + MetaState::None => { + let warning = Paragraph::new(Line::from(Span::styled( + NO_META_MSG, + Style::default().fg(Color::Yellow).bold(), + ))) + .block(block); + frame.render_widget(warning, area); + }, + MetaState::HiddenOnly => { + let lines = vec![ + label_line("Session", &agent.session_id, label_style), + label_line("CWD", &agent.cwd, label_style), + label_line("State", &agent.state, label_style), + ]; + frame.render_widget(Paragraph::new(lines).block(block), area); + }, + MetaState::HasFields => { + let lines = build_metadata_lines(agent, field_colors, label_style); + frame.render_widget(Paragraph::new(lines).block(block), area); + }, + } +} + +fn build_metadata_lines( + agent: &AgentSessionDto, + field_colors: &HashMap, + label_style: Style, +) -> Vec> { + let mut lines = Vec::new(); + let Some(obj) = agent.metadata.as_ref().and_then(|m| m.as_object()) else { + return lines; + }; + let mut keys: Vec<&String> = obj.keys().collect(); + keys.sort(); + for key in keys { + let Some(val) = obj.get(key) else { + continue; + }; + if !is_visible_meta_key(key) { + continue; + } + let Some(val_str) = format_scalar(val) else { + continue; + }; + let value_style = resolve_color(field_colors, key, &val_str) + .map(|c| Style::default().fg(c)) + .unwrap_or_default(); + lines.push(styled_label_line(key, &val_str, label_style, value_style)); + } + lines +} + +fn render_terminal_pane(frame: &mut Frame, area: Rect, props: &AgentsTabProps<'_>) { + let text = props.pane_output.unwrap_or(""); + let styled = text + .as_bytes() + .into_text() + .unwrap_or_else(|_| Text::raw(text)); + let pane = Paragraph::new(styled) + .wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL).title(PANE_TITLE)); + frame.render_widget(pane, area); +} + +fn render_input_bar(frame: &mut Frame, area: Rect, buf: &str) { + let input = Paragraph::new(Line::from(vec![ + Span::styled("› ", Style::default().fg(Color::Yellow)), + Span::raw(buf), + Span::styled("█", Style::default().fg(Color::Yellow)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(INPUT_BAR_TITLE), + ); + frame.render_widget(input, area); +} + +fn render_detail_overlay(frame: &mut Frame, area: Rect, agent: &AgentSessionDto) { + let label_style = Style::default().bold(); + let mut lines = vec![ + label_line("session_id", &agent.session_id, label_style), + label_line("cwd", &agent.cwd, label_style), + label_line("state", &agent.state, label_style), + label_line( + "updated_at", + &agent.updated_at_unix_ms.to_string(), + label_style, + ), + ]; + + if let Some(obj) = agent.metadata.as_ref().and_then(|m| m.as_object()) { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "── Metadata ──", + Style::default().fg(Color::Yellow), + ))); + let mut keys: Vec<&String> = obj.keys().collect(); + keys.sort(); + for key in keys { + let Some(val) = obj.get(key) else { + continue; + }; + let val_str = match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => "null".to_owned(), + other => { + let sorted = sort_json_keys(other); + serde_json::to_string_pretty(&sorted).unwrap_or_default() + }, + }; + for (i, line_str) in val_str.lines().enumerate() { + let label = if i == 0 { + key.as_str() + } else { + "" + }; + lines.push(label_line(label, line_str, label_style)); + } + } + } + + let popup = centered_rect( + area, + area.width * DETAIL_WIDTH_PCT / 100, + area.height * DETAIL_HEIGHT_PCT / 100, + ); + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Agent Detail (Enter to close) "), + ), + popup, + ); +} + +fn render_help_overlay(frame: &mut Frame, area: Rect) { + let height = (HELP_TEXT.lines().count() as u16) + 2; + let popup = centered_rect(area, HELP_POPUP_WIDTH, height); + + frame.render_widget(Clear, popup); + frame.render_widget( + Paragraph::new(HELP_TEXT).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Help (? to close) "), + ), + popup, + ); +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::header, + ratatui::{Terminal, backend::TestBackend}, + }; + + const FIXED_TS: u64 = u64::MAX; + + fn make_agent(session_id: &str, cwd: &str, state: &str) -> AgentSessionDto { + AgentSessionDto { + session_id: session_id.to_owned(), + cwd: cwd.to_owned(), + state: state.to_owned(), + updated_at_unix_ms: FIXED_TS, + metadata: None, + } + } + + fn make_agent_with_meta( + session_id: &str, + cwd: &str, + state: &str, + meta: serde_json::Value, + ) -> AgentSessionDto { + AgentSessionDto { + session_id: session_id.to_owned(), + cwd: cwd.to_owned(), + state: state.to_owned(), + updated_at_unix_ms: FIXED_TS, + metadata: Some(meta), + } + } + + fn render_to_string(terminal: &Terminal) -> String { + let buf = terminal.backend().buffer().clone(); + let mut lines: Vec = (0..buf.area.height) + .map(|y| { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_owned()) + .collect::() + .trim_end() + .to_owned() + }) + .collect(); + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + lines.join("\n") + } + + fn color_to_hex(c: Color) -> &'static str { + match c { + Color::Black => "#45475a", + Color::Red => "#f38ba8", + Color::Green => "#a6e3a1", + Color::Yellow => "#f9e2af", + Color::Blue => "#89b4fa", + Color::Magenta => "#f5c2e7", + Color::Cyan => "#89dceb", + Color::Gray | Color::White => "#cdd6f4", + Color::DarkGray => "#585b70", + Color::LightRed => "#f38ba8", + Color::LightGreen => "#a6e3a1", + Color::LightYellow => "#f9e2af", + Color::LightBlue => "#89b4fa", + Color::LightMagenta => "#f5c2e7", + Color::LightCyan => "#89dceb", + _ => "#cdd6f4", + } + } + + fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + } + + fn render_to_svg(terminal: &Terminal) -> String { + let buf = terminal.backend().buffer().clone(); + let w = buf.area.width; + let h = buf.area.height; + let char_w: f64 = 7.8; + let char_h: f64 = 16.0; + let pad: f64 = 12.0; + let svg_w = (w as f64) * char_w + pad * 2.0; + let svg_h = (h as f64) * char_h + pad * 2.0; + + let bg = "#1e1e2e"; + let mut svg = format!( + r#""#, + ); + + for y in 0..h { + struct Run { + text: String, + fg: Color, + bg: Color, + bold: bool, + } + let mut runs: Vec = Vec::new(); + + for x in 0..w { + let cell = &buf[(x, y)]; + let fg = cell.fg; + let bg = cell.bg; + let bold = cell.modifier.contains(Modifier::BOLD); + let sym = cell.symbol(); + + if let Some(last) = runs.last_mut() + && last.fg == fg + && last.bg == bg + && last.bold == bold + { + last.text.push_str(sym); + continue; + } + runs.push(Run { + text: sym.to_owned(), + fg, + bg, + bold, + }); + } + + let mut x_offset: f64 = 0.0; + for run in &runs { + let run_w = (run.text.chars().count() as f64) * char_w; + if run.bg != Color::Reset { + let bg_hex = color_to_hex(run.bg); + svg.push_str(&format!( + r#""#, + pad + x_offset, + pad + (y as f64) * char_h, + run_w, + char_h, + bg_hex, + )); + } + x_offset += run_w; + } + + let text_y = pad + (y as f64) * char_h + char_h * 0.75; + svg.push_str(&format!(r#""#, text_y)); + let mut cx: f64 = pad; + for run in &runs { + let trimmed = run.text.as_str(); + if trimmed.is_empty() { + continue; + } + let fg_hex = color_to_hex(if run.fg == Color::Reset { + Color::Gray + } else { + run.fg + }); + let weight = if run.bold { + r#" font-weight="bold""# + } else { + "" + }; + svg.push_str(&format!( + r#"{}"#, + cx, + weight, + fg_hex, + html_escape(trimmed), + )); + cx += (run.text.chars().count() as f64) * char_w; + } + svg.push_str(""); + } + + svg.push_str(""); + svg + } + + fn save_screenshot( + terminal: &Terminal, + name: &str, + ) -> Result<(), Box> { + let svg = render_to_svg(terminal); + let screenshots_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("screenshots"); + std::fs::create_dir_all(&screenshots_dir)?; + std::fs::write(screenshots_dir.join(format!("{name}.svg")), &svg)?; + Ok(()) + } + + fn make_props<'a>( + state: &'a ListDetailState, + agents: &'a [AgentSessionDto], + pane_output: Option<&'a str>, + segments: &'a [Segment], + ) -> AgentsTabProps<'a> { + static EMPTY_COLORS: std::sync::LazyLock> = + std::sync::LazyLock::new(HashMap::new); + static DEFAULT_ICONS: std::sync::LazyLock = + std::sync::LazyLock::new(StatusIcons::default); + static EMPTY_HIDDEN: std::sync::LazyLock> = std::sync::LazyLock::new(Vec::new); + AgentsTabProps { + state, + agents, + pane_output, + input_text: None, + header_segments: segments, + field_colors: &EMPTY_COLORS, + status_icons: &DEFAULT_ICONS, + column_header_color: Color::White, + hidden_columns: &EMPTY_HIDDEN, + table_collapsed: false, + meta_collapsed: false, + show_help: false, + show_detail: false, + } + } + + fn make_terminal( + width: u16, + height: u16, + agents: &[AgentSessionDto], + segments: &[Segment], + pane_output: Option<&str>, + customize: F, + ) -> Result, Box> + where + F: FnOnce(&mut AgentsTabProps<'_>), + { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend)?; + let mut state = ListDetailState::new(); + state.set_count(agents.len()); + + terminal.draw(|frame| { + let mut props = make_props(&state, agents, pane_output, segments); + customize(&mut props); + render_agents_tab(frame, frame.area(), &props); + })?; + + Ok(terminal) + } + + fn draw_agents( + width: u16, + height: u16, + agents: &[AgentSessionDto], + segments: &[Segment], + pane_output: Option<&str>, + ) -> Result> { + let terminal = make_terminal(width, height, agents, segments, pane_output, |_| {})?; + Ok(render_to_string(&terminal)) + } + + fn draw_agents_with( + width: u16, + height: u16, + agents: &[AgentSessionDto], + segments: &[Segment], + pane_output: Option<&str>, + customize: F, + ) -> Result> + where + F: FnOnce(&mut AgentsTabProps<'_>), + { + let terminal = make_terminal(width, height, agents, segments, pane_output, customize)?; + Ok(render_to_string(&terminal)) + } + + #[test] + fn snapshot_empty_state() -> Result<(), Box> { + let output = draw_agents(80, 10, &[], &[], None)?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_single_agent_no_metadata() -> Result<(), Box> { + let segments = header::parse_format("${status:-10} ${elapsed:>8}"); + let agents = vec![make_agent("s1", "/home/user/myapp", "working")]; + let output = draw_agents(80, 20, &agents, &segments, None)?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_single_agent_with_metadata() -> Result<(), Box> { + let segments = header::parse_format("${project:-20} ${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({"project": "myapp", "branch": "main"}), + )]; + let output = draw_agents(80, 20, &agents, &segments, None)?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_multiple_agents() -> Result<(), Box> { + let segments = header::parse_format("${project:-20} ${status:-10}"); + let agents = vec![ + make_agent_with_meta( + "s1", + "/a/alpha", + "working", + serde_json::json!({"project": "alpha"}), + ), + make_agent_with_meta( + "s2", + "/b/beta", + "idle", + serde_json::json!({"project": "beta"}), + ), + ]; + let output = draw_agents(80, 25, &agents, &segments, None)?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_with_terminal_pane() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent("s1", "/home/user/project", "working")]; + let output = draw_agents(80, 25, &agents, &segments, Some("$ cargo test\nall passed"))?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_auto_discovered_columns() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/a/myapp", + "working", + serde_json::json!({ + "ws_status": "idle", + "blocked_on": "review", + "terminal": {"type": "tmux", "server": "default", "pane_id": "%0"} + }), + )]; + let output = draw_agents(100, 20, &agents, &segments, None)?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_hidden_only_metadata() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({"terminal": {"type": "tmux", "server": "default", "pane_id": "%0"}}), + )]; + let output = draw_agents(80, 20, &agents, &segments, Some("$ echo hello"))?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_detail_overlay_with_metadata() -> Result<(), Box> { + let segments = header::parse_format("${project:-20} ${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({ + "pid": "12345", + "project": "myapp", + "branch": "feat/new-api", + "terminal": {"type": "tmux", "server": "default", "pane_id": "%3"} + }), + )]; + let output = draw_agents_with(80, 30, &agents, &segments, None, |props| { + props.show_detail = true; + })?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_detail_overlay_no_metadata() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent("s1", "/home/user/project", "idle")]; + let output = draw_agents_with(80, 25, &agents, &segments, None, |props| { + props.show_detail = true; + })?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_table_collapsed() -> Result<(), Box> { + let segments = header::parse_format("${project:-20} ${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({"project": "myapp", "branch": "main"}), + )]; + let output = draw_agents_with(80, 20, &agents, &segments, None, |props| { + props.table_collapsed = true; + })?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_meta_collapsed() -> Result<(), Box> { + let segments = header::parse_format("${project:-20} ${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({"project": "myapp"}), + )]; + let output = draw_agents_with(80, 20, &agents, &segments, None, |props| { + props.meta_collapsed = true; + })?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_both_collapsed() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent("s1", "/home/user/myapp", "working")]; + let output = draw_agents_with( + 80, + 20, + &agents, + &segments, + Some("$ make build\nok"), + |props| { + props.table_collapsed = true; + props.meta_collapsed = true; + }, + )?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_help_overlay() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent("s1", "/home/user/myapp", "working")]; + let output = draw_agents_with(80, 25, &agents, &segments, None, |props| { + props.show_help = true; + })?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + fn snapshot_input_bar() -> Result<(), Box> { + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent("s1", "/home/user/project", "working")]; + let output = draw_agents_with( + 80, + 25, + &agents, + &segments, + Some("$ cargo test\nrunning..."), + |props| { + props.input_text = Some("make build"); + }, + )?; + insta::assert_snapshot!(output); + Ok(()) + } + + #[test] + #[ignore] + fn generate_screenshots() -> Result<(), Box> { + let segments = header::parse_format( + "${pid:6} ${session_id:8} ${cwd:-20} ${project:-12} ${branch:-12} ${status:-8} ${elapsed:>6}", + ); + let agents = vec![ + make_agent_with_meta( + "abc12345", + "/home/user/frontend", + "working", + serde_json::json!({"pid": "9001", "project": "frontend", "branch": "main"}), + ), + make_agent_with_meta( + "def67890", + "/home/user/backend", + "idle", + serde_json::json!({"pid": "9002", "project": "backend", "branch": "feat/api"}), + ), + make_agent_with_meta( + "ghi11111", + "/home/user/infra", + "working", + serde_json::json!({"pid": "9003", "project": "infra", "branch": "fix/deploy", "blocked_on": "review"}), + ), + ]; + save_screenshot( + &make_terminal(120, 20, &agents, &segments, None, |_| {})?, + "main-view", + )?; + + let segments = header::parse_format("${project:-20} ${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({ + "pid": "12345", + "project": "myapp", + "branch": "feat/new-api", + "terminal": {"type": "tmux", "server": "default", "pane_id": "%3"} + }), + )]; + save_screenshot( + &make_terminal(80, 28, &agents, &segments, None, |p| { + p.show_detail = true; + })?, + "detail-overlay", + )?; + + let segments = header::parse_format("${project:-16} ${status:-8}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/project", + "working", + serde_json::json!({ + "project": "myproject", + "terminal": {"type": "tmux", "server": "default", "pane_id": "%1"} + }), + )]; + save_screenshot( + &make_terminal( + 80, + 25, + &agents, + &segments, + Some( + "$ cargo test\nrunning 12 tests\ntest parse ... ok\ntest build ... ok\ntest lint ... ok", + ), + |p| { + p.input_text = Some("make deploy"); + }, + )?, + "terminal-input", + )?; + + let segments = header::parse_format("${status:-10}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({ + "terminal": {"type": "tmux", "server": "default", "pane_id": "%0"} + }), + )]; + save_screenshot( + &make_terminal( + 80, + 18, + &agents, + &segments, + Some("$ make build\nCompiling arbor-tui v0.1.0\n Finished dev profile"), + |p| { + p.table_collapsed = true; + p.meta_collapsed = true; + }, + )?, + "collapsed-panels", + )?; + + let segments = header::parse_format("${project:-16} ${status:-8}"); + let agents = vec![make_agent_with_meta( + "s1", + "/home/user/myapp", + "working", + serde_json::json!({"project": "myapp"}), + )]; + save_screenshot( + &make_terminal(80, 22, &agents, &segments, None, |p| { + p.show_help = true; + })?, + "help-overlay", + )?; + Ok(()) + } + + #[test] + fn snapshot_rich_metadata_columns() -> Result<(), Box> { + let segments = header::parse_format( + "${pid:6} ${session_id:8} ${cwd:-20} ${project:-12} ${branch:-12} ${status:-8} ${elapsed:>6}", + ); + let agents = vec![ + make_agent_with_meta( + "abc12345", + "/home/user/frontend", + "working", + serde_json::json!({"pid": "9001", "project": "frontend", "branch": "main"}), + ), + make_agent_with_meta( + "def67890", + "/home/user/backend", + "idle", + serde_json::json!({"pid": "9002", "project": "backend", "branch": "feat/api"}), + ), + make_agent_with_meta( + "ghi11111", + "/home/user/infra", + "working", + serde_json::json!({"pid": "9003", "project": "infra", "branch": "fix/deploy", "blocked_on": "review"}), + ), + ]; + let output = draw_agents(120, 25, &agents, &segments, None)?; + insta::assert_snapshot!(output); + Ok(()) + } +} diff --git a/crates/arbor-tui/src/tabs/mod.rs b/crates/arbor-tui/src/tabs/mod.rs new file mode 100644 index 00000000..8fb115ff --- /dev/null +++ b/crates/arbor-tui/src/tabs/mod.rs @@ -0,0 +1 @@ +pub mod agents; diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_auto_discovered_columns.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_auto_discovered_columns.snap new file mode 100644 index 00000000..1610ac1d --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_auto_discovered_columns.snap @@ -0,0 +1,25 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 564 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────────────────────────┐ +│ status blocked_on ws_status │ +│● working review idle │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────────────────────────┐ +│blocked_on review │ +│ws_status idle │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_both_collapsed.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_both_collapsed.snap new file mode 100644 index 00000000..0b43a3fe --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_both_collapsed.snap @@ -0,0 +1,25 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 744 +expression: output +--- +Agents [t to expand]──────────────────────────────────────────────────────────── +Metadata [m to expand]────────────────────────────────────────────────────────── +┌Terminal (i=input)────────────────────────────────────────────────────────────┐ +│$ make build │ +│ok │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_no_metadata.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_no_metadata.snap new file mode 100644 index 00000000..395c5f25 --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_no_metadata.snap @@ -0,0 +1,30 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 703 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ status │ +│○ ┌ Agent Detail (Enter to close) ───────────────────────────────────────┐ │ +└───│session_id s1 │───┘ +┌Met│cwd /home/user/project │───┐ +│Thi│state idle │ │ +│ │updated_at 18446744073709551615 │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_with_metadata.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_with_metadata.snap new file mode 100644 index 00000000..cd3dbdf6 --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_detail_overlay_with_metadata.snap @@ -0,0 +1,35 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 693 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ project status branch pid │ +│● myapp working feat/new-api 12345 │ +└───┌ Agent Detail (Enter to close) ───────────────────────────────────────┐───┘ +┌Met│session_id s1 │───┐ +│bra│cwd /home/user/myapp │ │ +│pid│state working │ │ +│pro│updated_at 18446744073709551615 │ │ +│ │ │ │ +│ │── Metadata ── │ │ +│ │branch feat/new-api │ │ +│ │pid 12345 │ │ +│ │project myapp │ │ +│ │terminal { │ │ +│ │ "pane_id": "%3", │ │ +│ │ "server": "default", │ │ +│ │ "type": "tmux" │ │ +│ │ } │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_empty_state.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_empty_state.snap new file mode 100644 index 00000000..a490448e --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_empty_state.snap @@ -0,0 +1,15 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 497 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│No agents detected. │ +│ │ +│Agents appear when Claude Code hooks │ +│POST to /api/v1/agent/notify. │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_help_overlay.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_help_overlay.snap new file mode 100644 index 00000000..efda9cdd --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_help_overlay.snap @@ -0,0 +1,30 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 920 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ status │ +│● working │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│This agent is not publishing metadata via hooks. │ +│ ┌ Help (? to close) ───────────────────────┐ │ +│ │Keybindings: │ │ +│ │ q / C-c Quit │ │ +│ │ j / Down Navigate down │ │ +│ │ k / Up Navigate up │ │ +│ │ t Toggle agents table │ │ +│ │ m Toggle metadata panel │ │ +│ │ Enter Agent detail popup │ │ +│ │ r Refresh │ │ +│ │ i Send input to terminal pane │ │ +│ │ ? Toggle this help │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_hidden_only_metadata.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_hidden_only_metadata.snap new file mode 100644 index 00000000..1bbde1dc --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_hidden_only_metadata.snap @@ -0,0 +1,25 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 577 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ status │ +│● working │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│Session s1 │ +│CWD /home/user/myapp │ +│State working │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Terminal (i=input)────────────────────────────────────────────────────────────┐ +│$ echo hello │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_input_bar.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_input_bar.snap new file mode 100644 index 00000000..b04241df --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_input_bar.snap @@ -0,0 +1,30 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 764 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ status │ +│● working │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│This agent is not publishing metadata via hooks. │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Terminal (i=input)────────────────────────────────────────────────────────────┐ +│$ cargo test │ +│running... │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ Send to pane (Esc to cancel) ────────────────────────────────────────────────┐ +│› make build█ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_meta_collapsed.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_meta_collapsed.snap new file mode 100644 index 00000000..c1af6b73 --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_meta_collapsed.snap @@ -0,0 +1,10 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 733 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ project status │ +│● myapp working │ +└──────────────────────────────────────────────────────────────────────────────┘ +Metadata [m to expand]────────────────────────────────────────────────────────── diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_multiple_agents.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_multiple_agents.snap new file mode 100644 index 00000000..3d906b5e --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_multiple_agents.snap @@ -0,0 +1,30 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 539 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ project status │ +│● alpha working │ +│○ beta idle │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│project alpha │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_rich_metadata_columns.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_rich_metadata_columns.snap new file mode 100644 index 00000000..00fd1a54 --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_rich_metadata_columns.snap @@ -0,0 +1,30 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 791 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ pid session_id cwd project branch status elapsed blocked_on │ +│● 9001 abc12345 /home/user/frontend frontend main working 0s │ +│○ 9002 def67890 /home/user/backend backend feat/api idle 0s │ +│● 9003 ghi11111 /home/user/infra infra fix/deploy working 0s review │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│branch main │ +│pid 9001 │ +│project frontend │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_no_metadata.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_no_metadata.snap new file mode 100644 index 00000000..78d3e80f --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_no_metadata.snap @@ -0,0 +1,25 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 505 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ status elapsed │ +│● working 0s │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│This agent is not publishing metadata via hooks. │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_with_metadata.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_with_metadata.snap new file mode 100644 index 00000000..a8dfa6bb --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_single_agent_with_metadata.snap @@ -0,0 +1,25 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 518 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ project status branch │ +│● myapp working main │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│branch main │ +│project myapp │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_table_collapsed.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_table_collapsed.snap new file mode 100644 index 00000000..1ab6f11c --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_table_collapsed.snap @@ -0,0 +1,25 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 718 +expression: output +--- +Agents [t to expand]──────────────────────────────────────────────────────────── +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│branch main │ +│project myapp │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_with_terminal_pane.snap b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_with_terminal_pane.snap new file mode 100644 index 00000000..edf5cd3b --- /dev/null +++ b/crates/arbor-tui/src/tabs/snapshots/arbor_tui__tabs__agents__tests__snapshot_with_terminal_pane.snap @@ -0,0 +1,30 @@ +--- +source: crates/arbor-tui/src/tabs/agents.rs +assertion_line: 547 +expression: output +--- +┌Agents────────────────────────────────────────────────────────────────────────┐ +│ status │ +│● working │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Metadata──────────────────────────────────────────────────────────────────────┐ +│This agent is not publishing metadata via hooks. │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌Terminal (i=input)────────────────────────────────────────────────────────────┐ +│$ cargo test │ +│all passed │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/arbor-tui/src/widgets/list_detail.rs b/crates/arbor-tui/src/widgets/list_detail.rs new file mode 100644 index 00000000..6aab8708 --- /dev/null +++ b/crates/arbor-tui/src/widgets/list_detail.rs @@ -0,0 +1,85 @@ +pub struct ListDetailState { + pub selected: usize, + pub count: usize, +} + +impl ListDetailState { + pub fn new() -> Self { + Self { + selected: 0, + count: 0, + } + } + + pub fn select_next(&mut self) { + if self.count > 0 { + self.selected = (self.selected + 1).min(self.count - 1); + } + } + + pub fn select_prev(&mut self) { + self.selected = self.selected.saturating_sub(1); + } + + pub fn set_count(&mut self, count: usize) { + if count == 0 { + self.selected = 0; + } else if self.selected >= count { + self.selected = count - 1; + } + self.count = count; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_state(selected: usize, count: usize) -> ListDetailState { + let mut state = ListDetailState::new(); + state.count = count; + state.selected = selected; + state + } + + #[test] + fn select_next_advances_and_clamps() { + let mut state = make_state(0, 3); + state.select_next(); + assert_eq!(state.selected, 1); + state.select_next(); + assert_eq!(state.selected, 2); + state.select_next(); + assert_eq!(state.selected, 2); + } + + #[test] + fn select_prev_decrements_and_stops_at_zero() { + let mut state = make_state(1, 3); + state.select_prev(); + assert_eq!(state.selected, 0); + state.select_prev(); + assert_eq!(state.selected, 0); + } + + #[test] + fn set_count_clamps_selected() { + let mut state = make_state(5, 10); + state.set_count(3); + assert_eq!(state.selected, 2); + } + + #[test] + fn set_count_zero_resets_selected() { + let mut state = make_state(5, 10); + state.set_count(0); + assert_eq!(state.selected, 0); + } + + #[test] + fn select_next_noop_when_empty() { + let mut state = make_state(0, 0); + state.select_next(); + assert_eq!(state.selected, 0); + } +} diff --git a/crates/arbor-tui/src/widgets/mod.rs b/crates/arbor-tui/src/widgets/mod.rs new file mode 100644 index 00000000..de414bbc --- /dev/null +++ b/crates/arbor-tui/src/widgets/mod.rs @@ -0,0 +1,2 @@ +pub mod list_detail; +pub mod status_bar; diff --git a/crates/arbor-tui/src/widgets/status_bar.rs b/crates/arbor-tui/src/widgets/status_bar.rs new file mode 100644 index 00000000..42238f17 --- /dev/null +++ b/crates/arbor-tui/src/widgets/status_bar.rs @@ -0,0 +1,100 @@ +use ratatui::{prelude::*, widgets::*}; + +pub fn render_status_bar( + frame: &mut Frame, + area: Rect, + connected: bool, + last_poll_secs: Option, + action_hints: &[(char, &str)], +) { + let mut spans = Vec::new(); + + if connected { + spans.push(Span::styled( + "● Connected", + Style::default().fg(Color::Green), + )); + } else { + spans.push(Span::styled( + "● Disconnected", + Style::default().fg(Color::Red), + )); + } + + if let Some(secs) = last_poll_secs { + spans.push(Span::raw(format!(" Poll: {secs}s ago"))); + } + + spans.push(Span::raw(" ")); + spans.push(Span::styled("q", Style::default().fg(Color::Yellow))); + spans.push(Span::raw(":quit ")); + spans.push(Span::styled("j/k", Style::default().fg(Color::Yellow))); + spans.push(Span::raw(":nav")); + + for (key, name) in action_hints { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("{key}"), + Style::default().fg(Color::Cyan), + )); + spans.push(Span::raw(format!(":{name}"))); + } + + let line = Line::from(spans); + frame.render_widget(Paragraph::new(line), area); +} + +#[cfg(test)] +mod tests { + use { + super::*, + ratatui::{Terminal, backend::TestBackend}, + }; + + fn render_bar( + connected: bool, + poll_secs: Option, + action_hints: &[(char, &str)], + ) -> Result> { + let backend = TestBackend::new(100, 1); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + render_status_bar(frame, frame.area(), connected, poll_secs, action_hints); + })?; + + let buf = terminal.backend().buffer().clone(); + Ok((0..100).map(|x| buf[(x, 0)].symbol().to_string()).collect()) + } + + #[test] + fn test_status_bar_shows_connected() -> Result<(), Box> { + let text = render_bar(true, Some(3), &[])?; + assert!(text.contains("Connected"), "connected status missing"); + assert!(text.contains("3s ago"), "poll time missing"); + Ok(()) + } + + #[test] + fn test_status_bar_shows_disconnected() -> Result<(), Box> { + let text = render_bar(false, None, &[])?; + assert!(text.contains("Disconnected"), "disconnected status missing"); + Ok(()) + } + + #[test] + fn test_status_bar_shows_builtin_hints() -> Result<(), Box> { + let text = render_bar(true, None, &[])?; + assert!(text.contains("quit"), "quit hint missing"); + assert!(text.contains("nav"), "nav hint missing"); + Ok(()) + } + + #[test] + fn test_status_bar_shows_action_hints() -> Result<(), Box> { + let text = render_bar(true, None, &[('g', "goto"), ('o', "open")])?; + assert!(text.contains("goto"), "action hint 'goto' missing"); + assert!(text.contains("open"), "action hint 'open' missing"); + Ok(()) + } +}