Cockpit in the TUI (native ratatui view + CLI verbs + cross-machine) (#1114)
* refactor(cockpit): extract wire types into protocol.rs; fix replay lost semantics Foundation commit for the TUI cockpit view (issue #1018). Moves the HTTP / WebSocket wire types out of the server module so daemon, web frontend, CLI cockpit verbs, and the upcoming TUI cockpit view all import a single source of truth. A rename in one place now breaks every consumer at compile time instead of going silently divergent. Also fixes the hardcoded `lost: false` in /cockpit/replay: the event store now exposes lowest_seq(), and the endpoint reports lost = since < lowest_seq - 1 so a client returning after a long absence learns its history was truncated and can reload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cockpit): add daemon client (HTTP + WS + discovery + auto-spawn) Lays the substrate for the CLI cockpit verbs and the TUI cockpit view to come. Talks to an `aoe serve` daemon over the existing per-session cockpit REST + WebSocket surface; one shared `DaemonEndpoint` type backs both layers and the wire-type re-use from `cockpit::protocol` guarantees client and server can't drift on JSON shape. - discovery: AOE_DAEMON_URL env override first, then local serve.url paired with a live serve.pid. Loopback alternates preferred over tunnel addresses for same-box clients. Token extracted to a separate field so it travels as `Authorization: Bearer` (HTTP) or `?token=` (WS) but never gets logged. - http: typed client per cockpit verb (replay, context_primer, prompt, cancel, resolve_approval) mapped 1:1 to the per-session REST routes. 401 -> Unauthorized, 403 read-only -> ReadOnly, 404 -> SessionNotFound for clean error UX. - ws: tokio-tungstenite stream parsing CockpitBroadcastFrame and the `{"kind":"lagged"}` sentinel, plus a shutdown channel and url sanitiser so the token is never logged. - daemon_manager: ensure_daemon() that auto-spawns a loopback-only long-lived `aoe serve` if neither AOE_DAEMON_URL nor a live local daemon are found. AOE_DAEMON_URL set -> never auto-spawn, fail loud. tokio-tungstenite added as an optional dep folded into the `serve` feature alongside the rest of the cockpit surface. Both `cockpit/` and `server/` are already serve-gated, so keeping the client there is the smaller change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): aoe cockpit {history,status,prompt,approve,cancel,tail} Wires the daemon client into actual user-facing CLI verbs so the client surface added in 51ac75f doesn't sit dead. Auto-spawns a loopback daemon on first invocation, then talks to it over HTTP/WS: - history <id> [--since N] [--json]: dump persisted transcript. - status <id> [--json]: highest/lowest seq + lost flag + daemon source. - prompt <id> <text|->: send a prompt (`-` reads from stdin). - approve <id> <nonce> [--always|--deny]: resolve a pending approval. - cancel <id>: cancel the in-flight prompt. - tail <id> [--since N]: stream broadcast frames to stdout as JSON. ReplayResponse gains a `lowest_seq: Option<u64>` field so `status` can show the retention floor. Default-`None` on the `#[serde(default)]` attribute keeps the wire shape backwards-compatible with any client that pinned the old shape (frontend uses serde_json, treats unknown fields as harmless). `attach` verb deferred to commit 4 alongside the TUI cockpit view it opens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tui): native cockpit view Renders a cockpit session inside the TUI instead of toasting users toward the web dashboard. Mirrors the activity semantics of web/src/hooks/useCockpit.ts in a flat-row Rust reducer; talks to the local daemon via the cockpit::client added in 51ac75f. - src/tui/cockpit_view/reducer.rs: pure CockpitBroadcastFrame -> ActivityRow accumulator. Groups consecutive AgentMessageChunk events into one assistant row; flushes the buffer on any non-chunk event so intervening tool calls don't bleed into the wrong message turn. Mutates tool rows in place when ToolCallCompleted lands; resolves approvals by nonce lookup. Dedupes against duplicate seqs from the replay-vs-live overlap. - src/tui/cockpit_view/input.rs: focus model with three regions (composer / transcript / approval). Composer captures every key including `a`/`A`/`d`, so typing "always allow" with a pending approval can never silently resolve it (regression test pins this). - src/tui/cockpit_view/render.rs: three-pane layout (transcript / status banner / composer). Tool cards render as one-liners with truncated args + content preview; rich diff / image previews are deferred to followups, accessed via `o` to open the web view. - src/tui/cockpit_view/state.rs: owned view state (transcript, composer textarea, focus, ws handle, toast banner). - src/tui/cockpit_view/mod.rs: async orchestrator. Calls ensure_daemon, hydrates via /replay, opens WS, runs a tokio::select! over key events + ws frames + a redraw ticker, and reconnects on ws drop using the last seq. - src/tui/app.rs: new `Action::OpenCockpit(SessionId)` variant (serve-feature-gated) plus `pending_cockpit_open` slot the sync action handler uses to hand control back to the async loop, which then borrows event_stream + terminal for the cockpit run. - src/tui/home/input.rs: replace the "open the web dashboard" toast with Action::OpenCockpit when serve is built in; keep the toast as fallback for non-serve builds. - src/tui/home/render.rs: rename `[web]` badge to `[cockpit]` now that the TUI renders the session natively. - src/cockpit/protocol.rs: derive PartialEq+Eq on ApprovalDecisionWire so the input dispatcher's Intent enum can derive PartialEq. Help-screen keybinds, the `aoe cockpit attach` CLI verb, and the focus-isolation e2e regression are deferred to a follow-up commit so this diff stays scoped to "wire up the view." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): add aoe cockpit attach <id> Jumps straight into the TUI cockpit view for a known session id without going through the home screen. The intended pairing is `AOE_DAEMON_URL=https://remote.tld AOE_DAEMON_TOKEN=... aoe cockpit attach <id>`: attach to a remote cockpit session from a developer machine that doesn't have a local serve daemon (and shouldn't auto- spawn one — `ensure_daemon` short-circuits under the env override). cockpit_view::run_standalone takes care of the alternate-screen terminal setup that the parent TUI normally owns: enable_raw_mode, EnterAlternateScreen, EnableBracketedPaste, EnableMouseCapture; then the shared `run()` loop drives the view; then the same teardown sequence in reverse. Uses the empire theme since standalone attach doesn't load the home view's saved theme preference. Help-screen keybinds for the cockpit view are intentionally NOT added to the home help overlay — they only apply while the cockpit view is focused, and the home dialog is already at 41/42 lines of available height. The cockpit view's status banner already surfaces a focus-specific help hint inline, and docs/cockpit.md (commit 7) gets the full table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tui): cross-machine remote-cockpit picker Closes the cross-machine half of #1018. When `AOE_DAEMON_URL` is set (via env var or the new `--daemon-url` flag on `aoe`), the TUI swaps the local home view for a remote-cockpit picker that talks to the named daemon via HTTPS instead of reading the local FS. The remote view is intentionally separate from `HomeView` rather than a `SessionSource`-trait abstraction over it: HomeView is deeply local-coupled (tmux PTYs, on-disk Storage, container/host terminal mode, per-session profile management), and the remote surface only needs the small slice of "list cockpit sessions, open one, refresh." Trying to share the same struct would push tmux abstractions through a layer that has no notion of tmux. Two scoped surfaces are simpler than one over-generalised one. What the remote picker does: - Calls `GET /api/sessions` against the daemon, filters to `cockpit_mode = true` (tmux PTYs aren't reachable cross-machine without SSH). - Renders a selectable list (j/k navigate, Enter opens, r refreshes, q/Esc exits). - On Enter, hands off to `cockpit_view::run_for_endpoint`, a new variant of the cockpit run loop that takes a pre-discovered endpoint instead of auto-spawning. The user explicitly chose a remote daemon; silently auto-spawning a local one would attach to the wrong universe. Local-only operations (tmux attach, edit file, session stop, file diff) are absent rather than disabled: they aren't reachable on this machine, so showing them grayed out would mislead. The web dashboard remains the long-tail surface for remote management. `--daemon-url` is wired as a clap arg with `env = "AOE_DAEMON_URL"`, so the flag and env var are unified. When the flag is passed, main.rs mirrors the value back into the env so the same discovery code path the CLI cockpit verbs use also resolves it. The full `SessionSource` trait abstraction over `HomeView` from the original 4-PR plan is intentionally deferred: the remote picker already meets the acceptance criteria ("AOE_DAEMON_URL=... aoe opens a TUI that shows the remote session list, all cockpit operations work transparently"), and the broader refactor would touch every HomeView call site without much functional gain. Tracked as a followup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(cockpit): document TUI cockpit view + CLI verbs + cross-machine Replaces the "cockpit is web-dashboard only" framing in docs/cockpit.md with the actual TUI surface, the keybind table (composer / transcript / approval focus model, plus the focus-isolation guarantee), the cross-machine attach workflow via AOE_DAEMON_URL or `aoe cockpit attach`, and the full CLI verb catalogue. Regenerates docs/cli/reference.md via `cargo xtask gen-docs` so the new clap surface (`aoe --daemon-url`, the cockpit verbs, the attach flag) is pulled into the canonical reference (CI enforces this stays in sync). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(serve): add `aoe serve --status` for daemon introspection Prints PID, mode, primary + alternate URLs, and log path when the daemon is up; exits non-zero with an actionable hint otherwise. Mirrors `aoe url`'s patterns (bail-when-down, no token redaction) since both run under the same local-only security boundary. Wired through clap with `conflicts_with_all = ["stop","daemon","remote"]` so misuse fails early. * feat(serve): clearer error when daemon already running Previously: "Stop it first with `aoe serve --stop`." which hid the fact that the existing daemon is probably what the user wants. Now suggests `--status`, `aoe url`, and `--stop` so the discovery path is obvious without `--help` or docs. * feat(client): pre-flight health-check + env-aware `aoe serve --status` Two fixes for AOE_DAEMON_URL UX: 1. `ensure_daemon` now health-checks the env override endpoint before returning, so `aoe cockpit *` against an unreachable URL fails loud ("AOE_DAEMON_URL is set but the daemon at that URL is unreachable") instead of bubbling up a raw reqwest transport stack from each verb. 2. `aoe serve --status` now follows AOE_DAEMON_URL: when set, it pings the remote endpoint and reports reachability + token state instead of inspecting the local `serve.pid` file. Same friendly error path when the remote is down. Adds `HttpClient::health_check()` (GET /api/sessions) for both. The endpoint is cheap, authenticated, and separates "host down" (transport error) from "auth misconfigured" (401) at the call site. Doc note in cockpit.md spelling out the retarget so users can predict the behavior without reading the source. * fix(cockpit-view): auto-scroll past wrapped agent-message chunks Transcript scroll was clamped against `lines.len()` (logical line count). Streaming AgentMessageChunk events grow text *within* a single Line, which the Paragraph then wraps to multiple visual rows — the logical count stayed constant, so `scroll_offset = u16::MAX` (stick-to-bottom) clipped short of the newest chunk. Tool calls didn't show the bug because each call pushes whole new Line entries. Switch to `visual_line_count(lines, width)`: per-line display width divided by available columns, rounded up, summed. Approximate (no account for tabs/control chars) but accurate for the streaming-text case that matters. Unit-pinned with a regression test. * fix(cockpit): address PR review (UTF-8 panic, em-dashes, error fidelity) - render: replace byte-slice truncation with char-safe truncate_chars so tool args/content with multi-byte codepoints at the cutoff don't panic the TUI. Regression tests pin the boundary cases. - client/ws: real WsError::Parse variant instead of fabricating an InvalidOpcode protocol error; the toast now carries the actual reason. - client/daemon_manager: distinguish EnvOverrideUnauthorized from EnvOverrideUnreachable so a wrong AOE_DAEMON_TOKEN shows the right message; cockpit_view renders both. NoExecutable no longer absorbs log-file IO failures (new LogFile variant). - style: sweep em-dashes from PR-added Rust comments, status-banner string literals, and docs/cockpit.md per project rule. xtask post- processes clap_markdown's hard-coded em-dash bullet separator so docs/cli/reference.md regenerates without them and future CLI flags inherit the rule automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cockpit): apply remaining PR review polish - cli/serve: --status now conflicts with --no-auth, --read-only, --passphrase, --port, --tunnel-name, --no-tailscale, --tunnel-url, --open in addition to --stop/--daemon/--remote, so a misuse like `aoe serve --status --port 9000` fails fast instead of silently accepting the extras. - client/ws: WsHandle::shutdown awaits the reader task's graceful close with a 200ms budget before falling back to abort(), so a Close frame actually reaches the daemon on clean exits. - client/http: list_sessions<T>() reuses the shared auth/header plumbing for GET /api/sessions. The remote-home picker drops its bespoke reqwest::Client and goes through HttpClient like every other cockpit verb does. - cockpit_view: reconnect_with_backoff replaces the one-shot WS reconnect with 250/500/1000ms attempts so a 2-second daemon bounce recovers without paging the user. - cockpit_view: composer height magic numbers become COMPOSER_BORDER_ROWS + COMPOSER_MAX_CONTENT_ROWS. - cockpit_view/reducer: tracing::debug! when a frame is dropped against last_seq, so a true reordering shows up in logs without spamming on the normal replay/live overlap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cockpit): require a running daemon; remove auto-spawn Opening a cockpit session from the TUI (or running any aoe cockpit verb) used to auto-spawn a loopback aoe serve in the background if one wasn't already running. That hid the choice between localhost, Tailscale Funnel, and Cloudflare tunnel from the user, and left an aoe serve process behind that they didn't ask for. Now require_daemon() (renamed from ensure_daemon) returns ManagerError::NoDaemonRunning with a multi-line actionable hint pointing at: aoe serve --daemon (localhost only) aoe serve --daemon --remote (Tailscale Funnel / Cloudflare) aoe serve --daemon --tunnel-name … (named Cloudflare Tunnel) …and AOE_DAEMON_URL for attaching to an existing remote daemon. The TUI renders the message in the cockpit error screen; the CLI verbs print it to stderr and exit non-zero. setsid()-detached spawn, deadline polling, log file management, and the related error variants (NoExecutable, LogFile, SpawnFailedFast, SpawnTimeout) are all gone. docs/cockpit.md updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): regen CLI docs, allow webpki-roots license, drop em-dash from xtask comment Three small fixes for the CI failures on PR #1114: - Docs check: the multi-line `--status` doc I added incb95fbbwasn't reflected in docs/cli/reference.md (forgot to re-run `cargo xtask gen-docs`). Regenerated. Also dropped the now-stale "cockpit auto-spawn flow" phrase from the help text since auto-spawn was removed in3dc14ef. - Supply Chain: tokio-tungstenite's `rustls-tls-webpki-roots` feature pulls in `webpki-roots` (CDLA-Permissive-2.0). deny.toml already whitelists CDLA-Permissive-2.0 for the equivalent `webpki-root-certs` crate that reqwest uses; extend the same exception to `webpki-roots`. - xtask: the comment explaining the em-dash strip itself contained a literal em-dash, contradicting its own rule. Rewrote with a Unicode escape so the source file has zero literal em-dash code points and the strip target is described unambiguously. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(xtask): drop em-dash strip in gen-docs The project's no-em-dash rule is about human-authored docs and comments, where the writer should have picked a comma or semicolon. clap-markdown's bullet separator is a renderer choice applied uniformly to every entry; there's no human author to nudge, and the strip created a layer of indirection in xtask that future contributors would have had to remember. Reverts the strip introduced earlier in this branch; docs/cli/reference.md goes back to clap-markdown's native output (209 em-dashes restored). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agents): note em-dash exception for clap-generated CLI reference The no-em-dash rule is about human-authored docs and comments. docs/cli/reference.md is generated by `cargo xtask gen-docs` and inherits clap-markdown's em-dash bullet separator uniformly; that's a renderer choice, not prose, so leave it alone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agents): generalize em-dash exception to all auto-generated content Carving out a single file (docs/cli/reference.md) was too narrow: the underlying principle is "human-authored prose only." Rephrase so future auto-generated docs (changelogs, API refs, schemas) are covered without needing another edit here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(agents): trim the em-dash exception example The rule stands on its own without the reference.md example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: njbrake <nathan@mozilla.ai> Co-authored-by: Nathan Brake <33383515+njbrake@users.noreply.github.com>
This commit is contained in:
@@ -53,7 +53,7 @@ Every configurable field must be editable in the settings TUI. When adding one t
|
||||
|
||||
- Let `cargo fmt` + `cargo clippy` decide; fix warnings.
|
||||
- **No dead code.** Never add `#[allow(dead_code)]` or write fields/functions that nothing reads. If a field isn't used yet, don't add it; if it stops being used, remove it.
|
||||
- **No emdashes or `--`** as separators in docs/comments; use commas, semicolons, or rephrase.
|
||||
- **No emdashes or `--`** as separators in docs/comments; use commas, semicolons, or rephrase. The rule applies to human-authored prose only; auto-generated content inherits whatever its renderer emits, so leave those files alone.
|
||||
- Rust naming: `snake_case` modules/functions, `CamelCase` types, `SCREAMING_SNAKE_CASE` constants.
|
||||
- Keep OS-specific logic in `src/process/{macos,linux}.rs`, not sprinkled `cfg` checks.
|
||||
- Don't preserve backwards compatibility by default; call it out when a change is breaking.
|
||||
|
||||
Generated
+25
@@ -154,6 +154,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tower-http",
|
||||
@@ -4252,8 +4253,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4456,6 +4461,8 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -4803,6 +4810,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -142,6 +142,12 @@ agent-client-protocol-tokio = { version = "0.11", optional = true }
|
||||
tar = { version = "0.4", optional = true }
|
||||
xz2 = { version = "0.1", optional = true }
|
||||
|
||||
# WebSocket client for the cockpit-in-TUI view: subscribes to the daemon's
|
||||
# `/sessions/{id}/cockpit/ws` stream and pumps frames into the local reducer.
|
||||
# Folded into the `serve` feature alongside the rest of the cockpit surface;
|
||||
# the daemon itself uses axum's built-in WS support and never pulls this in.
|
||||
tokio-tungstenite = { version = "0.29", default-features = false, features = ["rustls-tls-webpki-roots", "connect"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Re-exports private tmux env helpers via `crate::tmux::test_support` so that
|
||||
@@ -172,6 +178,7 @@ serve = [
|
||||
"agent-client-protocol-tokio",
|
||||
"tar",
|
||||
"xz2",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -31,8 +31,13 @@ allow = [
|
||||
exceptions = [
|
||||
# UEFI-only crate, not linked into the final binary on Linux/macOS
|
||||
{ allow = ["LGPL-2.1-or-later"], crate = "r-efi" },
|
||||
# CDLA-Permissive-2.0 is a permissive data license
|
||||
# CDLA-Permissive-2.0 is a permissive data license. Both crates
|
||||
# ship Mozilla's CA bundle under that license; the project tree
|
||||
# ends up with whichever one the dependency happens to pull in
|
||||
# (`reqwest` uses `webpki-root-certs`; `tokio-tungstenite` uses
|
||||
# `webpki-roots`).
|
||||
{ allow = ["CDLA-Permissive-2.0"], crate = "webpki-root-certs" },
|
||||
{ allow = ["CDLA-Permissive-2.0"], crate = "webpki-roots" },
|
||||
]
|
||||
|
||||
[licenses.private]
|
||||
|
||||
@@ -63,6 +63,13 @@ This document contains the help content for the `aoe` command-line program.
|
||||
* [`aoe cockpit kill`↴](#aoe-cockpit-kill)
|
||||
* [`aoe cockpit logs`↴](#aoe-cockpit-logs)
|
||||
* [`aoe cockpit restart`↴](#aoe-cockpit-restart)
|
||||
* [`aoe cockpit history`↴](#aoe-cockpit-history)
|
||||
* [`aoe cockpit status`↴](#aoe-cockpit-status)
|
||||
* [`aoe cockpit prompt`↴](#aoe-cockpit-prompt)
|
||||
* [`aoe cockpit approve`↴](#aoe-cockpit-approve)
|
||||
* [`aoe cockpit cancel`↴](#aoe-cockpit-cancel)
|
||||
* [`aoe cockpit tail`↴](#aoe-cockpit-tail)
|
||||
* [`aoe cockpit attach`↴](#aoe-cockpit-attach)
|
||||
* [`aoe uninstall`↴](#aoe-uninstall)
|
||||
* [`aoe update`↴](#aoe-update)
|
||||
* [`aoe completion`↴](#aoe-completion)
|
||||
@@ -104,6 +111,7 @@ Run without arguments to launch the TUI dashboard.
|
||||
###### **Options:**
|
||||
|
||||
* `-p`, `--profile <PROFILE>` — Profile to use (separate workspace with its own sessions)
|
||||
* `--daemon-url <DAEMON_URL>` — Attach to a remote cockpit daemon instead of using the local session list. Equivalent to setting `AOE_DAEMON_URL`; pair with `AOE_DAEMON_TOKEN` for the bearer token. Only meaningful at the no-subcommand `aoe` invocation (the TUI dashboard); ignored otherwise
|
||||
|
||||
|
||||
|
||||
@@ -818,6 +826,9 @@ Start a web dashboard for remote session access
|
||||
* `--tunnel-url <TUNNEL_URL>` — Hostname for a named tunnel (e.g., aoe.example.com)
|
||||
* `--daemon` — Run as a background daemon (detach from terminal)
|
||||
* `--stop` — Stop a running daemon
|
||||
* `--status` — Print the running daemon's PID, mode, URLs, and log path. Exits non-zero when no daemon is running. Useful for shell scripts that want to know whether a daemon is up without parsing `ps`.
|
||||
|
||||
`--status` is read-only and incompatible with every flag that would change daemon state (`--stop`, `--daemon`, `--remote`) or the bind config of a fresh daemon (`--no-auth`, `--read-only`, `--passphrase`, `--port`, `--tunnel-name`, `--no-tailscale`, `--tunnel-url`, `--open`). Clap reports the misuse instead of silently ignoring the extras.
|
||||
* `--passphrase <PASSPHRASE>` — Require a passphrase for login (second-factor auth). Can also be set via AOE_SERVE_PASSPHRASE environment variable
|
||||
* `--open` — Open the dashboard URL in the default browser once the server is ready. Ignored under --daemon, --remote, SSH (SSH_CONNECTION/SSH_TTY), or when no display server is reachable on Linux/BSD
|
||||
|
||||
@@ -851,6 +862,13 @@ Cockpit (ACP-based native agent rendering) management
|
||||
* `kill` — SIGKILL a worker immediately (use when `stop` doesn't take)
|
||||
* `logs` — Tail the runner's log file for a cockpit session
|
||||
* `restart` — Restart a wedged cockpit worker: stop the existing runner, then let the daemon's reconciler spawn a fresh one on the next tick
|
||||
* `history` — Print the persisted transcript for a cockpit session
|
||||
* `status` — Print live status for a cockpit session: highest/lowest seq, and whether the on-disk retention window has truncated history
|
||||
* `prompt` — Send a prompt to a cockpit session's agent
|
||||
* `approve` — Resolve a pending approval (default: allow). Use --always for a session-scoped allow-list entry, --deny to refuse the request
|
||||
* `cancel` — Cancel the in-flight prompt for a cockpit session
|
||||
* `tail` — Stream the cockpit broadcast for a session to stdout as JSON lines (one frame per line). Press Ctrl-C to stop
|
||||
* `attach` — Open the TUI cockpit view directly for a known session id. Combine with `AOE_DAEMON_URL` (+ `AOE_DAEMON_TOKEN`) to attach across machines without going through the home session list
|
||||
|
||||
|
||||
|
||||
@@ -943,6 +961,114 @@ Restart a wedged cockpit worker: stop the existing runner, then let the daemon's
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit history`
|
||||
|
||||
Print the persisted transcript for a cockpit session
|
||||
|
||||
**Usage:** `aoe cockpit history [OPTIONS] <SESSION>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--since <SINCE>` — Skip events at or below this seq
|
||||
|
||||
Default value: `0`
|
||||
* `--json` — Emit raw frames as JSON (one frame per line)
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit status`
|
||||
|
||||
Print live status for a cockpit session: highest/lowest seq, and whether the on-disk retention window has truncated history
|
||||
|
||||
**Usage:** `aoe cockpit status [OPTIONS] <SESSION>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--json` — Emit machine-readable JSON instead of a human report
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit prompt`
|
||||
|
||||
Send a prompt to a cockpit session's agent
|
||||
|
||||
**Usage:** `aoe cockpit prompt <SESSION> <TEXT>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
* `<TEXT>` — Prompt text. Pass `-` to read from stdin
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit approve`
|
||||
|
||||
Resolve a pending approval (default: allow). Use --always for a session-scoped allow-list entry, --deny to refuse the request
|
||||
|
||||
**Usage:** `aoe cockpit approve [OPTIONS] <SESSION> <NONCE>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
* `<NONCE>` — Approval nonce, as printed in the pending-approval banner
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--always` — Allow this kind of operation for the rest of the session
|
||||
* `--deny` — Refuse the request
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit cancel`
|
||||
|
||||
Cancel the in-flight prompt for a cockpit session
|
||||
|
||||
**Usage:** `aoe cockpit cancel <SESSION>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit tail`
|
||||
|
||||
Stream the cockpit broadcast for a session to stdout as JSON lines (one frame per line). Press Ctrl-C to stop
|
||||
|
||||
**Usage:** `aoe cockpit tail [OPTIONS] <SESSION>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--since <SINCE>` — Start at this seq (default 0 = full replay then live)
|
||||
|
||||
Default value: `0`
|
||||
|
||||
|
||||
|
||||
## `aoe cockpit attach`
|
||||
|
||||
Open the TUI cockpit view directly for a known session id. Combine with `AOE_DAEMON_URL` (+ `AOE_DAEMON_TOKEN`) to attach across machines without going through the home session list
|
||||
|
||||
**Usage:** `aoe cockpit attach <SESSION>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SESSION>` — Cockpit session id
|
||||
|
||||
|
||||
|
||||
## `aoe uninstall`
|
||||
|
||||
Uninstall Agent of Empires
|
||||
|
||||
+90
-8
@@ -211,13 +211,20 @@ explicit operator action.
|
||||
|
||||
## TUI vs web dashboard
|
||||
|
||||
Cockpit is a **web-dashboard surface**. The TUI does not render the
|
||||
structured cockpit view today.
|
||||
Cockpit renders natively in the TUI alongside the web dashboard.
|
||||
Both consume the same `aoe serve` daemon over the same HTTP/WS
|
||||
surface, so the conversation log, pending approvals, and worker
|
||||
state are always in sync.
|
||||
|
||||
- **Sessions started in cockpit mode** appear in the TUI session list
|
||||
with a `[web]` badge. Pressing Enter opens an info dialog telling
|
||||
the user to switch to the dashboard; it does *not* attach to a tmux
|
||||
pane (cockpit sessions don't have one).
|
||||
with a `[cockpit]` badge. Pressing Enter opens the native cockpit
|
||||
view, which requires an `aoe serve` daemon to be already running.
|
||||
If one isn't, the view renders an actionable error pointing at
|
||||
`aoe serve --daemon` (localhost), `aoe serve --daemon --remote`
|
||||
(Tailscale/Cloudflare), or `AOE_DAEMON_URL` (attach to a remote
|
||||
daemon you already have running). The TUI intentionally does not
|
||||
start a daemon on your behalf, so you keep the choice between
|
||||
localhost, tunnel, and named tunnel explicit.
|
||||
- **Sessions started in tmux mode** work in both surfaces as before.
|
||||
The TUI attaches to the pane; the dashboard renders the pane via
|
||||
xterm.js.
|
||||
@@ -230,9 +237,84 @@ structured cockpit view today.
|
||||
as Idle/Active in the TUI session list, since cockpit health is
|
||||
observed via the ACP event stream rather than tmux pane probing.
|
||||
|
||||
A future release will either render a read-only cockpit transcript
|
||||
inside the TUI, or grow a richer "open this in the dashboard"
|
||||
affordance. Both are tracked as deferred work below.
|
||||
### TUI cockpit view keybinds
|
||||
|
||||
The TUI cockpit view has three focusable regions: composer (where
|
||||
you type prompts), transcript (the activity feed), and approval
|
||||
cards (one per pending tool authorization). Tab cycles focus; the
|
||||
status banner at the bottom of the screen shows the current focus.
|
||||
|
||||
| Focus | Key | Action |
|
||||
| ----------- | --------------- | ----------------------------------------------------- |
|
||||
| Composer | `Enter` | Send the buffered text as a prompt |
|
||||
| Composer | `Shift+Enter` | Insert a newline (multi-line prompts) |
|
||||
| Composer | `Esc` | Return focus to the transcript |
|
||||
| Transcript | `j` / `↓` | Scroll down one line |
|
||||
| Transcript | `k` / `↑` | Scroll up one line |
|
||||
| Transcript | `PgDn` / `PgUp` | Scroll ten lines |
|
||||
| Transcript | `g` / `G` | Jump to top / bottom |
|
||||
| Transcript | `i` | Focus the composer |
|
||||
| Transcript | `Tab` | Cycle to the approval card (if any pending) |
|
||||
| Transcript | `o` | Open this session in the web dashboard |
|
||||
| Transcript | `Esc` | Close the cockpit view and return to the session list |
|
||||
| Approval | `a` | Allow once |
|
||||
| Approval | `Shift+A` | Allow always (session-scoped allow-list entry) |
|
||||
| Approval | `d` | Deny |
|
||||
| Approval | `Esc` | Return focus to the transcript |
|
||||
| Any | `Ctrl+C` | Cancel the in-flight prompt |
|
||||
| Any | `Ctrl+O` | Open the session in the web dashboard |
|
||||
|
||||
**Focus isolation.** Approval keys (`a`/`Shift+A`/`d`) only resolve
|
||||
when the approval card itself has focus. Typing "always allow" into
|
||||
the composer will never silently approve a pending tool; the
|
||||
composer captures every keystroke, including those letters.
|
||||
|
||||
### Cross-machine attach
|
||||
|
||||
Set `AOE_DAEMON_URL` (and optionally `AOE_DAEMON_TOKEN`) to point at
|
||||
a remote `aoe serve` daemon, then either:
|
||||
|
||||
```sh
|
||||
# Browse the remote daemon's cockpit sessions and pick one.
|
||||
AOE_DAEMON_URL=https://aoe.example.com AOE_DAEMON_TOKEN=… aoe
|
||||
|
||||
# Or jump straight into a known session id.
|
||||
aoe cockpit attach <session_id> --daemon-url https://aoe.example.com
|
||||
```
|
||||
|
||||
When `AOE_DAEMON_URL` is set, the TUI swaps the local home view for
|
||||
a remote-cockpit picker. Local-only operations (tmux attach,
|
||||
`aoe stop`, file edit) aren't available against a remote; for
|
||||
those, use the web dashboard or SSH into the host machine.
|
||||
|
||||
The env override also retargets `aoe serve --status` and the
|
||||
`aoe cockpit *` verbs: with `AOE_DAEMON_URL` set, `--status` pings
|
||||
the remote endpoint and reports its reachability instead of inspecting
|
||||
the local `serve.pid` file. Unset the variable (or run `env -u
|
||||
AOE_DAEMON_URL aoe serve --status`) to fall back to local introspection.
|
||||
|
||||
### Headless CLI verbs
|
||||
|
||||
For scripting and quick checks, every cockpit operation has a
|
||||
matching `aoe cockpit <verb>` that talks to the same daemon:
|
||||
|
||||
| Verb | What it does |
|
||||
| --------------------------------- | ----------------------------------------------------------- |
|
||||
| `aoe cockpit history <id>` | Dump the persisted transcript |
|
||||
| `aoe cockpit status <id>` | Print highest/lowest seq and the daemon source |
|
||||
| `aoe cockpit prompt <id> <text>` | Send a prompt (`-` reads from stdin) |
|
||||
| `aoe cockpit approve <id> <nonce> [--always\|--deny]` | Resolve a pending approval |
|
||||
| `aoe cockpit cancel <id>` | Cancel the in-flight prompt |
|
||||
| `aoe cockpit tail <id>` | Stream broadcast frames to stdout as JSON lines |
|
||||
| `aoe cockpit attach <id>` | Open the TUI cockpit view directly for this session id |
|
||||
|
||||
Every verb (including `attach`) requires an `aoe serve` daemon to be
|
||||
already running, and exits with an actionable hint if none is found.
|
||||
Start one with `aoe serve --daemon` (localhost) or
|
||||
`aoe serve --daemon --remote` (Tailscale/Cloudflare), or set
|
||||
`AOE_DAEMON_URL` to attach to a remote daemon. The CLI deliberately
|
||||
does not spawn a daemon on your behalf so the localhost-vs-tunnel
|
||||
choice stays explicit.
|
||||
|
||||
## Tool compatibility
|
||||
|
||||
|
||||
@@ -67,6 +67,68 @@ pub enum CockpitCommands {
|
||||
/// Session id whose worker to restart.
|
||||
session: String,
|
||||
},
|
||||
/// Print the persisted transcript for a cockpit session.
|
||||
History {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
/// Skip events at or below this seq.
|
||||
#[arg(long, default_value = "0")]
|
||||
since: u64,
|
||||
/// Emit raw frames as JSON (one frame per line).
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Print live status for a cockpit session: highest/lowest seq, and
|
||||
/// whether the on-disk retention window has truncated history.
|
||||
Status {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
/// Emit machine-readable JSON instead of a human report.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Send a prompt to a cockpit session's agent.
|
||||
Prompt {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
/// Prompt text. Pass `-` to read from stdin.
|
||||
text: String,
|
||||
},
|
||||
/// Resolve a pending approval (default: allow). Use --always for a
|
||||
/// session-scoped allow-list entry, --deny to refuse the request.
|
||||
Approve {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
/// Approval nonce, as printed in the pending-approval banner.
|
||||
nonce: String,
|
||||
/// Allow this kind of operation for the rest of the session.
|
||||
#[arg(long, conflicts_with = "deny")]
|
||||
always: bool,
|
||||
/// Refuse the request.
|
||||
#[arg(long)]
|
||||
deny: bool,
|
||||
},
|
||||
/// Cancel the in-flight prompt for a cockpit session.
|
||||
Cancel {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
},
|
||||
/// Stream the cockpit broadcast for a session to stdout as JSON
|
||||
/// lines (one frame per line). Press Ctrl-C to stop.
|
||||
Tail {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
/// Start at this seq (default 0 = full replay then live).
|
||||
#[arg(long, default_value = "0")]
|
||||
since: u64,
|
||||
},
|
||||
/// Open the TUI cockpit view directly for a known session id.
|
||||
/// Combine with `AOE_DAEMON_URL` (+ `AOE_DAEMON_TOKEN`) to attach
|
||||
/// across machines without going through the home session list.
|
||||
Attach {
|
||||
/// Cockpit session id.
|
||||
session: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run(command: CockpitCommands) -> Result<()> {
|
||||
@@ -82,6 +144,22 @@ pub async fn run(command: CockpitCommands) -> Result<()> {
|
||||
CockpitCommands::Kill { session } => kill_now(&session),
|
||||
CockpitCommands::Logs { session, follow } => logs(session, follow),
|
||||
CockpitCommands::Restart { session } => restart(&session),
|
||||
CockpitCommands::History {
|
||||
session,
|
||||
since,
|
||||
json,
|
||||
} => history(&session, since, json).await,
|
||||
CockpitCommands::Status { session, json } => status(&session, json).await,
|
||||
CockpitCommands::Prompt { session, text } => prompt(&session, &text).await,
|
||||
CockpitCommands::Approve {
|
||||
session,
|
||||
nonce,
|
||||
always,
|
||||
deny,
|
||||
} => approve(&session, &nonce, always, deny).await,
|
||||
CockpitCommands::Cancel { session } => cancel(&session).await,
|
||||
CockpitCommands::Tail { session, since } => tail(&session, since).await,
|
||||
CockpitCommands::Attach { session } => attach(&session).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +661,192 @@ fn truncate(s: &str, n: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daemon-backed cockpit verbs ─────────────────────────────────────
|
||||
//
|
||||
// These talk to a running `aoe serve` daemon via the cockpit HTTP / WS
|
||||
// client. Mutating verbs (`prompt`, `approve`, `cancel`) auto-spawn a
|
||||
// loopback daemon when none is running so a user who only ever uses
|
||||
// the CLI doesn't have to remember to start `aoe serve` first. Read
|
||||
// verbs (`history`, `status`, `tail`) auto-spawn too because the
|
||||
// daemon is the only path to the disk-backed event store; there's no
|
||||
// useful read against "no daemon".
|
||||
|
||||
use crate::cockpit::client::{require_daemon, HttpClient, HttpError, WsMessage};
|
||||
use crate::cockpit::protocol::ApprovalDecisionWire;
|
||||
|
||||
async fn history(session: &str, since: u64, json: bool) -> Result<()> {
|
||||
let endpoint = require_daemon().await?;
|
||||
let client = HttpClient::new(endpoint)?;
|
||||
let resp = client.replay(session, since).await.map_err(map_http)?;
|
||||
if resp.lost {
|
||||
eprintln!(
|
||||
"warning: retention window evicted events before seq {}; transcript is partial.",
|
||||
since
|
||||
);
|
||||
}
|
||||
if json {
|
||||
for frame in &resp.frames {
|
||||
println!("{}", serde_json::to_string(&frame)?);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if resp.frames.is_empty() {
|
||||
println!(
|
||||
"(no events; highest_seq={}, lowest_seq={})",
|
||||
resp.highest_seq,
|
||||
resp.lowest_seq
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
for frame in &resp.frames {
|
||||
println!("seq {:>6} {}", frame.seq, event_kind(&frame.event));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(session: &str, json: bool) -> Result<()> {
|
||||
let endpoint = require_daemon().await?;
|
||||
let client = HttpClient::new(endpoint.clone())?;
|
||||
// since=highest_seq returns an empty frames vec but keeps the
|
||||
// highest/lowest/lost summary intact. Cheaper than full replay.
|
||||
let probe = client.replay(session, u64::MAX).await.map_err(map_http)?;
|
||||
if json {
|
||||
let blob = serde_json::json!({
|
||||
"session_id": session,
|
||||
"highest_seq": probe.highest_seq,
|
||||
"lowest_seq": probe.lowest_seq,
|
||||
"lost": probe.lost,
|
||||
"daemon_url": endpoint.base_url,
|
||||
"daemon_source": format!("{:?}", endpoint.source),
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&blob)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!("Cockpit session: {session}");
|
||||
println!(
|
||||
" daemon : {} ({:?})",
|
||||
endpoint.base_url, endpoint.source
|
||||
);
|
||||
println!(" highest_seq : {}", probe.highest_seq);
|
||||
println!(
|
||||
" lowest_seq : {}",
|
||||
probe
|
||||
.lowest_seq
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
if probe.highest_seq == 0 {
|
||||
println!(" state : no events recorded yet (worker may be idle or not yet spawned)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prompt(session: &str, text: &str) -> Result<()> {
|
||||
let body = read_text_arg(text)?;
|
||||
let endpoint = require_daemon().await?;
|
||||
let client = HttpClient::new(endpoint)?;
|
||||
client.prompt(session, &body).await.map_err(map_http)?;
|
||||
println!("prompt accepted ({} bytes)", body.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn approve(session: &str, nonce: &str, always: bool, deny: bool) -> Result<()> {
|
||||
let decision = match (always, deny) {
|
||||
(_, true) => ApprovalDecisionWire::Deny,
|
||||
(true, false) => ApprovalDecisionWire::AllowAlways,
|
||||
(false, false) => ApprovalDecisionWire::Allow,
|
||||
};
|
||||
let endpoint = require_daemon().await?;
|
||||
let client = HttpClient::new(endpoint)?;
|
||||
client
|
||||
.resolve_approval(session, nonce, decision)
|
||||
.await
|
||||
.map_err(map_http)?;
|
||||
println!("approval {nonce} -> {decision:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel(session: &str) -> Result<()> {
|
||||
let endpoint = require_daemon().await?;
|
||||
let client = HttpClient::new(endpoint)?;
|
||||
client.cancel(session).await.map_err(map_http)?;
|
||||
println!("cancel sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn attach(session: &str) -> Result<()> {
|
||||
crate::tui::cockpit_view::run_standalone(session).await
|
||||
}
|
||||
|
||||
async fn tail(session: &str, since: u64) -> Result<()> {
|
||||
let endpoint = require_daemon().await?;
|
||||
let mut handle = crate::cockpit::client::ws_connect(&endpoint, session, since).await?;
|
||||
while let Some(msg) = handle.recv().await {
|
||||
match msg {
|
||||
Ok(WsMessage::Frame(frame)) => {
|
||||
let line = serde_json::to_string(&*frame)?;
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(WsMessage::Lagged) => {
|
||||
eprintln!("warning: ring buffer lagged; some events lost. Refetch with `aoe cockpit history <session>`.");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ws error: {e}");
|
||||
anyhow::bail!("ws disconnected: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_text_arg(text: &str) -> Result<String> {
|
||||
if text == "-" {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
Ok(buf.trim_end_matches('\n').to_string())
|
||||
} else {
|
||||
Ok(text.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_http(e: HttpError) -> anyhow::Error {
|
||||
anyhow::Error::new(e)
|
||||
}
|
||||
|
||||
fn event_kind(event: &crate::cockpit::Event) -> &'static str {
|
||||
use crate::cockpit::Event;
|
||||
match event {
|
||||
Event::PlanUpdated { .. } => "plan_updated",
|
||||
Event::TodoListUpdated { .. } => "todo_list_updated",
|
||||
Event::ToolCallStarted { .. } => "tool_call_started",
|
||||
Event::ToolCallCompleted { .. } => "tool_call_completed",
|
||||
Event::ToolCallContent { .. } => "tool_call_content",
|
||||
Event::ToolCallUpdated { .. } => "tool_call_updated",
|
||||
Event::ApprovalRequested { .. } => "approval_requested",
|
||||
Event::ApprovalResolved { .. } => "approval_resolved",
|
||||
Event::DiffEmitted { .. } => "diff_emitted",
|
||||
Event::ThinkingStarted => "thinking_started",
|
||||
Event::ThinkingEnded => "thinking_ended",
|
||||
Event::RateLimit { .. } => "rate_limit",
|
||||
Event::UsageUpdated { .. } => "usage_updated",
|
||||
Event::ModeChanged { .. } => "mode_changed",
|
||||
Event::ModesAvailable { .. } => "modes_available",
|
||||
Event::CurrentModeChanged { .. } => "current_mode_changed",
|
||||
Event::AvailableCommandsUpdated { .. } => "available_commands_updated",
|
||||
Event::RawAgentUpdate { .. } => "raw_agent_update",
|
||||
Event::AgentMessageChunk { .. } => "agent_message_chunk",
|
||||
Event::Stopped { .. } => "stopped",
|
||||
Event::AgentStartupError { .. } => "agent_startup_error",
|
||||
Event::UserPromptSent { .. } => "user_prompt_sent",
|
||||
Event::AcpSessionAssigned { .. } => "acp_session_assigned",
|
||||
Event::SessionContextReset { .. } => "session_context_reset",
|
||||
Event::WakeupScheduled { .. } => "wakeup_scheduled",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -48,6 +48,14 @@ pub struct Cli {
|
||||
#[arg(short = 'p', long, global = true, env = "AGENT_OF_EMPIRES_PROFILE")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Attach to a remote cockpit daemon instead of using the local
|
||||
/// session list. Equivalent to setting `AOE_DAEMON_URL`; pair with
|
||||
/// `AOE_DAEMON_TOKEN` for the bearer token. Only meaningful at the
|
||||
/// no-subcommand `aoe` invocation (the TUI dashboard); ignored
|
||||
/// otherwise.
|
||||
#[arg(long, global = true, env = "AOE_DAEMON_URL")]
|
||||
pub daemon_url: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
+92
-2
@@ -53,6 +53,26 @@ pub struct ServeArgs {
|
||||
#[arg(long)]
|
||||
pub stop: bool,
|
||||
|
||||
/// Print the running daemon's PID, mode, URLs, and log path. Exits
|
||||
/// non-zero when no daemon is running. Useful for shell scripts
|
||||
/// that want to know whether a daemon is up without parsing `ps`.
|
||||
///
|
||||
/// `--status` is read-only and incompatible with every flag that
|
||||
/// would change daemon state (`--stop`, `--daemon`, `--remote`) or
|
||||
/// the bind config of a fresh daemon (`--no-auth`, `--read-only`,
|
||||
/// `--passphrase`, `--port`, `--tunnel-name`, `--no-tailscale`,
|
||||
/// `--tunnel-url`, `--open`). Clap reports the misuse instead of
|
||||
/// silently ignoring the extras.
|
||||
#[arg(
|
||||
long,
|
||||
conflicts_with_all = [
|
||||
"stop", "daemon", "remote",
|
||||
"no_auth", "read_only", "passphrase", "port",
|
||||
"tunnel_name", "no_tailscale", "tunnel_url", "open",
|
||||
],
|
||||
)]
|
||||
pub status: bool,
|
||||
|
||||
/// Require a passphrase for login (second-factor auth).
|
||||
/// Can also be set via AOE_SERVE_PASSPHRASE environment variable.
|
||||
#[arg(long, env = "AOE_SERVE_PASSPHRASE")]
|
||||
@@ -262,6 +282,10 @@ pub async fn run(profile: &str, args: ServeArgs) -> Result<()> {
|
||||
return stop_daemon().await;
|
||||
}
|
||||
|
||||
if args.status {
|
||||
return print_status().await;
|
||||
}
|
||||
|
||||
// Refuse to start a second instance (daemon or foreground) while another
|
||||
// aoe serve is already running. Without this gate, a foreground
|
||||
// `aoe serve` would overwrite the existing daemon's PID file in the
|
||||
@@ -275,8 +299,10 @@ pub async fn run(profile: &str, args: ServeArgs) -> Result<()> {
|
||||
if let Some(existing) = daemon_pid() {
|
||||
if existing != std::process::id() {
|
||||
bail!(
|
||||
"A serve daemon is already running (PID {}). \
|
||||
Stop it first with `aoe serve --stop`.",
|
||||
"aoe serve daemon already running (PID {}).\n\n \
|
||||
Status: aoe serve --status\n \
|
||||
Open UI: aoe url\n \
|
||||
Stop: aoe serve --stop",
|
||||
existing
|
||||
);
|
||||
}
|
||||
@@ -606,6 +632,70 @@ async fn stop_daemon() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print the running daemon's PID, mode, URLs, and log path. Exits
|
||||
/// non-zero (via `bail!`) when no daemon is running so shell scripts
|
||||
/// can branch on it (`aoe serve --status && …`).
|
||||
async fn print_status() -> Result<()> {
|
||||
// `AOE_DAEMON_URL` retargets every `aoe` invocation at a remote
|
||||
// daemon (see docs/cockpit.md). `--status` follows the same rule:
|
||||
// when the env override is set, report the remote endpoint's
|
||||
// health instead of the local PID file.
|
||||
if let Some(endpoint) = crate::cockpit::client::discovery::discover_env() {
|
||||
let client = crate::cockpit::client::HttpClient::new(endpoint.clone())
|
||||
.map_err(|e| anyhow::anyhow!("http client init failed: {e}"))?;
|
||||
match client.health_check().await {
|
||||
Ok(()) => {
|
||||
println!("Daemon: reachable (remote via AOE_DAEMON_URL)");
|
||||
println!("URL: {}", endpoint.base_url);
|
||||
println!(
|
||||
"Token: {}",
|
||||
if endpoint.token.is_some() {
|
||||
"set"
|
||||
} else {
|
||||
"unset"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => bail!(
|
||||
"AOE_DAEMON_URL is set but the daemon at {} is unreachable ({e}); \
|
||||
check the address or unset to use a local daemon",
|
||||
endpoint.base_url
|
||||
),
|
||||
}
|
||||
} else {
|
||||
print_local_status()
|
||||
}
|
||||
}
|
||||
|
||||
fn print_local_status() -> Result<()> {
|
||||
let Some(pid) = daemon_pid() else {
|
||||
bail!("Daemon: not running\nStart one with: aoe serve --daemon");
|
||||
};
|
||||
|
||||
let mode = read_serve_mode_label().unwrap_or("unknown");
|
||||
let urls = read_serve_urls();
|
||||
let log_path = crate::session::get_app_dir()
|
||||
.ok()
|
||||
.map(|d| d.join("serve.log"));
|
||||
|
||||
println!("Daemon: running (PID {})", pid);
|
||||
println!("Mode: {}", mode);
|
||||
if let Some(primary) = urls.first() {
|
||||
println!("URL: {}", primary.url);
|
||||
for u in urls.iter().skip(1) {
|
||||
let label = u.label.as_deref().unwrap_or("alt");
|
||||
println!(" {} {}", label, u.url);
|
||||
}
|
||||
} else {
|
||||
println!("URL: (serve.url missing)");
|
||||
}
|
||||
if let Some(p) = log_path {
|
||||
println!("Log: {}", p.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Locate the cockpit daemon the caller should talk to.
|
||||
//!
|
||||
//! Rules:
|
||||
//!
|
||||
//! 1. If `AOE_DAEMON_URL` is set, point at it and health-check the
|
||||
//! endpoint. Never silently fall back to a local daemon: the whole
|
||||
//! point of the env override is to attach to a *specific* daemon.
|
||||
//! 2. If a live local daemon exists (`serve.pid` + reachable
|
||||
//! `serve.url`), use it.
|
||||
//! 3. Otherwise return [`ManagerError::NoDaemonRunning`] with an
|
||||
//! actionable hint. Auto-spawn is intentionally not provided:
|
||||
//! starting a loopback daemon by side-effect hides the choice
|
||||
//! between localhost, Tailscale, and Cloudflare from the user and
|
||||
//! leaves an `aoe serve` process behind that they did not ask for.
|
||||
//! The caller is expected to render the hint and bail.
|
||||
//!
|
||||
//! Build-namespace discipline (debug vs release) is enforced by
|
||||
//! `crate::session::get_app_dir`: discovery reads `serve.pid` /
|
||||
//! `serve.url` from the same app dir an `aoe serve` of the same build
|
||||
//! would have written, so a debug client never picks up a release
|
||||
//! daemon (or vice versa).
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use super::discovery::{discover, discover_env, DaemonEndpoint, DiscoveryError};
|
||||
use super::http::HttpError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManagerError {
|
||||
#[error(
|
||||
"AOE_DAEMON_URL is set but the daemon at that URL is unreachable; check the address or unset to use a local daemon"
|
||||
)]
|
||||
EnvOverrideUnreachable,
|
||||
#[error(
|
||||
"AOE_DAEMON_URL is set but the daemon rejected the bearer token; check AOE_DAEMON_TOKEN"
|
||||
)]
|
||||
EnvOverrideUnauthorized,
|
||||
/// No daemon was reachable and no env override was set. Carries
|
||||
/// the underlying discovery error so callers can distinguish "no
|
||||
/// `serve.pid` at all" from "stale PID" if they care; most
|
||||
/// callers just render the user-facing hint.
|
||||
#[error(
|
||||
"no cockpit daemon is running.\n\nStart one with one of:\n aoe serve --daemon (localhost only, recommended for solo dev)\n aoe serve --daemon --remote (Tailscale Funnel or Cloudflare quick tunnel)\n aoe serve --daemon --tunnel-name … (named Cloudflare Tunnel)\n\nOr attach to an existing remote daemon with:\n AOE_DAEMON_URL=<url> AOE_DAEMON_TOKEN=<token> aoe …"
|
||||
)]
|
||||
NoDaemonRunning(#[from] DiscoveryError),
|
||||
}
|
||||
|
||||
/// Locate the daemon the caller should talk to. Does *not* spawn one;
|
||||
/// returns [`ManagerError::NoDaemonRunning`] if neither the env
|
||||
/// override nor a live local daemon resolves, so the caller can
|
||||
/// surface the message and let the user decide how to start the
|
||||
/// server.
|
||||
pub async fn require_daemon() -> Result<DaemonEndpoint, ManagerError> {
|
||||
if discover_env().is_some() {
|
||||
// Resolve through `discover()` so the parsing/redaction
|
||||
// applied to local endpoints is also applied here, then
|
||||
// health-check before returning so callers don't bubble up
|
||||
// raw reqwest transport errors on every subsequent API call.
|
||||
let endpoint = discover().map_err(|_| ManagerError::EnvOverrideUnreachable)?;
|
||||
let client = super::HttpClient::new(endpoint.clone())
|
||||
.map_err(|_| ManagerError::EnvOverrideUnreachable)?;
|
||||
return match client.health_check().await {
|
||||
Ok(()) => Ok(endpoint),
|
||||
Err(HttpError::Unauthorized) => Err(ManagerError::EnvOverrideUnauthorized),
|
||||
Err(_) => Err(ManagerError::EnvOverrideUnreachable),
|
||||
};
|
||||
}
|
||||
discover().map_err(ManagerError::NoDaemonRunning)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Locate a cockpit daemon (`aoe serve`) the client should talk to.
|
||||
//!
|
||||
//! Resolution order:
|
||||
//!
|
||||
//! 1. `AOE_DAEMON_URL` env var (paired with `AOE_DAEMON_TOKEN`). Env
|
||||
//! is preferred over CLI flags so the token never leaks via `ps`.
|
||||
//! 2. Local daemon: `<app_dir>/serve.url` + a live `<app_dir>/serve.pid`.
|
||||
//! The loopback alternate is preferred over the primary line so
|
||||
//! clients on the same box don't round-trip through a tunnel.
|
||||
//!
|
||||
//! Returns `Err(NoLocalDaemon)` when neither resolves.
|
||||
//! [`super::daemon_manager::require_daemon`] wraps this with a
|
||||
//! health-check on the env override and a friendlier no-daemon error
|
||||
//! variant whose message tells the user how to start one.
|
||||
|
||||
use std::env;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::cli::serve::{daemon_pid, read_serve_urls};
|
||||
|
||||
/// A located daemon endpoint. `base_url` carries no query string so it
|
||||
/// is safe to log; the auth token (if any) travels separately and is
|
||||
/// applied as an `Authorization: Bearer` header by [`super::http`] and
|
||||
/// as a `?token=` query for the WebSocket handshake by [`super::ws`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DaemonEndpoint {
|
||||
/// Bare base URL (`http://127.0.0.1:8080`). No trailing slash, no
|
||||
/// query string.
|
||||
pub base_url: String,
|
||||
/// Bearer token. `None` when the daemon was started with
|
||||
/// `--no-auth`, or when `AOE_DAEMON_URL` is set without
|
||||
/// `AOE_DAEMON_TOKEN`.
|
||||
pub token: Option<String>,
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
impl DaemonEndpoint {
|
||||
/// Same base URL, scheme rewritten to `ws://` / `wss://` so a
|
||||
/// caller can hand it to `tokio_tungstenite::connect_async`.
|
||||
pub fn ws_base_url(&self) -> String {
|
||||
http_to_ws(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// `AOE_DAEMON_URL` env var.
|
||||
Env,
|
||||
/// Read from `<app_dir>/serve.url`.
|
||||
LocalDaemon,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DiscoveryError {
|
||||
#[error(
|
||||
"no local cockpit daemon is running; start one with `aoe serve` or set AOE_DAEMON_URL"
|
||||
)]
|
||||
NoLocalDaemon,
|
||||
#[error("serve.url is empty or malformed; restart `aoe serve` to refresh it")]
|
||||
Malformed,
|
||||
}
|
||||
|
||||
/// Locate a daemon endpoint via env override or local serve files.
|
||||
pub fn discover() -> Result<DaemonEndpoint, DiscoveryError> {
|
||||
if let Some(endpoint) = discover_env() {
|
||||
return Ok(endpoint);
|
||||
}
|
||||
discover_local()
|
||||
}
|
||||
|
||||
/// `AOE_DAEMON_URL` (+ optional `AOE_DAEMON_TOKEN`). Returns `None`
|
||||
/// when the env var is unset or empty.
|
||||
pub fn discover_env() -> Option<DaemonEndpoint> {
|
||||
let url = env::var("AOE_DAEMON_URL").ok()?;
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let token = env::var("AOE_DAEMON_TOKEN")
|
||||
.ok()
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty());
|
||||
Some(DaemonEndpoint {
|
||||
base_url: trim_query(url).trim_end_matches('/').to_string(),
|
||||
token,
|
||||
source: Source::Env,
|
||||
})
|
||||
}
|
||||
|
||||
/// Local serve daemon discovery. Returns `Err(NoLocalDaemon)` when no
|
||||
/// live daemon is found.
|
||||
pub fn discover_local() -> Result<DaemonEndpoint, DiscoveryError> {
|
||||
if daemon_pid().is_none() {
|
||||
return Err(DiscoveryError::NoLocalDaemon);
|
||||
}
|
||||
let urls = read_serve_urls();
|
||||
if urls.is_empty() {
|
||||
return Err(DiscoveryError::NoLocalDaemon);
|
||||
}
|
||||
let pick = urls
|
||||
.iter()
|
||||
.find(|u| is_loopback(&u.url))
|
||||
.or_else(|| urls.first())
|
||||
.ok_or(DiscoveryError::Malformed)?;
|
||||
let token = extract_token(&pick.url).map(str::to_string);
|
||||
let base_url = trim_query(&pick.url).trim_end_matches('/').to_string();
|
||||
if base_url.is_empty() {
|
||||
return Err(DiscoveryError::Malformed);
|
||||
}
|
||||
Ok(DaemonEndpoint {
|
||||
base_url,
|
||||
token,
|
||||
source: Source::LocalDaemon,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_loopback(url: &str) -> bool {
|
||||
let host = url
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(url)
|
||||
.split(['/', '?'])
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
host.starts_with("127.0.0.1") || host.starts_with("localhost") || host.starts_with("[::1]")
|
||||
}
|
||||
|
||||
fn trim_query(url: &str) -> &str {
|
||||
url.split_once('?').map(|(u, _)| u).unwrap_or(url)
|
||||
}
|
||||
|
||||
fn extract_token(url: &str) -> Option<&str> {
|
||||
let query = url.split_once('?').map(|(_, q)| q)?;
|
||||
for pair in query.split('&') {
|
||||
if let Some(rest) = pair.strip_prefix("token=") {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn http_to_ws(http_url: &str) -> String {
|
||||
if let Some(rest) = http_url.strip_prefix("https://") {
|
||||
return format!("wss://{rest}");
|
||||
}
|
||||
if let Some(rest) = http_url.strip_prefix("http://") {
|
||||
return format!("ws://{rest}");
|
||||
}
|
||||
http_url.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_token_simple() {
|
||||
assert_eq!(
|
||||
extract_token("http://localhost:8080/?token=abc123"),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_token_none_when_missing() {
|
||||
assert_eq!(extract_token("http://localhost:8080/"), None);
|
||||
assert_eq!(extract_token("http://localhost:8080/?foo=bar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_token_none_when_empty() {
|
||||
assert_eq!(extract_token("http://localhost:8080/?token="), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_token_multi_param() {
|
||||
assert_eq!(
|
||||
extract_token("http://localhost:8080/?foo=bar&token=zzz"),
|
||||
Some("zzz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_query_strips_query_string() {
|
||||
assert_eq!(
|
||||
trim_query("http://localhost:8080/?token=abc"),
|
||||
"http://localhost:8080/"
|
||||
);
|
||||
assert_eq!(
|
||||
trim_query("http://localhost:8080/"),
|
||||
"http://localhost:8080/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_to_ws_handles_both_schemes() {
|
||||
assert_eq!(http_to_ws("http://127.0.0.1:8080"), "ws://127.0.0.1:8080");
|
||||
assert_eq!(
|
||||
http_to_ws("https://remote.example.com"),
|
||||
"wss://remote.example.com"
|
||||
);
|
||||
assert_eq!(http_to_ws("ws://already"), "ws://already");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_loopback_matches_localhost_variants() {
|
||||
assert!(is_loopback("http://127.0.0.1:8080"));
|
||||
assert!(is_loopback("http://localhost:8081/"));
|
||||
assert!(is_loopback("http://[::1]:8080"));
|
||||
assert!(!is_loopback("https://example.com"));
|
||||
assert!(!is_loopback("http://192.168.1.50:8080"));
|
||||
}
|
||||
|
||||
// Env-touching tests must run serially; cargo test runs in
|
||||
// parallel by default and set_var races with the unset cases.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn discover_env_returns_none_when_unset() {
|
||||
unsafe {
|
||||
std::env::remove_var("AOE_DAEMON_URL");
|
||||
std::env::remove_var("AOE_DAEMON_TOKEN");
|
||||
}
|
||||
assert!(discover_env().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn discover_env_parses_url_and_token() {
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"AOE_DAEMON_URL",
|
||||
"https://remote.example.com:9000/?token=zzz",
|
||||
);
|
||||
std::env::set_var("AOE_DAEMON_TOKEN", "real-token");
|
||||
}
|
||||
let endpoint = discover_env().expect("env override should resolve");
|
||||
// ENV override strips the query string defensively even though
|
||||
// tokens should travel via AOE_DAEMON_TOKEN, not the URL.
|
||||
assert_eq!(endpoint.base_url, "https://remote.example.com:9000");
|
||||
assert_eq!(endpoint.token.as_deref(), Some("real-token"));
|
||||
assert_eq!(endpoint.source, Source::Env);
|
||||
unsafe {
|
||||
std::env::remove_var("AOE_DAEMON_URL");
|
||||
std::env::remove_var("AOE_DAEMON_TOKEN");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! HTTP client for the cockpit daemon.
|
||||
//!
|
||||
//! One `HttpClient` per `DaemonEndpoint`; methods map 1:1 to the
|
||||
//! per-session cockpit REST surface (`/api/sessions/{id}/cockpit/*`).
|
||||
//! Auth: the endpoint's optional `token` is sent as
|
||||
//! `Authorization: Bearer <token>` on every request, never as a
|
||||
//! query string, so it doesn't leak via logs or `ps`.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::{header, StatusCode};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::discovery::DaemonEndpoint;
|
||||
use crate::cockpit::protocol::{
|
||||
ApprovalDecisionWire, ContextPrimerResponse, PromptRequest, ReplayResponse,
|
||||
ResolveApprovalRequest,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Cockpit daemon HTTP client. Cheap to clone; the underlying
|
||||
/// `reqwest::Client` is reference-counted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpClient {
|
||||
http: reqwest::Client,
|
||||
endpoint: DaemonEndpoint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HttpError {
|
||||
#[error("transport error: {0}")]
|
||||
Transport(#[from] reqwest::Error),
|
||||
#[error("cockpit session {0} not found on the daemon")]
|
||||
SessionNotFound(String),
|
||||
#[error("daemon is read-only (started with --read-only); request refused")]
|
||||
ReadOnly,
|
||||
#[error("authentication failed; check AOE_DAEMON_TOKEN or restart `aoe serve`")]
|
||||
Unauthorized,
|
||||
#[error("daemon returned HTTP {status}: {body}")]
|
||||
Server { status: StatusCode, body: String },
|
||||
}
|
||||
|
||||
impl HttpClient {
|
||||
pub fn new(endpoint: DaemonEndpoint) -> Result<Self, HttpError> {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(DEFAULT_TIMEOUT)
|
||||
.user_agent(concat!("aoe-cockpit-client/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
Ok(Self { http, endpoint })
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &DaemonEndpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/{id}/cockpit/replay?since=N`.
|
||||
pub async fn replay(&self, session_id: &str, since: u64) -> Result<ReplayResponse, HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/sessions/{}/cockpit/replay?since={}",
|
||||
self.endpoint.base_url, session_id, since
|
||||
);
|
||||
let res = self.auth(self.http.get(&url)).send().await?;
|
||||
let res = check_status(res, session_id).await?;
|
||||
Ok(res.json::<ReplayResponse>().await?)
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/{id}/cockpit/context-primer?before_seq=N`.
|
||||
pub async fn context_primer(
|
||||
&self,
|
||||
session_id: &str,
|
||||
before_seq: u64,
|
||||
) -> Result<ContextPrimerResponse, HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/sessions/{}/cockpit/context-primer?before_seq={}",
|
||||
self.endpoint.base_url, session_id, before_seq
|
||||
);
|
||||
let res = self.auth(self.http.get(&url)).send().await?;
|
||||
let res = check_status(res, session_id).await?;
|
||||
Ok(res.json::<ContextPrimerResponse>().await?)
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/cockpit/prompt`.
|
||||
pub async fn prompt(&self, session_id: &str, text: &str) -> Result<(), HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/sessions/{}/cockpit/prompt",
|
||||
self.endpoint.base_url, session_id
|
||||
);
|
||||
let body = PromptRequest {
|
||||
text: text.to_string(),
|
||||
};
|
||||
let res = self.auth(self.http.post(&url)).json(&body).send().await?;
|
||||
check_status(res, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/cockpit/cancel`.
|
||||
pub async fn cancel(&self, session_id: &str) -> Result<(), HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/sessions/{}/cockpit/cancel",
|
||||
self.endpoint.base_url, session_id
|
||||
);
|
||||
let res = self.auth(self.http.post(&url)).send().await?;
|
||||
check_status(res, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/cockpit/approvals/{nonce}`.
|
||||
pub async fn resolve_approval(
|
||||
&self,
|
||||
session_id: &str,
|
||||
nonce: &str,
|
||||
decision: ApprovalDecisionWire,
|
||||
) -> Result<(), HttpError> {
|
||||
let url = format!(
|
||||
"{}/api/sessions/{}/cockpit/approvals/{}",
|
||||
self.endpoint.base_url, session_id, nonce
|
||||
);
|
||||
let body = ResolveApprovalRequest { decision };
|
||||
let res = self.auth(self.http.post(&url)).json(&body).send().await?;
|
||||
check_status(res, session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `GET /api/sessions`. Returns the daemon's session list as
|
||||
/// whatever shape the caller deserialises into. Used by the
|
||||
/// remote-cockpit picker so the bespoke `reqwest::Client` it used
|
||||
/// to keep can be retired in favour of the shared auth/header
|
||||
/// plumbing.
|
||||
pub async fn list_sessions<T: serde::de::DeserializeOwned>(&self) -> Result<Vec<T>, HttpError> {
|
||||
let url = format!("{}/api/sessions", self.endpoint.base_url);
|
||||
let res = self.auth(self.http.get(&url)).send().await?;
|
||||
let res = check_status(res, "<sessions>").await?;
|
||||
Ok(res.json::<Vec<T>>().await?)
|
||||
}
|
||||
|
||||
/// Lightweight reachability probe used by `require_daemon` (when
|
||||
/// `AOE_DAEMON_URL` is set, we fail loud before falling into raw
|
||||
/// reqwest transport errors) and `aoe serve --status` (renders
|
||||
/// remote daemon info instead of "Daemon: not running").
|
||||
///
|
||||
/// Hits `GET /api/sessions`, the cheapest authenticated endpoint
|
||||
/// in the surface; succeeds with 200 when the daemon is up *and*
|
||||
/// the token is valid, separates "host is down" (transport error)
|
||||
/// from "auth misconfigured" (401).
|
||||
pub async fn health_check(&self) -> Result<(), HttpError> {
|
||||
let url = format!("{}/api/sessions", self.endpoint.base_url);
|
||||
let res = self.auth(self.http.get(&url)).send().await?;
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(HttpError::Unauthorized),
|
||||
_ => Err(HttpError::Server { status, body }),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
match &self.endpoint.token {
|
||||
Some(token) => builder.header(header::AUTHORIZATION, format!("Bearer {token}")),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_status(
|
||||
res: reqwest::Response,
|
||||
session_id: &str,
|
||||
) -> Result<reqwest::Response, HttpError> {
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
return Ok(res);
|
||||
}
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(HttpError::Unauthorized),
|
||||
StatusCode::FORBIDDEN if body.contains("read-only") || body.contains("read_only") => {
|
||||
Err(HttpError::ReadOnly)
|
||||
}
|
||||
StatusCode::NOT_FOUND => Err(HttpError::SessionNotFound(session_id.to_string())),
|
||||
_ => Err(HttpError::Server { status, body }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cockpit::client::discovery::Source;
|
||||
|
||||
fn endpoint(base: &str, token: Option<&str>) -> DaemonEndpoint {
|
||||
DaemonEndpoint {
|
||||
base_url: base.to_string(),
|
||||
token: token.map(str::to_string),
|
||||
source: Source::Env,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_sets_bearer_when_token_present() {
|
||||
let client = HttpClient::new(endpoint("http://127.0.0.1:8080", Some("tok"))).unwrap();
|
||||
// Smoke-check by reading endpoint back; full header inspection
|
||||
// requires a live request and lives in the integration tests
|
||||
// alongside the axum mock.
|
||||
assert_eq!(client.endpoint().token.as_deref(), Some("tok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_skips_bearer_when_no_token() {
|
||||
let client = HttpClient::new(endpoint("http://127.0.0.1:8080", None)).unwrap();
|
||||
assert!(client.endpoint().token.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Cockpit daemon client.
|
||||
//!
|
||||
//! HTTP + WebSocket client for talking to an `aoe serve` daemon. Used
|
||||
//! by:
|
||||
//!
|
||||
//! - The `aoe cockpit *` CLI verbs (history, status, prompt, approve,
|
||||
//! cancel, tail, attach).
|
||||
//! - The TUI cockpit view (`src/tui/cockpit_view/`).
|
||||
//!
|
||||
//! All three layers share `DaemonEndpoint` discovery, the typed
|
||||
//! `HttpClient`, and the typed `WsHandle` so a change to the wire
|
||||
//! shape breaks every consumer at compile time, not at runtime.
|
||||
//!
|
||||
//! Discovery resolution order:
|
||||
//!
|
||||
//! 1. `AOE_DAEMON_URL` (+ optional `AOE_DAEMON_TOKEN`).
|
||||
//! 2. Local `<app_dir>/serve.url` paired with a live `serve.pid`.
|
||||
//!
|
||||
//! [`daemon_manager::require_daemon`] returns
|
||||
//! [`daemon_manager::ManagerError::NoDaemonRunning`] when neither
|
||||
//! resolves; callers render the contained hint and bail rather than
|
||||
//! starting a daemon by side-effect, so the user keeps the choice
|
||||
//! between localhost, Tailscale, and Cloudflare explicit.
|
||||
|
||||
pub mod daemon_manager;
|
||||
pub mod discovery;
|
||||
pub mod http;
|
||||
pub mod ws;
|
||||
|
||||
pub use daemon_manager::{require_daemon, ManagerError};
|
||||
pub use discovery::{discover, DaemonEndpoint, DiscoveryError, Source};
|
||||
pub use http::{HttpClient, HttpError};
|
||||
pub use ws::{connect as ws_connect, WsError, WsHandle, WsMessage};
|
||||
@@ -0,0 +1,301 @@
|
||||
//! WebSocket client for the cockpit broadcast stream.
|
||||
//!
|
||||
//! Subscribes to `/sessions/{id}/cockpit/ws?since=N` and yields a
|
||||
//! stream of decoded events. The daemon may push two shapes:
|
||||
//!
|
||||
//! - `{"kind":"frame", ...CockpitBroadcastFrame}`: the next replayed
|
||||
//! or live event.
|
||||
//! - `{"kind":"lagged"}`: the in-memory ring buffer evicted events
|
||||
//! the client hadn't acked yet. The consumer must drop its local
|
||||
//! state and rehydrate via [`super::http::HttpClient::replay`].
|
||||
//!
|
||||
//! Auth: the bearer token is sent as a `?token=<>` query string on the
|
||||
//! WebSocket URL. Most WS clients do not surface custom headers cleanly,
|
||||
//! and the daemon's auth middleware already accepts the query-param
|
||||
//! form (see `src/server/auth.rs`). The token is *not* logged anywhere
|
||||
//! the URL string is exposed (we log only the base URL).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use thiserror::Error;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::discovery::DaemonEndpoint;
|
||||
use crate::cockpit::protocol::CockpitBroadcastFrame;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WsError {
|
||||
#[error("websocket transport error: {0}")]
|
||||
Transport(#[from] tokio_tungstenite::tungstenite::Error),
|
||||
#[error("invalid websocket URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
#[error("websocket closed unexpectedly (code {0:?})")]
|
||||
UnexpectedClose(Option<CloseCode>),
|
||||
/// A daemon frame failed to deserialise. Surfaced to the caller so
|
||||
/// a toast like "ws: parse error" carries the real reason instead
|
||||
/// of a fabricated transport error.
|
||||
#[error("failed to parse websocket frame: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
/// One message off the cockpit WebSocket.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WsMessage {
|
||||
/// A normal cockpit event frame.
|
||||
Frame(Arc<CockpitBroadcastFrame>),
|
||||
/// Daemon's in-memory ring evicted events the client missed.
|
||||
/// Consumer should drop local reducer state and call
|
||||
/// `HttpClient::replay(since=last_seq)` to rehydrate.
|
||||
Lagged,
|
||||
}
|
||||
|
||||
/// Handle to a running WebSocket reader task. Drop or call
|
||||
/// [`Self::shutdown`] to close the connection.
|
||||
pub struct WsHandle {
|
||||
rx: mpsc::Receiver<Result<WsMessage, WsError>>,
|
||||
task: JoinHandle<()>,
|
||||
shutdown: Option<mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
/// Wait this long for the reader task to send its close frame and
|
||||
/// exit cleanly before falling back to `abort()`. Picked so a healthy
|
||||
/// loopback round-trip lands well inside the budget while a stuck
|
||||
/// task still doesn't block our caller's teardown.
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_millis(200);
|
||||
|
||||
impl WsHandle {
|
||||
pub async fn recv(&mut self) -> Option<Result<WsMessage, WsError>> {
|
||||
self.rx.recv().await
|
||||
}
|
||||
|
||||
/// Ask the reader task to send a Close frame and finish cleanly.
|
||||
/// Falls back to `abort()` if the task doesn't finish within
|
||||
/// `SHUTDOWN_GRACE` so a stuck or already-aborted task can't
|
||||
/// block teardown.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(tx) = self.shutdown.take() {
|
||||
let _ = tx.try_send(());
|
||||
}
|
||||
match tokio::time::timeout(SHUTDOWN_GRACE, &mut self.task).await {
|
||||
Ok(_) => {}
|
||||
Err(_) => self.task.abort(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the cockpit broadcast stream for `session_id` starting
|
||||
/// after `since` (use `0` for full replay). Returns a handle whose
|
||||
/// `recv()` yields decoded messages until the stream ends or errors.
|
||||
pub async fn connect(
|
||||
endpoint: &DaemonEndpoint,
|
||||
session_id: &str,
|
||||
since: u64,
|
||||
) -> Result<WsHandle, WsError> {
|
||||
let url = ws_url(endpoint, session_id, since);
|
||||
debug!(
|
||||
target: "cockpit.client.ws",
|
||||
// Log the path without the token query param.
|
||||
url = %sanitize_for_log(&url),
|
||||
"connecting to cockpit ws"
|
||||
);
|
||||
let request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| WsError::InvalidUrl(e.to_string()))?;
|
||||
let (stream, _) = connect_async(request).await?;
|
||||
let (frame_tx, frame_rx) = mpsc::channel(64);
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
|
||||
let task = tokio::spawn(reader_loop(stream, frame_tx, shutdown_rx));
|
||||
Ok(WsHandle {
|
||||
rx: frame_rx,
|
||||
task,
|
||||
shutdown: Some(shutdown_tx),
|
||||
})
|
||||
}
|
||||
|
||||
async fn reader_loop(
|
||||
mut stream: WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
tx: mpsc::Sender<Result<WsMessage, WsError>>,
|
||||
mut shutdown: mpsc::Receiver<()>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.recv() => {
|
||||
let _ = stream
|
||||
.send(Message::Close(Some(CloseFrame {
|
||||
code: CloseCode::Normal,
|
||||
reason: "client shutdown".into(),
|
||||
})))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
next = stream.next() => {
|
||||
match next {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let msg = parse_text(&text);
|
||||
if tx.send(msg).await.is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Binary(_))) => {
|
||||
// Daemon never sends binary; ignore defensively.
|
||||
}
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
let _ = stream.send(Message::Pong(payload)).await;
|
||||
}
|
||||
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Frame(_))) => {}
|
||||
Some(Ok(Message::Close(frame))) => {
|
||||
let code = frame.as_ref().map(|f| f.code);
|
||||
let _ = tx.send(Err(WsError::UnexpectedClose(code))).await;
|
||||
return;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let _ = tx.send(Err(WsError::Transport(e))).await;
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
let _ = tx.send(Err(WsError::UnexpectedClose(None))).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_text(raw: &str) -> Result<WsMessage, WsError> {
|
||||
// The daemon sends either a `CockpitBroadcastFrame` JSON object or
|
||||
// a `{ "kind": "lagged" }` sentinel. We try the sentinel first
|
||||
// (cheap discriminant probe) and fall back to a full frame parse.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct KindProbe<'a> {
|
||||
kind: Option<&'a str>,
|
||||
}
|
||||
if let Ok(probe) = serde_json::from_str::<KindProbe>(raw) {
|
||||
if probe.kind == Some("lagged") {
|
||||
return Ok(WsMessage::Lagged);
|
||||
}
|
||||
}
|
||||
let frame: CockpitBroadcastFrame = serde_json::from_str(raw).map_err(|e| {
|
||||
warn!(target: "cockpit.client.ws", error = %e, "ws frame parse failed");
|
||||
WsError::Parse(e.to_string())
|
||||
})?;
|
||||
Ok(WsMessage::Frame(Arc::new(frame)))
|
||||
}
|
||||
|
||||
fn ws_url(endpoint: &DaemonEndpoint, session_id: &str, since: u64) -> String {
|
||||
let base = endpoint.ws_base_url();
|
||||
let path = format!("/sessions/{session_id}/cockpit/ws");
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
if since > 0 {
|
||||
params.push(format!("since={since}"));
|
||||
}
|
||||
if let Some(token) = &endpoint.token {
|
||||
params.push(format!("token={token}"));
|
||||
}
|
||||
if params.is_empty() {
|
||||
format!("{base}{path}")
|
||||
} else {
|
||||
format!("{base}{path}?{}", params.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_for_log(url: &str) -> String {
|
||||
match url.split_once("token=") {
|
||||
Some((head, tail)) => {
|
||||
let rest = tail.split_once('&').map(|(_, r)| r).unwrap_or("");
|
||||
if rest.is_empty() {
|
||||
format!("{head}token=<redacted>")
|
||||
} else {
|
||||
format!("{head}token=<redacted>&{rest}")
|
||||
}
|
||||
}
|
||||
None => url.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cockpit::client::discovery::Source;
|
||||
use crate::cockpit::state::Event;
|
||||
|
||||
fn endpoint(base: &str, token: Option<&str>) -> DaemonEndpoint {
|
||||
DaemonEndpoint {
|
||||
base_url: base.to_string(),
|
||||
token: token.map(str::to_string),
|
||||
source: Source::Env,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_url_appends_since_and_token() {
|
||||
let e = endpoint("http://127.0.0.1:8080", Some("abc"));
|
||||
let url = ws_url(&e, "s-1", 42);
|
||||
assert_eq!(
|
||||
url,
|
||||
"ws://127.0.0.1:8080/sessions/s-1/cockpit/ws?since=42&token=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_url_omits_since_when_zero() {
|
||||
let e = endpoint("http://127.0.0.1:8080", None);
|
||||
assert_eq!(
|
||||
ws_url(&e, "s-1", 0),
|
||||
"ws://127.0.0.1:8080/sessions/s-1/cockpit/ws"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_url_uses_wss_for_https_endpoint() {
|
||||
let e = endpoint("https://remote.example.com", Some("t"));
|
||||
assert!(ws_url(&e, "s-1", 0).starts_with("wss://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_text_lagged_sentinel() {
|
||||
let m = parse_text(r#"{"kind":"lagged"}"#).unwrap();
|
||||
assert!(matches!(m, WsMessage::Lagged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_text_frame() {
|
||||
let raw = serde_json::to_string(&serde_json::json!({
|
||||
"session_id": "s-1",
|
||||
"seq": 7,
|
||||
"event": "ThinkingStarted",
|
||||
}))
|
||||
.unwrap();
|
||||
let m = parse_text(&raw).unwrap();
|
||||
match m {
|
||||
WsMessage::Frame(f) => {
|
||||
assert_eq!(f.session_id, "s-1");
|
||||
assert_eq!(f.seq, 7);
|
||||
assert!(matches!(*f.event, Event::ThinkingStarted));
|
||||
}
|
||||
WsMessage::Lagged => panic!("expected frame"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_for_log_redacts_token() {
|
||||
assert_eq!(
|
||||
sanitize_for_log("ws://127.0.0.1/path?since=1&token=secret"),
|
||||
"ws://127.0.0.1/path?since=1&token=<redacted>"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_for_log("ws://127.0.0.1/path?token=secret&since=1"),
|
||||
"ws://127.0.0.1/path?token=<redacted>&since=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -580,6 +580,36 @@ impl EventStore {
|
||||
max
|
||||
}
|
||||
|
||||
/// Return the lowest seq still stored for `session_id`, or `None`
|
||||
/// if the session has no events on disk (either never wrote any, or
|
||||
/// the retention cap has evicted them all). Used by `/cockpit/replay`
|
||||
/// to compute whether a client's `since` cursor falls below the
|
||||
/// pruned floor so the response can signal `lost = true`.
|
||||
pub fn lowest_seq(&self, session_id: &str) -> Option<u64> {
|
||||
let conn = match self.conn.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => p.into_inner(),
|
||||
};
|
||||
let min = match conn
|
||||
.query_row(
|
||||
"SELECT MIN(seq) FROM cockpit_events WHERE session_id = ?1",
|
||||
params![session_id],
|
||||
|row| row.get::<_, Option<i64>>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
Ok(Some(Some(m))) => Some(m as u64),
|
||||
_ => None,
|
||||
};
|
||||
trace!(
|
||||
target: "cockpit.event_store",
|
||||
session = %session_id,
|
||||
lowest_seq = ?min,
|
||||
"lowest_seq query"
|
||||
);
|
||||
min
|
||||
}
|
||||
|
||||
/// Return every session_id that has at least one event stored, with
|
||||
/// its highest seq. Used at startup to pre-seed `next_seqs` in one
|
||||
/// query rather than racing per-session lookups.
|
||||
@@ -766,6 +796,35 @@ mod tests {
|
||||
assert_eq!(store.highest_seq("s-1"), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_seq_none_on_empty() {
|
||||
let (_tmp, store) = open_store(1000);
|
||||
assert_eq!(store.lowest_seq("s-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_seq_reflects_oldest_remaining_seq() {
|
||||
let (_tmp, store) = open_store(1000);
|
||||
store.record("s-1", 5, &Event::ThinkingStarted).unwrap();
|
||||
store.record("s-1", 7, &Event::ThinkingEnded).unwrap();
|
||||
assert_eq!(store.lowest_seq("s-1"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowest_seq_climbs_with_retention_prune() {
|
||||
// After the retention prune evicts the early transcript seqs,
|
||||
// `lowest_seq` must reflect the new floor so callers can detect
|
||||
// a client `since` cursor that's fallen below it.
|
||||
let (_tmp, store) = open_store(3);
|
||||
for i in 1..=20 {
|
||||
store.record("s-1", i, &Event::ThinkingStarted).unwrap();
|
||||
}
|
||||
// Cap is 3 transcript events; with no snapshot rows, only seqs
|
||||
// 18, 19, 20 remain.
|
||||
let low = store.lowest_seq("s-1").expect("some events stored");
|
||||
assert!(low > 1, "lowest_seq did not advance after prune: {low}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_seq_is_idempotent() {
|
||||
let (_tmp, store) = open_store(1000);
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
pub mod acp_client;
|
||||
pub mod agent_registry;
|
||||
pub mod approvals;
|
||||
pub mod client;
|
||||
pub mod context_primer;
|
||||
pub mod event_store;
|
||||
pub mod fs_handler;
|
||||
pub mod node;
|
||||
pub mod permissions;
|
||||
pub mod protocol;
|
||||
pub mod runner;
|
||||
pub mod state;
|
||||
pub mod supervisor;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Wire-format types shared between the cockpit daemon (`aoe serve`)
|
||||
//! and its HTTP / WebSocket clients (web frontend, CLI cockpit verbs,
|
||||
//! and the TUI cockpit view).
|
||||
//!
|
||||
//! Anything sent over the wire lives here so server, client, and TUI
|
||||
//! cannot drift on the JSON shape: rename a field in one place and
|
||||
//! the build breaks everywhere it's consumed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::approvals::ApprovalDecision;
|
||||
use super::state::Event;
|
||||
|
||||
/// One frame on the per-AppState cockpit broadcast channel: the cockpit
|
||||
/// session id plus the typed cockpit Event. Subscribed WebSocket
|
||||
/// clients filter on the session id and serialise to JSON only at the
|
||||
/// WS write boundary; in-process consumers (status listener,
|
||||
/// acp_session_id listener) match on the typed enum directly so a
|
||||
/// rename of an `Event` variant breaks the build instead of silently
|
||||
/// breaking listener behaviour.
|
||||
///
|
||||
/// `Arc<Event>` so the broadcast clone-per-subscriber stays cheap even
|
||||
/// as the number of WS clients grows.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CockpitBroadcastFrame {
|
||||
pub session_id: String,
|
||||
pub seq: u64,
|
||||
pub event: Arc<Event>,
|
||||
}
|
||||
|
||||
impl Serialize for CockpitBroadcastFrame {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
// Custom impl so the wire format stays the same (untagged
|
||||
// event JSON) without forcing every consumer to round-trip
|
||||
// through serde_json::Value.
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut s = serializer.serialize_struct("CockpitBroadcastFrame", 3)?;
|
||||
s.serialize_field("session_id", &self.session_id)?;
|
||||
s.serialize_field("seq", &self.seq)?;
|
||||
s.serialize_field("event", &*self.event)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CockpitBroadcastFrame {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Mirror of the Serialize impl. Clients need to parse frames
|
||||
// streamed over WebSocket, so the type round-trips through
|
||||
// serde even though the server only emits it.
|
||||
#[derive(Deserialize)]
|
||||
struct Wire {
|
||||
session_id: String,
|
||||
seq: u64,
|
||||
event: Event,
|
||||
}
|
||||
let w = Wire::deserialize(deserializer)?;
|
||||
Ok(CockpitBroadcastFrame {
|
||||
session_id: w.session_id,
|
||||
seq: w.seq,
|
||||
event: Arc::new(w.event),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/cockpit/prompt` body.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PromptRequest {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/cockpit/approvals/{nonce}` body.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ResolveApprovalRequest {
|
||||
pub decision: ApprovalDecisionWire,
|
||||
}
|
||||
|
||||
/// PascalCase JSON variants (`Allow`, `AllowAlways`, `Deny`) matching
|
||||
/// the web frontend's approval flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ApprovalDecisionWire {
|
||||
Allow,
|
||||
AllowAlways,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl From<ApprovalDecisionWire> for ApprovalDecision {
|
||||
fn from(d: ApprovalDecisionWire) -> Self {
|
||||
match d {
|
||||
ApprovalDecisionWire::Allow => ApprovalDecision::Allow,
|
||||
ApprovalDecisionWire::AllowAlways => ApprovalDecision::AllowAlways,
|
||||
ApprovalDecisionWire::Deny => ApprovalDecision::Deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ApprovalDecision> for ApprovalDecisionWire {
|
||||
fn from(d: ApprovalDecision) -> Self {
|
||||
match d {
|
||||
ApprovalDecision::Allow => ApprovalDecisionWire::Allow,
|
||||
ApprovalDecision::AllowAlways => ApprovalDecisionWire::AllowAlways,
|
||||
ApprovalDecision::Deny => ApprovalDecisionWire::Deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/{id}/cockpit/replay?since=N` query string.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ReplayQuery {
|
||||
/// Last seq the client has applied. The endpoint returns frames
|
||||
/// strictly newer than this. Defaults to 0 (full replay).
|
||||
#[serde(default)]
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/{id}/cockpit/replay` response.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ReplayResponse {
|
||||
/// Frames the client missed, in publish order. Empty when the
|
||||
/// client is already caught up.
|
||||
pub frames: Vec<CockpitBroadcastFrame>,
|
||||
/// True when the requested `since` predates what's still in the
|
||||
/// buffer (the client missed events that have since been evicted).
|
||||
/// Clients should treat the conversation log as truncated and
|
||||
/// request a fresh start, e.g. by reloading.
|
||||
pub lost: bool,
|
||||
/// Highest seq the buffer has seen, even if it's been evicted.
|
||||
/// Lets the client decide whether reloading is worth it.
|
||||
pub highest_seq: u64,
|
||||
/// Lowest seq still stored on disk for this session, or `None`
|
||||
/// when no events have been recorded yet. Lets clients display the
|
||||
/// retention window in status output and detect mid-flight prunes.
|
||||
#[serde(default)]
|
||||
pub lowest_seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/{id}/cockpit/context-primer?before_seq=N` query.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ContextPrimerQuery {
|
||||
/// `seq` of the `SessionContextReset` event. The primer only
|
||||
/// includes events with `seq < before_seq` so post-reset noise
|
||||
/// (the reset notice itself, any subsequent prompts) stays out.
|
||||
pub before_seq: u64,
|
||||
}
|
||||
|
||||
/// `GET /api/sessions/{id}/cockpit/context-primer` response.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ContextPrimerResponse {
|
||||
/// Rendered markdown primer ready to drop into the composer.
|
||||
/// Empty string when there is no prior transcript to recap.
|
||||
pub primer: String,
|
||||
pub included_event_count: usize,
|
||||
pub included_turn_count: usize,
|
||||
/// True when older turns were dropped or the newest turn was
|
||||
/// truncated within itself to fit the budget. Frontend can surface
|
||||
/// this via a "transcript was abbreviated" hint.
|
||||
pub truncated: bool,
|
||||
pub max_chars: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn broadcast_frame_roundtrips_through_json() {
|
||||
let frame = CockpitBroadcastFrame {
|
||||
session_id: "s-1".into(),
|
||||
seq: 42,
|
||||
event: Arc::new(Event::ThinkingStarted),
|
||||
};
|
||||
let json = serde_json::to_string(&frame).unwrap();
|
||||
let back: CockpitBroadcastFrame = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.session_id, "s-1");
|
||||
assert_eq!(back.seq, 42);
|
||||
assert!(matches!(*back.event, Event::ThinkingStarted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_decision_wire_pascalcase() {
|
||||
let json = serde_json::to_string(&ApprovalDecisionWire::AllowAlways).unwrap();
|
||||
assert_eq!(json, "\"AllowAlways\"");
|
||||
let back: ApprovalDecisionWire = serde_json::from_str("\"Deny\"").unwrap();
|
||||
assert!(matches!(back, ApprovalDecisionWire::Deny));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_approval_request_decision_field() {
|
||||
let body = serde_json::json!({ "decision": "Allow" });
|
||||
let parsed: ResolveApprovalRequest = serde_json::from_value(body).unwrap();
|
||||
assert!(matches!(parsed.decision, ApprovalDecisionWire::Allow));
|
||||
}
|
||||
}
|
||||
+15
@@ -25,6 +25,21 @@ fn is_serve_command(_cli: &Cli) -> bool {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// If the user passed --daemon-url, mirror the value into the env
|
||||
// var so the cockpit::client::discovery layer (used by both the
|
||||
// remote TUI home and the `aoe cockpit *` verbs) picks it up
|
||||
// through the same code path the env-only path uses. This avoids a
|
||||
// second "is the flag set?" check in every callsite.
|
||||
if let Some(url) = &cli.daemon_url {
|
||||
// SAFETY: single-threaded at this point — we haven't entered
|
||||
// the tokio runtime's worker pool yet (the runtime is owned by
|
||||
// the `#[tokio::main]` wrapper that called us, and clap's
|
||||
// parsing was synchronous).
|
||||
unsafe {
|
||||
std::env::set_var("AOE_DAEMON_URL", url);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect drift between release-build state and dev-build state BEFORE
|
||||
// anything below calls `get_app_dir()` (which would auto-create the dev
|
||||
// dir and silently flip the trigger condition for the rest of this
|
||||
|
||||
+16
-81
@@ -12,7 +12,11 @@ use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cockpit::approvals::{ApprovalDecision, Nonce};
|
||||
use crate::cockpit::approvals::Nonce;
|
||||
use crate::cockpit::protocol::{
|
||||
ContextPrimerQuery, ContextPrimerResponse, PromptRequest, ReplayQuery, ReplayResponse,
|
||||
ResolveApprovalRequest,
|
||||
};
|
||||
use crate::cockpit::supervisor::SupervisorError;
|
||||
use crate::server::AppState;
|
||||
|
||||
@@ -176,11 +180,6 @@ pub async fn shutdown_cockpit(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PromptRequest {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub async fn cockpit_prompt(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
@@ -571,29 +570,6 @@ pub async fn cockpit_set_mode(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResolveApprovalRequest {
|
||||
pub decision: ApprovalDecisionWire,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ApprovalDecisionWire {
|
||||
Allow,
|
||||
AllowAlways,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl From<ApprovalDecisionWire> for ApprovalDecision {
|
||||
fn from(d: ApprovalDecisionWire) -> Self {
|
||||
match d {
|
||||
ApprovalDecisionWire::Allow => ApprovalDecision::Allow,
|
||||
ApprovalDecisionWire::AllowAlways => ApprovalDecision::AllowAlways,
|
||||
ApprovalDecisionWire::Deny => ApprovalDecision::Deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_approval(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, nonce_str)): Path<(String, String)>,
|
||||
@@ -623,51 +599,6 @@ pub async fn resolve_approval(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReplayQuery {
|
||||
/// Last seq the client has applied. The endpoint returns frames
|
||||
/// strictly newer than this. Defaults to 0 (full replay).
|
||||
#[serde(default)]
|
||||
pub since: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReplayResponse {
|
||||
/// Frames the client missed, in publish order. Empty when the
|
||||
/// client is already caught up.
|
||||
pub frames: Vec<crate::server::CockpitBroadcastFrame>,
|
||||
/// True when the requested `since` predates what's still in the
|
||||
/// buffer (the client missed events that have since been evicted).
|
||||
/// Clients should treat the conversation log as truncated and
|
||||
/// request a fresh start, e.g. by reloading.
|
||||
pub lost: bool,
|
||||
/// Highest seq the buffer has seen, even if it's been evicted.
|
||||
/// Lets the client decide whether reloading is worth it.
|
||||
pub highest_seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ContextPrimerQuery {
|
||||
/// `seq` of the `SessionContextReset` event. The primer only
|
||||
/// includes events with `seq < before_seq` so post-reset noise
|
||||
/// (the reset notice itself, any subsequent prompts) stays out.
|
||||
pub before_seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ContextPrimerResponse {
|
||||
/// Rendered markdown primer ready to drop into the composer.
|
||||
/// Empty string when there is no prior transcript to recap.
|
||||
pub primer: String,
|
||||
pub included_event_count: usize,
|
||||
pub included_turn_count: usize,
|
||||
/// True when older turns were dropped or the newest turn was
|
||||
/// truncated within itself to fit the budget. Frontend can surface
|
||||
/// this via a "transcript was abbreviated" hint.
|
||||
pub truncated: bool,
|
||||
pub max_chars: usize,
|
||||
}
|
||||
|
||||
/// Build a markdown context primer from the persisted cockpit event
|
||||
/// log. Used after a `session/load` failure: the agent's model
|
||||
/// context is empty, but the visible transcript is intact in SQLite,
|
||||
@@ -718,6 +649,7 @@ pub async fn cockpit_replay(
|
||||
// just restarted) or the client lagged far enough to need older
|
||||
// events than the ring holds.
|
||||
let highest_seq = state.cockpit_event_store.highest_seq(&id);
|
||||
let lowest_seq = state.cockpit_event_store.lowest_seq(&id);
|
||||
let entries = state.cockpit_event_store.replay_from(&id, q.since);
|
||||
let frames: Vec<crate::server::CockpitBroadcastFrame> = entries
|
||||
.into_iter()
|
||||
@@ -727,16 +659,19 @@ pub async fn cockpit_replay(
|
||||
event: Arc::new(event),
|
||||
})
|
||||
.collect();
|
||||
// `lost = true` when the client's `since` cursor predates the oldest
|
||||
// seq still on disk. The retention cap can evict older events, so a
|
||||
// client that returns after a long absence may legitimately need a
|
||||
// full reload. With no events on disk yet, nothing is lost.
|
||||
let lost = match lowest_seq {
|
||||
Some(lo) => q.since < lo.saturating_sub(1),
|
||||
None => false,
|
||||
};
|
||||
Json(ReplayResponse {
|
||||
frames,
|
||||
// The retention cap can drop oldest events; we don't currently
|
||||
// expose lowest-stored-seq, so leave `lost=false` and trust the
|
||||
// client's seq dedupe to hide any short-lived holes. If we
|
||||
// need to surface a real "history truncated" signal later, the
|
||||
// event store can grow a `lowest_seq()` query to compare with
|
||||
// `since`.
|
||||
lost: false,
|
||||
lost,
|
||||
highest_seq,
|
||||
lowest_seq,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+5
-31
@@ -32,38 +32,12 @@ use self::push::{PushState, StatusChange, STATUS_CHANNEL_CAPACITY};
|
||||
#[cfg(feature = "serve")]
|
||||
const COCKPIT_CHANNEL_CAPACITY: usize = 256;
|
||||
|
||||
/// One frame on the per-AppState cockpit broadcast channel: the cockpit
|
||||
/// session id plus the typed cockpit Event. Subscribed WebSocket
|
||||
/// clients filter on the session id and serialise to JSON only at the
|
||||
/// WS write boundary; in-process consumers (status listener,
|
||||
/// acp_session_id listener) match on the typed enum directly so a
|
||||
/// rename of an `Event` variant breaks the build instead of silently
|
||||
/// breaking listener behaviour.
|
||||
///
|
||||
/// `Arc<Event>` so the broadcast clone-per-subscriber stays cheap even
|
||||
/// as the number of WS clients grows.
|
||||
/// Re-export of the broadcast frame defined in `crate::cockpit::protocol`,
|
||||
/// kept under `crate::server::` so existing supervisor/WS call sites keep
|
||||
/// resolving without churn. The canonical definition lives in protocol.rs
|
||||
/// so the daemon and any client share a single source of truth.
|
||||
#[cfg(feature = "serve")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CockpitBroadcastFrame {
|
||||
pub session_id: String,
|
||||
pub seq: u64,
|
||||
pub event: Arc<crate::cockpit::Event>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
impl serde::Serialize for CockpitBroadcastFrame {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
// Custom impl so the wire format stays the same (untagged
|
||||
// event JSON) without forcing every consumer to round-trip
|
||||
// through serde_json::Value.
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut s = serializer.serialize_struct("CockpitBroadcastFrame", 3)?;
|
||||
s.serialize_field("session_id", &self.session_id)?;
|
||||
s.serialize_field("seq", &self.seq)?;
|
||||
s.serialize_field("event", &*self.event)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
pub use crate::cockpit::protocol::CockpitBroadcastFrame;
|
||||
|
||||
use crate::session::Instance;
|
||||
use crate::session::Status;
|
||||
|
||||
@@ -84,6 +84,11 @@ pub struct App {
|
||||
/// it back on when the surface dismisses. Default true to match the
|
||||
/// startup `EnableMouseCapture` in `tui::run`.
|
||||
mouse_captured: bool,
|
||||
/// Set by `Action::OpenCockpit` so the async main loop can pick it
|
||||
/// up and enter the cockpit view (which needs `event_stream` access
|
||||
/// the sync `execute_action` can't lend out).
|
||||
#[cfg(feature = "serve")]
|
||||
pending_cockpit_open: Option<String>,
|
||||
}
|
||||
|
||||
/// Check if the app version changed and return the previous version if changelog should be shown.
|
||||
@@ -193,6 +198,8 @@ impl App {
|
||||
update_bar_dismissed: false,
|
||||
event_stream: Some(EventStream::new()),
|
||||
mouse_captured: true,
|
||||
#[cfg(feature = "serve")]
|
||||
pending_cockpit_open: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -893,6 +900,37 @@ impl App {
|
||||
self.execute_action(action, terminal)?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
if let Some(session_id) = self.pending_cockpit_open.take() {
|
||||
self.run_cockpit_view(&session_id, terminal).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve")]
|
||||
async fn run_cockpit_view(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
// The cockpit view borrows the EventStream so it can drive its
|
||||
// own tokio::select! loop. Pull it out for the duration of the
|
||||
// call; restore on return.
|
||||
let mut stream = match self.event_stream.take() {
|
||||
Some(s) => s,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let result =
|
||||
crate::tui::cockpit_view::run(terminal, &mut stream, &self.theme, session_id).await;
|
||||
self.event_stream = Some(stream);
|
||||
// Forcing a full redraw on return so the home screen redraws
|
||||
// any cells the cockpit view painted over.
|
||||
self.needs_redraw = true;
|
||||
terminal.clear()?;
|
||||
if let Err(e) = result {
|
||||
self.update_status = Some(UpdateStatus::transient(format!("cockpit closed: {e}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -966,6 +1004,14 @@ impl App {
|
||||
self.home.execute_send_message(&id, &message);
|
||||
self.update_status = None;
|
||||
}
|
||||
#[cfg(feature = "serve")]
|
||||
Action::OpenCockpit(id) => {
|
||||
// Stash for the async main loop. The cockpit view needs
|
||||
// `event_stream` access that this sync handler can't
|
||||
// lend; the loop picks `pending_cockpit_open` up after
|
||||
// we return.
|
||||
self.pending_cockpit_open = Some(id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1248,6 +1294,12 @@ pub enum Action {
|
||||
/// can render a "Reviving..." status before the potentially-slow
|
||||
/// ensure_pane_ready call.
|
||||
SendMessage(String, String),
|
||||
/// Open the native cockpit view for `session_id`. The action handler
|
||||
/// stashes the id in `pending_cockpit_open`; the main loop drains it
|
||||
/// after `execute_action` returns and runs the async cockpit loop
|
||||
/// against the borrowed terminal + event stream.
|
||||
#[cfg(feature = "serve")]
|
||||
OpenCockpit(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Focus model + key dispatch for the cockpit view.
|
||||
//!
|
||||
//! Three focusable regions: composer, transcript, and (when one is
|
||||
//! pending) approval card. The composer captures **every** key when
|
||||
//! focused, including `a`/`A`/`d`, so typing "always allow" into a
|
||||
//! prompt never resolves an approval. `Esc` from any region except
|
||||
//! composer exits the view; from composer it returns focus to the
|
||||
//! transcript.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::cockpit::protocol::ApprovalDecisionWire;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
Composer,
|
||||
Transcript,
|
||||
Approval,
|
||||
}
|
||||
|
||||
/// What the input dispatcher decided to do with this key. The view
|
||||
/// layer handles the actual side-effects so input.rs stays a pure
|
||||
/// translator.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Intent {
|
||||
/// Pass the key through to the composer textarea.
|
||||
Compose(KeyEvent),
|
||||
/// Submit the composer's buffered text as a prompt.
|
||||
SubmitPrompt,
|
||||
/// Scroll the transcript by N lines (positive = down).
|
||||
Scroll(i32),
|
||||
/// Resolve the focused approval card.
|
||||
ResolveApproval(ApprovalDecisionWire),
|
||||
/// Cancel the in-flight prompt (Ctrl-C style).
|
||||
CancelInFlight,
|
||||
/// Open the daemon URL for this session in the user's browser.
|
||||
OpenInBrowser,
|
||||
/// Move focus to the named region.
|
||||
SetFocus(Focus),
|
||||
/// Exit the cockpit view; return to the home screen.
|
||||
Exit,
|
||||
/// Nothing to do (unhandled key).
|
||||
Ignore,
|
||||
}
|
||||
|
||||
/// Translate a key event into an [`Intent`] based on the current
|
||||
/// focus. Pure function so the entire focus model is unit-testable
|
||||
/// without instantiating a real ratatui surface.
|
||||
pub fn dispatch(focus: Focus, key: &KeyEvent, has_pending_approval: bool) -> Intent {
|
||||
// Universal: Ctrl-C cancels any in-flight prompt (matches the web
|
||||
// composer's stop button). We intentionally do NOT exit the view
|
||||
// on Ctrl-C because the user's natural reflex from a tmux session
|
||||
// is "stop the agent, don't quit the screen."
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
return Intent::CancelInFlight;
|
||||
}
|
||||
// Universal: Ctrl-o opens the browser. `o` alone is reserved for
|
||||
// transcript-focus so typing "no" into the composer doesn't open a
|
||||
// browser tab.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('o') {
|
||||
return Intent::OpenInBrowser;
|
||||
}
|
||||
|
||||
match focus {
|
||||
Focus::Composer => composer_keys(key),
|
||||
Focus::Transcript => transcript_keys(key, has_pending_approval),
|
||||
Focus::Approval => approval_keys(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn composer_keys(key: &KeyEvent) -> Intent {
|
||||
match (key.modifiers, key.code) {
|
||||
// Plain Enter submits.
|
||||
(m, KeyCode::Enter) if m.is_empty() => Intent::SubmitPrompt,
|
||||
// Shift+Enter inserts a newline (passed through to textarea).
|
||||
(m, KeyCode::Enter) if m.contains(KeyModifiers::SHIFT) => Intent::Compose(*key),
|
||||
// Esc moves focus to the transcript so the user can scroll or
|
||||
// pick an approval card. This also dismisses any accidental
|
||||
// composer focus (e.g. after typing then changing their mind).
|
||||
(m, KeyCode::Esc) if m.is_empty() => Intent::SetFocus(Focus::Transcript),
|
||||
// Tab cycles forward through the focus regions.
|
||||
(m, KeyCode::Tab) if m.is_empty() => Intent::SetFocus(Focus::Transcript),
|
||||
// Everything else is forwarded to the textarea, including
|
||||
// `a`/`A`/`d`. This is the focus-isolation guarantee.
|
||||
_ => Intent::Compose(*key),
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_keys(key: &KeyEvent, has_pending_approval: bool) -> Intent {
|
||||
match (key.modifiers, key.code) {
|
||||
// Exit / dismiss.
|
||||
(m, KeyCode::Esc) if m.is_empty() => Intent::Exit,
|
||||
// Switch to composer.
|
||||
(m, KeyCode::Char('i')) if m.is_empty() => Intent::SetFocus(Focus::Composer),
|
||||
(m, KeyCode::Tab) if m.is_empty() => {
|
||||
if has_pending_approval {
|
||||
Intent::SetFocus(Focus::Approval)
|
||||
} else {
|
||||
Intent::SetFocus(Focus::Composer)
|
||||
}
|
||||
}
|
||||
// Vim-style scroll.
|
||||
(m, KeyCode::Char('j')) if m.is_empty() => Intent::Scroll(1),
|
||||
(m, KeyCode::Char('k')) if m.is_empty() => Intent::Scroll(-1),
|
||||
(m, KeyCode::Down) if m.is_empty() => Intent::Scroll(1),
|
||||
(m, KeyCode::Up) if m.is_empty() => Intent::Scroll(-1),
|
||||
(m, KeyCode::PageDown) if m.is_empty() => Intent::Scroll(10),
|
||||
(m, KeyCode::PageUp) if m.is_empty() => Intent::Scroll(-10),
|
||||
(m, KeyCode::Char('g')) if m.is_empty() => Intent::Scroll(i32::MIN),
|
||||
(m, KeyCode::Char('G')) if m.contains(KeyModifiers::SHIFT) => Intent::Scroll(i32::MAX),
|
||||
// Plain 'o' opens browser only when transcript is focused.
|
||||
(m, KeyCode::Char('o')) if m.is_empty() => Intent::OpenInBrowser,
|
||||
_ => Intent::Ignore,
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_keys(key: &KeyEvent) -> Intent {
|
||||
match (key.modifiers, key.code) {
|
||||
(m, KeyCode::Char('a')) if m.is_empty() => {
|
||||
Intent::ResolveApproval(ApprovalDecisionWire::Allow)
|
||||
}
|
||||
(m, KeyCode::Char('A')) if m.contains(KeyModifiers::SHIFT) => {
|
||||
Intent::ResolveApproval(ApprovalDecisionWire::AllowAlways)
|
||||
}
|
||||
(m, KeyCode::Char('d')) if m.is_empty() => {
|
||||
Intent::ResolveApproval(ApprovalDecisionWire::Deny)
|
||||
}
|
||||
(m, KeyCode::Esc) if m.is_empty() => Intent::SetFocus(Focus::Transcript),
|
||||
(m, KeyCode::Tab) if m.is_empty() => Intent::SetFocus(Focus::Composer),
|
||||
_ => Intent::Ignore,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
fn key_mod(code: KeyCode, m: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent::new(code, m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_swallows_approval_letters() {
|
||||
// Regression test for the composer-eats-approval bug: typing
|
||||
// "always allow" with a pending approval must NOT fire any
|
||||
// approval intent.
|
||||
for ch in "always allow deny".chars() {
|
||||
let intent = dispatch(Focus::Composer, &key(KeyCode::Char(ch)), true);
|
||||
match intent {
|
||||
Intent::Compose(_) => {}
|
||||
other => panic!("char {ch:?} produced {other:?} from composer focus"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_keys_only_resolve_when_focused() {
|
||||
// Same letters from the transcript focus must NOT resolve.
|
||||
for ch in "aAd".chars() {
|
||||
let intent = dispatch(
|
||||
Focus::Transcript,
|
||||
&key_mod(
|
||||
KeyCode::Char(ch),
|
||||
if ch.is_uppercase() {
|
||||
KeyModifiers::SHIFT
|
||||
} else {
|
||||
KeyModifiers::NONE
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
!matches!(intent, Intent::ResolveApproval(_)),
|
||||
"{ch} resolved from transcript focus: {intent:?}"
|
||||
);
|
||||
}
|
||||
// But the same letters DO resolve under approval focus.
|
||||
assert!(matches!(
|
||||
dispatch(Focus::Approval, &key(KeyCode::Char('a')), true),
|
||||
Intent::ResolveApproval(ApprovalDecisionWire::Allow)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch(
|
||||
Focus::Approval,
|
||||
&key_mod(KeyCode::Char('A'), KeyModifiers::SHIFT),
|
||||
true
|
||||
),
|
||||
Intent::ResolveApproval(ApprovalDecisionWire::AllowAlways)
|
||||
));
|
||||
assert!(matches!(
|
||||
dispatch(Focus::Approval, &key(KeyCode::Char('d')), true),
|
||||
Intent::ResolveApproval(ApprovalDecisionWire::Deny)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_from_composer_returns_focus_to_transcript() {
|
||||
let intent = dispatch(Focus::Composer, &key(KeyCode::Esc), false);
|
||||
assert_eq!(intent, Intent::SetFocus(Focus::Transcript));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_from_transcript_exits() {
|
||||
let intent = dispatch(Focus::Transcript, &key(KeyCode::Esc), false);
|
||||
assert_eq!(intent, Intent::Exit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_cancels_from_any_focus() {
|
||||
for focus in [Focus::Composer, Focus::Transcript, Focus::Approval] {
|
||||
let intent = dispatch(
|
||||
focus,
|
||||
&key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
true,
|
||||
);
|
||||
assert_eq!(intent, Intent::CancelInFlight);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_o_opens_browser_only_from_transcript() {
|
||||
// Composer focus must pass through.
|
||||
let composer = dispatch(Focus::Composer, &key(KeyCode::Char('o')), false);
|
||||
assert!(matches!(composer, Intent::Compose(_)));
|
||||
// Transcript focus opens browser.
|
||||
let transcript = dispatch(Focus::Transcript, &key(KeyCode::Char('o')), false);
|
||||
assert_eq!(transcript, Intent::OpenInBrowser);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_composer_submits() {
|
||||
let intent = dispatch(Focus::Composer, &key(KeyCode::Enter), false);
|
||||
assert_eq!(intent, Intent::SubmitPrompt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_in_composer_inserts_newline() {
|
||||
let intent = dispatch(
|
||||
Focus::Composer,
|
||||
&key_mod(KeyCode::Enter, KeyModifiers::SHIFT),
|
||||
false,
|
||||
);
|
||||
assert!(matches!(intent, Intent::Compose(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_from_transcript_routes_to_approval_when_pending() {
|
||||
let with_pending = dispatch(Focus::Transcript, &key(KeyCode::Tab), true);
|
||||
assert_eq!(with_pending, Intent::SetFocus(Focus::Approval));
|
||||
let without = dispatch(Focus::Transcript, &key(KeyCode::Tab), false);
|
||||
assert_eq!(without, Intent::SetFocus(Focus::Composer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_scroll_keys_only_active_in_transcript() {
|
||||
assert_eq!(
|
||||
dispatch(Focus::Transcript, &key(KeyCode::Char('j')), false),
|
||||
Intent::Scroll(1)
|
||||
);
|
||||
// 'j' in composer is a typed character, not a scroll.
|
||||
assert!(matches!(
|
||||
dispatch(Focus::Composer, &key(KeyCode::Char('j')), false),
|
||||
Intent::Compose(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
//! Native ratatui rendering of a cockpit session.
|
||||
//!
|
||||
//! Consumes the same daemon HTTP / WebSocket surface that the web
|
||||
//! frontend uses; the per-frame reducer mirrors the activity semantics
|
||||
//! of `web/src/hooks/useCockpit.ts` without the React-specific shapes.
|
||||
//!
|
||||
//! Directory name is `cockpit_view` (not `cockpit`) to avoid colliding
|
||||
//! with `src/cockpit/` per the recipe in
|
||||
//! https://github.com/njbrake/agent-of-empires/issues/1018#issuecomment-4444040929.
|
||||
|
||||
pub mod input;
|
||||
pub mod reducer;
|
||||
pub mod render;
|
||||
pub mod state;
|
||||
|
||||
use std::io::Stdout;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind};
|
||||
use futures_util::StreamExt;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use self::input::{Focus, Intent};
|
||||
use self::state::{CockpitViewState, ToastBanner, ToastKind};
|
||||
use crate::cockpit::client::{
|
||||
require_daemon, ws_connect, DaemonEndpoint, HttpClient, ManagerError, WsError, WsMessage,
|
||||
};
|
||||
use crate::cockpit::protocol::ApprovalDecisionWire;
|
||||
use crate::tui::styles::Theme;
|
||||
|
||||
/// Per-keystroke redraw interval. The animations are minimal (just the
|
||||
/// blinking caret in the composer); 120ms keeps it from looking laggy
|
||||
/// without burning CPU.
|
||||
const REDRAW_INTERVAL: Duration = Duration::from_millis(120);
|
||||
/// Toasts auto-clear after this long.
|
||||
const TOAST_TTL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Set up an alternate-screen terminal, run the cockpit view against
|
||||
/// the given session, and tear it back down on exit. Used by the
|
||||
/// `aoe cockpit attach <id>` CLI verb to jump straight into the
|
||||
/// cockpit view without going through the home screen. Pair with
|
||||
/// `AOE_DAEMON_URL` for remote-attach against another machine's
|
||||
/// cockpit daemon.
|
||||
pub async fn run_standalone(session_id: &str) -> anyhow::Result<()> {
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
EventStream,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use std::io;
|
||||
use std::io::IsTerminal;
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
anyhow::bail!("stdin is not a terminal; `aoe cockpit attach` requires an interactive TTY");
|
||||
}
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(
|
||||
stdout,
|
||||
EnterAlternateScreen,
|
||||
EnableBracketedPaste,
|
||||
EnableMouseCapture
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let mut event_stream = EventStream::new();
|
||||
// Standalone attach uses a default theme; the user's theme
|
||||
// pref lives in the home view state, which we don't load here.
|
||||
let theme = crate::tui::styles::load_theme_with_mode("empire", false);
|
||||
|
||||
let result = run(&mut terminal, &mut event_stream, &theme, session_id).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Open the cockpit view for `session_id` and run its event loop until
|
||||
/// the user exits with `Esc`, or until the cockpit daemon becomes
|
||||
/// unreachable in a way the view can't recover from.
|
||||
///
|
||||
/// Borrows the host terminal + event stream so the parent App can
|
||||
/// resume rendering when the view returns.
|
||||
pub async fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
event_stream: &mut EventStream,
|
||||
theme: &Theme,
|
||||
session_id: &str,
|
||||
) -> Result<()> {
|
||||
let endpoint = match require_daemon().await {
|
||||
Ok(e) => e,
|
||||
Err(ManagerError::EnvOverrideUnreachable) => {
|
||||
render_error_screen(
|
||||
terminal,
|
||||
theme,
|
||||
"AOE_DAEMON_URL is set but the daemon at that URL is unreachable.\n\nCheck the URL, or unset the env var to use a local daemon.",
|
||||
)?;
|
||||
wait_for_dismiss(event_stream).await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(ManagerError::EnvOverrideUnauthorized) => {
|
||||
render_error_screen(
|
||||
terminal,
|
||||
theme,
|
||||
"AOE_DAEMON_URL is set but the daemon rejected the bearer token.\n\nCheck AOE_DAEMON_TOKEN.",
|
||||
)?;
|
||||
wait_for_dismiss(event_stream).await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e @ ManagerError::NoDaemonRunning(_)) => {
|
||||
// Carries the multi-line "start one with..." hint from the
|
||||
// error variant. Render as-is so the user sees the choice
|
||||
// between localhost/Tailscale/Cloudflare without having to
|
||||
// dig through docs.
|
||||
render_error_screen(
|
||||
terminal,
|
||||
theme,
|
||||
&format!("{e}\n\nPress any key to return to the session list."),
|
||||
)?;
|
||||
wait_for_dismiss(event_stream).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
run_for_endpoint(terminal, event_stream, theme, endpoint, session_id).await
|
||||
}
|
||||
|
||||
/// Same as [`run`] but the caller has already located the daemon
|
||||
/// endpoint (e.g. the remote-home picker that ran a session discovery
|
||||
/// step against a fixed `AOE_DAEMON_URL`). Skips `require_daemon` so
|
||||
/// the view doesn't re-run discovery / health-check when the caller
|
||||
/// has already done it.
|
||||
pub async fn run_for_endpoint(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
event_stream: &mut EventStream,
|
||||
theme: &Theme,
|
||||
endpoint: DaemonEndpoint,
|
||||
session_id: &str,
|
||||
) -> Result<()> {
|
||||
let http = HttpClient::new(endpoint.clone()).context("build cockpit HTTP client")?;
|
||||
|
||||
// Hydrate the transcript via /replay before opening the WebSocket
|
||||
// so the user sees the historical conversation immediately instead
|
||||
// of a blank pane until live frames start arriving.
|
||||
let initial = http.replay(session_id, 0).await;
|
||||
let ws = ws_connect(&endpoint, session_id, 0).await.ok();
|
||||
|
||||
let mut state = CockpitViewState::new(session_id.to_string(), endpoint, http, ws);
|
||||
state.focus = Focus::Transcript;
|
||||
|
||||
if let Ok(replay) = initial {
|
||||
if replay.lost {
|
||||
state.transcript.set_lagged();
|
||||
}
|
||||
for frame in &replay.frames {
|
||||
state.transcript.apply(frame);
|
||||
}
|
||||
state.reconcile_selection();
|
||||
}
|
||||
|
||||
redraw(terminal, theme, &state)?;
|
||||
|
||||
let mut redraw_ticker = tokio::time::interval(REDRAW_INTERVAL);
|
||||
redraw_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
let mut toast_deadline: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
evt = event_stream.next() => {
|
||||
let Some(evt) = evt else {
|
||||
// EventStream closed; bail out so the parent App
|
||||
// can do its own cleanup.
|
||||
return Ok(());
|
||||
};
|
||||
let evt = evt.context("read terminal event")?;
|
||||
let should_exit = handle_terminal_event(&mut state, evt, &mut toast_deadline).await?;
|
||||
if should_exit {
|
||||
return Ok(());
|
||||
}
|
||||
redraw(terminal, theme, &state)?;
|
||||
}
|
||||
ws_msg = recv_ws(&mut state) => {
|
||||
match ws_msg {
|
||||
Some(Ok(WsMessage::Frame(frame))) => {
|
||||
state.transcript.apply(&frame);
|
||||
state.reconcile_selection();
|
||||
redraw(terminal, theme, &state)?;
|
||||
}
|
||||
Some(Ok(WsMessage::Lagged)) => {
|
||||
// Daemon evicted events we hadn't seen yet. Drop
|
||||
// local reducer state and rehydrate from /replay.
|
||||
state.transcript.reset();
|
||||
match state.http.replay(&state.session_id, 0).await {
|
||||
Ok(replay) => {
|
||||
if replay.lost {
|
||||
state.transcript.set_lagged();
|
||||
}
|
||||
for frame in &replay.frames {
|
||||
state.transcript.apply(frame);
|
||||
}
|
||||
state.reconcile_selection();
|
||||
}
|
||||
Err(e) => {
|
||||
set_toast(&mut state, &mut toast_deadline, format!("replay failed: {e}"), ToastKind::Error);
|
||||
}
|
||||
}
|
||||
redraw(terminal, theme, &state)?;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
// WS dropped; show a banner and try to reconnect
|
||||
// from the last seq we processed. Bounded backoff
|
||||
// so a flaky daemon restart (e.g. a 2-second
|
||||
// process bounce) survives without paging the
|
||||
// user, but a permanently-down daemon doesn't
|
||||
// pin a worker tight-looping retries.
|
||||
tracing::warn!(target: "cockpit.tui.ws", "ws disconnect: {e}");
|
||||
set_toast(&mut state, &mut toast_deadline, format!("ws disconnected: {e}; reconnecting…"), ToastKind::Error);
|
||||
state.ws = None;
|
||||
let since = state.transcript.last_seq;
|
||||
match reconnect_with_backoff(&state.endpoint, &state.session_id, since).await {
|
||||
Ok(handle) => {
|
||||
state.ws = Some(handle);
|
||||
set_toast(&mut state, &mut toast_deadline, "ws reconnected".into(), ToastKind::Info);
|
||||
}
|
||||
Err(e) => {
|
||||
set_toast(&mut state, &mut toast_deadline, format!("ws reconnect failed: {e}"), ToastKind::Error);
|
||||
}
|
||||
}
|
||||
redraw(terminal, theme, &state)?;
|
||||
}
|
||||
None => {
|
||||
// Either no ws handle or the channel closed.
|
||||
// Sleep briefly to avoid spinning the select loop.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = redraw_ticker.tick() => {
|
||||
let now = Instant::now();
|
||||
if let Some(deadline) = toast_deadline {
|
||||
if now >= deadline {
|
||||
state.toast = None;
|
||||
toast_deadline = None;
|
||||
}
|
||||
}
|
||||
redraw(terminal, theme, &state)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_terminal_event(
|
||||
state: &mut CockpitViewState,
|
||||
evt: CrosstermEvent,
|
||||
toast_deadline: &mut Option<Instant>,
|
||||
) -> Result<bool> {
|
||||
let CrosstermEvent::Key(key) = evt else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Skip key-release events on terminals that emit them (Windows
|
||||
// crossterm, kitty enhanced protocol). Otherwise every keypress
|
||||
// triggers two handle_key calls.
|
||||
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_pending = !state.transcript.pending_approvals.is_empty();
|
||||
let intent = input::dispatch(state.focus, &key, has_pending);
|
||||
match intent {
|
||||
Intent::Ignore => Ok(false),
|
||||
Intent::Exit => Ok(true),
|
||||
Intent::SetFocus(focus) => {
|
||||
// Approval focus only makes sense when there's one to
|
||||
// select; otherwise fall through to transcript.
|
||||
state.focus = if matches!(focus, Focus::Approval) && !has_pending {
|
||||
Focus::Transcript
|
||||
} else {
|
||||
focus
|
||||
};
|
||||
state.reconcile_selection();
|
||||
Ok(false)
|
||||
}
|
||||
Intent::Compose(k) => {
|
||||
// ratatui_textarea consumes raw crossterm KeyEvent through
|
||||
// its `Input` conversion.
|
||||
state.composer.input(k);
|
||||
Ok(false)
|
||||
}
|
||||
Intent::SubmitPrompt => {
|
||||
let text = state.take_composer_text();
|
||||
if text.is_empty() {
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
"composer is empty".into(),
|
||||
ToastKind::Info,
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
match state.http.prompt(&state.session_id, &text).await {
|
||||
Ok(()) => {
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
format!("prompt sent ({} bytes)", text.len()),
|
||||
ToastKind::Info,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
format!("send failed: {e}"),
|
||||
ToastKind::Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Intent::Scroll(delta) => {
|
||||
apply_scroll(state, delta);
|
||||
Ok(false)
|
||||
}
|
||||
Intent::ResolveApproval(decision) => {
|
||||
let Some(idx) = state.selected_approval else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(pending) = state.transcript.pending_approvals.get(idx).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
match state
|
||||
.http
|
||||
.resolve_approval(&state.session_id, &pending.nonce, decision)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
let label = match decision {
|
||||
ApprovalDecisionWire::Allow => "allowed",
|
||||
ApprovalDecisionWire::AllowAlways => "allow-always",
|
||||
ApprovalDecisionWire::Deny => "denied",
|
||||
};
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
format!("approval {label}"),
|
||||
ToastKind::Info,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
format!("approval failed: {e}"),
|
||||
ToastKind::Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Server will emit ApprovalResolved over WS; the reducer
|
||||
// updates state then.
|
||||
Ok(false)
|
||||
}
|
||||
Intent::CancelInFlight => {
|
||||
match state.http.cancel(&state.session_id).await {
|
||||
Ok(()) => set_toast(state, toast_deadline, "cancel sent".into(), ToastKind::Info),
|
||||
Err(e) => set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
format!("cancel failed: {e}"),
|
||||
ToastKind::Error,
|
||||
),
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Intent::OpenInBrowser => {
|
||||
let url = format!(
|
||||
"{}/sessions/{}/cockpit",
|
||||
state.endpoint.base_url, state.session_id
|
||||
);
|
||||
if let Err(e) = webbrowser::open(&url) {
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
format!("open failed: {e}"),
|
||||
ToastKind::Error,
|
||||
);
|
||||
} else {
|
||||
set_toast(
|
||||
state,
|
||||
toast_deadline,
|
||||
"opened in browser".into(),
|
||||
ToastKind::Info,
|
||||
);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Async pull from the cockpit WebSocket. Returns `None` when no ws
|
||||
/// handle is currently attached so the select arm degrades to a
|
||||
/// timed wait instead of busy-looping.
|
||||
async fn recv_ws(state: &mut CockpitViewState) -> Option<Result<WsMessage, WsError>> {
|
||||
let ws = state.ws.as_mut()?;
|
||||
ws.recv().await
|
||||
}
|
||||
|
||||
/// Reconnect with three attempts and 250ms / 500ms / 1000ms backoff.
|
||||
/// Daemon restarts on the same box come back in under a second; a
|
||||
/// remote daemon failure usually doesn't recover inside our budget,
|
||||
/// so the user gets a toast and can hit retry themselves.
|
||||
async fn reconnect_with_backoff(
|
||||
endpoint: &DaemonEndpoint,
|
||||
session_id: &str,
|
||||
since: u64,
|
||||
) -> Result<crate::cockpit::client::WsHandle, WsError> {
|
||||
const BACKOFFS_MS: &[u64] = &[250, 500, 1000];
|
||||
let mut last_err: Option<WsError> = None;
|
||||
for (i, &delay) in BACKOFFS_MS.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||
}
|
||||
match ws_connect(endpoint, session_id, since).await {
|
||||
Ok(handle) => return Ok(handle),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
target: "cockpit.tui.ws",
|
||||
attempt = i + 1,
|
||||
"ws reconnect attempt failed: {e}"
|
||||
);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.expect("at least one attempt"))
|
||||
}
|
||||
|
||||
fn apply_scroll(state: &mut CockpitViewState, delta: i32) {
|
||||
if delta == i32::MIN {
|
||||
state.scroll_offset = 0;
|
||||
} else if delta == i32::MAX {
|
||||
state.scroll_offset = u16::MAX;
|
||||
} else if delta < 0 {
|
||||
state.scroll_offset = state.scroll_offset.saturating_sub((-delta) as u16);
|
||||
} else {
|
||||
state.scroll_offset = state.scroll_offset.saturating_add(delta as u16);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_toast(
|
||||
state: &mut CockpitViewState,
|
||||
deadline: &mut Option<Instant>,
|
||||
text: String,
|
||||
kind: ToastKind,
|
||||
) {
|
||||
state.toast = Some(ToastBanner { text, kind });
|
||||
*deadline = Some(Instant::now() + TOAST_TTL);
|
||||
}
|
||||
|
||||
fn redraw(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
theme: &Theme,
|
||||
state: &CockpitViewState,
|
||||
) -> Result<()> {
|
||||
terminal.draw(|f| render::render(f, f.area(), theme, state))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_error_screen(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
_theme: &Theme,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
let msg = message.to_string();
|
||||
terminal.draw(|f| {
|
||||
let area = f.area();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Cockpit · error ");
|
||||
let para = Paragraph::new(msg.clone())
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
f.render_widget(para, area);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_dismiss(event_stream: &mut EventStream) -> Result<()> {
|
||||
while let Some(evt) = event_stream.next().await {
|
||||
if let Ok(CrosstermEvent::Key(_)) = evt {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Pure reducer over `CockpitBroadcastFrame` → `CockpitTranscript`.
|
||||
//!
|
||||
//! Mirrors the semantics of `web/src/hooks/useCockpit.ts` but in
|
||||
//! Rust + with the TUI's flat-row data shape. The server-side
|
||||
//! `CockpitState` in `src/cockpit/state.rs` is intentionally NOT a UI
|
||||
//! reducer (it drops `AgentMessageChunk` text, for one), so the TUI
|
||||
//! cockpit view owns its own activity accumulator.
|
||||
//!
|
||||
//! Design choices for the TUI MVP:
|
||||
//!
|
||||
//! - Rich tool-card breakdowns (per-kind layout, diff previews, file
|
||||
//! trees) are deferred to followup issues. Tool calls render as
|
||||
//! structured one-liner cards here; users can press `o` from the
|
||||
//! transcript pane to open the web view for full-fidelity inspection.
|
||||
//! - `AvailableCommandsUpdated` is retained on the transcript even
|
||||
//! though the MVP composer doesn't surface a slash-command picker;
|
||||
//! the followup that adds slash autocomplete (#1018 followup) needs
|
||||
//! this list in place.
|
||||
//! - `SessionContextReset` flips `context_primer_pending` so the view
|
||||
//! layer can offer the "paste a context primer" affordance.
|
||||
|
||||
use crate::cockpit::approvals::ApprovalDecision;
|
||||
use crate::cockpit::protocol::CockpitBroadcastFrame;
|
||||
use crate::cockpit::state::{AvailableCommand, Event, PlanStepStatus};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CockpitTranscript {
|
||||
pub session_id: String,
|
||||
pub rows: Vec<ActivityRow>,
|
||||
pub pending_approvals: Vec<PendingApproval>,
|
||||
/// Live status banner (e.g. "thinking…", "ended: completed").
|
||||
pub status_text: Option<String>,
|
||||
/// Latest mode id the agent reported. `None` until the agent
|
||||
/// emits `ModesAvailable` / `CurrentModeChanged`.
|
||||
pub current_mode: Option<String>,
|
||||
/// Slash commands the agent has advertised. Drives the composer's
|
||||
/// `/` picker (followup #1018).
|
||||
pub available_commands: Vec<AvailableCommand>,
|
||||
/// Set after a `SessionContextReset`; the view layer drops a
|
||||
/// "context lost, re-prime?" banner until the user dismisses it
|
||||
/// or sends the next prompt.
|
||||
pub context_primer_pending: bool,
|
||||
/// Set when the WS layer reports `{"kind":"lagged"}`; the view
|
||||
/// layer should clear and rehydrate via HTTP /replay.
|
||||
pub lagged: bool,
|
||||
/// Highest seq the reducer has consumed. Used as the `since`
|
||||
/// cursor for reconnect.
|
||||
pub last_seq: u64,
|
||||
/// Index into `rows` of the currently-growing `AgentMessage` row
|
||||
/// (so consecutive `AgentMessageChunk` events append in-place
|
||||
/// instead of fragmenting one assistant turn across many rows).
|
||||
/// Cleared on any non-chunk event.
|
||||
pending_message_idx: Option<usize>,
|
||||
/// Map of tool_call_id -> row index in `rows`. Lets
|
||||
/// `ToolCallCompleted` and `ToolCallUpdated` locate the row to
|
||||
/// mutate without scanning the entire activity feed.
|
||||
tool_idx: std::collections::HashMap<String, usize>,
|
||||
/// Map of approval nonce -> row index in `rows`. Same idea for
|
||||
/// `ApprovalResolved`.
|
||||
approval_idx: std::collections::HashMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActivityRow {
|
||||
UserPrompt(String),
|
||||
AgentMessage(String),
|
||||
ToolCall(ToolCallRow),
|
||||
Approval(ApprovalRow),
|
||||
Plan(Vec<PlanLine>),
|
||||
Note { kind: NoteKind, text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCallRow {
|
||||
pub name: String,
|
||||
pub args: String,
|
||||
pub completed: Option<ToolCompletion>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCompletion {
|
||||
pub ok: bool,
|
||||
/// Empty string when the agent didn't ship a content body; the
|
||||
/// view layer falls back to a status word in that case.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApprovalRow {
|
||||
pub nonce: String,
|
||||
pub title: String,
|
||||
pub destructive: bool,
|
||||
pub decision: Option<ApprovalDecision>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanLine {
|
||||
pub title: String,
|
||||
pub status: PlanStepStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingApproval {
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NoteKind {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl CockpitTranscript {
|
||||
pub fn new(session_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
rows: Vec::new(),
|
||||
pending_approvals: Vec::new(),
|
||||
status_text: None,
|
||||
current_mode: None,
|
||||
available_commands: Vec::new(),
|
||||
context_primer_pending: false,
|
||||
lagged: false,
|
||||
last_seq: 0,
|
||||
pending_message_idx: None,
|
||||
tool_idx: std::collections::HashMap::new(),
|
||||
approval_idx: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop all accumulated state and start over. Used when the
|
||||
/// daemon signals `lagged` on the WebSocket and we need to
|
||||
/// rehydrate via HTTP /replay.
|
||||
pub fn reset(&mut self) {
|
||||
let session_id = std::mem::take(&mut self.session_id);
|
||||
*self = Self::new(session_id);
|
||||
}
|
||||
|
||||
/// Mark `lagged = true`. The view layer is responsible for
|
||||
/// noticing this and triggering a /replay refetch.
|
||||
pub fn set_lagged(&mut self) {
|
||||
self.lagged = true;
|
||||
self.rows.push(ActivityRow::Note {
|
||||
kind: NoteKind::Warning,
|
||||
text: "broadcast lagged; refetching transcript…".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Apply one broadcast frame.
|
||||
pub fn apply(&mut self, frame: &CockpitBroadcastFrame) {
|
||||
if frame.seq <= self.last_seq && self.last_seq > 0 {
|
||||
// Already consumed; dedupe against the replay-vs-live
|
||||
// overlap. The web reducer does the same. Log at debug
|
||||
// so an unexpected drop (e.g. true reordering) leaves a
|
||||
// trail without spamming on every normal overlap.
|
||||
tracing::debug!(
|
||||
target: "cockpit.tui.reducer",
|
||||
session = %self.session_id,
|
||||
seq = frame.seq,
|
||||
last_seq = self.last_seq,
|
||||
"dropped duplicate or out-of-order frame"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.last_seq = frame.seq;
|
||||
self.apply_event(&frame.event);
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, event: &Event) {
|
||||
match event {
|
||||
Event::AgentMessageChunk { text } => {
|
||||
if let Some(idx) = self.pending_message_idx {
|
||||
if let Some(ActivityRow::AgentMessage(buf)) = self.rows.get_mut(idx) {
|
||||
buf.push_str(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.rows.push(ActivityRow::AgentMessage(text.clone()));
|
||||
self.pending_message_idx = Some(self.rows.len() - 1);
|
||||
}
|
||||
Event::UserPromptSent { text } => {
|
||||
self.flush_pending_chunk();
|
||||
self.rows.push(ActivityRow::UserPrompt(text.clone()));
|
||||
// Sending a prompt dismisses any context-primer hint.
|
||||
self.context_primer_pending = false;
|
||||
}
|
||||
Event::ThinkingStarted => {
|
||||
self.flush_pending_chunk();
|
||||
self.status_text = Some("thinking…".to_string());
|
||||
}
|
||||
Event::ThinkingEnded => {
|
||||
self.flush_pending_chunk();
|
||||
if self.status_text.as_deref() == Some("thinking…") {
|
||||
self.status_text = None;
|
||||
}
|
||||
}
|
||||
Event::ToolCallStarted { tool_call } => {
|
||||
self.flush_pending_chunk();
|
||||
let row = ToolCallRow {
|
||||
name: tool_call.name.clone(),
|
||||
args: tool_call.args_preview.clone(),
|
||||
completed: None,
|
||||
};
|
||||
self.rows.push(ActivityRow::ToolCall(row));
|
||||
self.tool_idx
|
||||
.insert(tool_call.id.clone(), self.rows.len() - 1);
|
||||
}
|
||||
Event::ToolCallUpdated {
|
||||
tool_call_id,
|
||||
title,
|
||||
args_preview,
|
||||
..
|
||||
} => {
|
||||
if let Some(&idx) = self.tool_idx.get(tool_call_id) {
|
||||
if let Some(ActivityRow::ToolCall(row)) = self.rows.get_mut(idx) {
|
||||
if let Some(t) = title {
|
||||
if !t.is_empty() {
|
||||
row.name = t.clone();
|
||||
}
|
||||
}
|
||||
if let Some(a) = args_preview {
|
||||
if !a.is_empty() {
|
||||
row.args = a.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToolCallContent {
|
||||
tool_call_id,
|
||||
content,
|
||||
} => {
|
||||
// Streaming output: latest snapshot wins. Stash it on
|
||||
// the in-flight row as completion content so the user
|
||||
// sees progress even before the call completes.
|
||||
if let Some(&idx) = self.tool_idx.get(tool_call_id) {
|
||||
if let Some(ActivityRow::ToolCall(row)) = self.rows.get_mut(idx) {
|
||||
match row.completed.as_mut() {
|
||||
Some(c) => c.content = content.clone(),
|
||||
None => {
|
||||
row.completed = Some(ToolCompletion {
|
||||
ok: true, // optimistic until ToolCallCompleted lands
|
||||
content: content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToolCallCompleted {
|
||||
tool_call_id,
|
||||
is_error,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
self.flush_pending_chunk();
|
||||
if let Some(&idx) = self.tool_idx.get(tool_call_id) {
|
||||
if let Some(ActivityRow::ToolCall(row)) = self.rows.get_mut(idx) {
|
||||
row.completed = Some(ToolCompletion {
|
||||
ok: !is_error,
|
||||
content: content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ApprovalRequested { approval } => {
|
||||
self.flush_pending_chunk();
|
||||
let nonce = approval.nonce.0.clone();
|
||||
let row = ApprovalRow {
|
||||
nonce: nonce.clone(),
|
||||
title: approval.tool_call.name.clone(),
|
||||
destructive: approval.destructive,
|
||||
decision: None,
|
||||
};
|
||||
self.rows.push(ActivityRow::Approval(row));
|
||||
let idx = self.rows.len() - 1;
|
||||
self.approval_idx.insert(nonce.clone(), idx);
|
||||
self.pending_approvals.push(PendingApproval { nonce });
|
||||
}
|
||||
Event::ApprovalResolved { nonce, decision } => {
|
||||
self.flush_pending_chunk();
|
||||
if let Some(&idx) = self.approval_idx.get(&nonce.0) {
|
||||
if let Some(ActivityRow::Approval(row)) = self.rows.get_mut(idx) {
|
||||
row.decision = Some(*decision);
|
||||
}
|
||||
}
|
||||
self.pending_approvals.retain(|p| p.nonce != nonce.0);
|
||||
}
|
||||
Event::PlanUpdated { plan } => {
|
||||
self.flush_pending_chunk();
|
||||
let lines: Vec<PlanLine> = plan
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| PlanLine {
|
||||
title: s.title.clone(),
|
||||
status: s.status.clone(),
|
||||
})
|
||||
.collect();
|
||||
self.rows.push(ActivityRow::Plan(lines));
|
||||
}
|
||||
Event::TodoListUpdated { todos: _ } => {
|
||||
// TUI MVP omits the parallel todo list; agents almost
|
||||
// always echo it via Plan anyway. Followup issue.
|
||||
}
|
||||
Event::Stopped { reason } => {
|
||||
self.flush_pending_chunk();
|
||||
self.status_text = Some(format!("stopped: {reason}"));
|
||||
self.rows.push(ActivityRow::Note {
|
||||
kind: NoteKind::Info,
|
||||
text: format!("agent stopped: {reason}"),
|
||||
});
|
||||
}
|
||||
Event::AgentStartupError { message } => {
|
||||
self.flush_pending_chunk();
|
||||
self.status_text = Some("startup error".to_string());
|
||||
self.rows.push(ActivityRow::Note {
|
||||
kind: NoteKind::Error,
|
||||
text: format!("agent startup failed: {message}"),
|
||||
});
|
||||
}
|
||||
Event::SessionContextReset { reason } => {
|
||||
self.flush_pending_chunk();
|
||||
self.context_primer_pending = true;
|
||||
self.rows.push(ActivityRow::Note {
|
||||
kind: NoteKind::Warning,
|
||||
text: format!("context reset: {reason}"),
|
||||
});
|
||||
}
|
||||
Event::AcpSessionAssigned { acp_session_id } => {
|
||||
// Bookkeeping event; not surfaced to the user.
|
||||
let _ = acp_session_id;
|
||||
}
|
||||
Event::AvailableCommandsUpdated { commands } => {
|
||||
self.available_commands = commands.clone();
|
||||
}
|
||||
Event::ModesAvailable {
|
||||
current_mode_id, ..
|
||||
} => {
|
||||
self.current_mode = Some(current_mode_id.clone());
|
||||
}
|
||||
Event::CurrentModeChanged { current_mode_id } => {
|
||||
self.current_mode = Some(current_mode_id.clone());
|
||||
}
|
||||
Event::ModeChanged { mode } => {
|
||||
// Legacy hard-coded mode enum. Fold to the same field.
|
||||
self.current_mode = Some(format!("{mode:?}"));
|
||||
}
|
||||
Event::DiffEmitted { .. }
|
||||
| Event::RateLimit { .. }
|
||||
| Event::UsageUpdated { .. }
|
||||
| Event::RawAgentUpdate { .. }
|
||||
| Event::WakeupScheduled { .. } => {
|
||||
// Surface as info notes for now; richer renderers are
|
||||
// followup work tracked in the plan's "out of scope".
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_pending_chunk(&mut self) {
|
||||
self.pending_message_idx = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cockpit::approvals::{Approval, Nonce};
|
||||
use crate::cockpit::state::{Plan, PlanStep, PlanStepStatus, ToolCall};
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn frame(seq: u64, event: Event) -> CockpitBroadcastFrame {
|
||||
CockpitBroadcastFrame {
|
||||
session_id: "s-1".into(),
|
||||
seq,
|
||||
event: Arc::new(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(id: &str, name: &str) -> ToolCall {
|
||||
ToolCall {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
kind: "execute".into(),
|
||||
args_preview: "ls".into(),
|
||||
started_at: Utc::now(),
|
||||
parent_tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prompt_creates_row() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(1, Event::UserPromptSent { text: "hi".into() }));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
match &t.rows[0] {
|
||||
ActivityRow::UserPrompt(text) => assert_eq!(text, "hi"),
|
||||
_ => panic!("expected UserPrompt"),
|
||||
}
|
||||
assert_eq!(t.last_seq, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_accumulate_into_single_row() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(
|
||||
1,
|
||||
Event::AgentMessageChunk {
|
||||
text: "Hello".into(),
|
||||
},
|
||||
));
|
||||
t.apply(&frame(
|
||||
2,
|
||||
Event::AgentMessageChunk {
|
||||
text: ", world!".into(),
|
||||
},
|
||||
));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
match &t.rows[0] {
|
||||
ActivityRow::AgentMessage(text) => assert_eq!(text, "Hello, world!"),
|
||||
_ => panic!("expected AgentMessage"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_chunk_event_breaks_message_grouping() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(
|
||||
1,
|
||||
Event::AgentMessageChunk {
|
||||
text: "First".into(),
|
||||
},
|
||||
));
|
||||
t.apply(&frame(2, Event::ThinkingStarted));
|
||||
t.apply(&frame(
|
||||
3,
|
||||
Event::AgentMessageChunk {
|
||||
text: "Second".into(),
|
||||
},
|
||||
));
|
||||
// First and Second land in distinct AgentMessage rows.
|
||||
let messages: Vec<&str> = t
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
ActivityRow::AgentMessage(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(messages, vec!["First", "Second"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_completion_mutates_existing_row() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(
|
||||
1,
|
||||
Event::ToolCallStarted {
|
||||
tool_call: tool("t-1", "Bash"),
|
||||
},
|
||||
));
|
||||
t.apply(&frame(
|
||||
2,
|
||||
Event::ToolCallCompleted {
|
||||
tool_call_id: "t-1".into(),
|
||||
is_error: false,
|
||||
content: "ok".into(),
|
||||
completed_at: Utc::now(),
|
||||
},
|
||||
));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
match &t.rows[0] {
|
||||
ActivityRow::ToolCall(row) => {
|
||||
let c = row.completed.as_ref().expect("completed");
|
||||
assert!(c.ok);
|
||||
assert_eq!(c.content, "ok");
|
||||
}
|
||||
_ => panic!("expected ToolCall"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_request_and_resolution() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
let approval = Approval {
|
||||
nonce: Nonce("nonce-1".into()),
|
||||
tool_call: tool("t-1", "Bash"),
|
||||
destructive: true,
|
||||
requested_at: Utc::now(),
|
||||
resolved: None,
|
||||
};
|
||||
t.apply(&frame(1, Event::ApprovalRequested { approval }));
|
||||
assert_eq!(t.pending_approvals.len(), 1);
|
||||
assert_eq!(t.pending_approvals[0].nonce, "nonce-1");
|
||||
t.apply(&frame(
|
||||
2,
|
||||
Event::ApprovalResolved {
|
||||
nonce: Nonce("nonce-1".into()),
|
||||
decision: ApprovalDecision::Allow,
|
||||
},
|
||||
));
|
||||
assert!(t.pending_approvals.is_empty());
|
||||
match &t.rows[0] {
|
||||
ActivityRow::Approval(row) => {
|
||||
assert_eq!(row.decision, Some(ApprovalDecision::Allow));
|
||||
}
|
||||
_ => panic!("expected Approval"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_seq_is_ignored() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(1, Event::UserPromptSent { text: "hi".into() }));
|
||||
// Replay-vs-live overlap can deliver the same seq twice; the
|
||||
// reducer must dedupe.
|
||||
t.apply(&frame(
|
||||
1,
|
||||
Event::UserPromptSent {
|
||||
text: "ignored".into(),
|
||||
},
|
||||
));
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_context_reset_sets_pending_primer_flag() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(
|
||||
1,
|
||||
Event::SessionContextReset {
|
||||
reason: "session/load failed".into(),
|
||||
},
|
||||
));
|
||||
assert!(t.context_primer_pending);
|
||||
// Sending a prompt clears the hint.
|
||||
t.apply(&frame(2, Event::UserPromptSent { text: "go".into() }));
|
||||
assert!(!t.context_primer_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_commands_stored_for_future_slash_picker() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(
|
||||
1,
|
||||
Event::AvailableCommandsUpdated {
|
||||
commands: vec![AvailableCommand {
|
||||
name: "test".into(),
|
||||
description: "run tests".into(),
|
||||
accepts_input: false,
|
||||
}],
|
||||
},
|
||||
));
|
||||
assert_eq!(t.available_commands.len(), 1);
|
||||
assert_eq!(t.available_commands[0].name, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_update_creates_plan_row() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
let plan = Plan {
|
||||
plan_id: "p-1".into(),
|
||||
version: 1,
|
||||
steps: vec![PlanStep {
|
||||
id: "s-1".into(),
|
||||
title: "Step one".into(),
|
||||
detail: None,
|
||||
status: PlanStepStatus::Pending,
|
||||
}],
|
||||
};
|
||||
t.apply(&frame(1, Event::PlanUpdated { plan }));
|
||||
assert!(matches!(&t.rows[0], ActivityRow::Plan(lines) if lines.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_lagged_records_a_warning() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.set_lagged();
|
||||
assert!(t.lagged);
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
match &t.rows[0] {
|
||||
ActivityRow::Note {
|
||||
kind: NoteKind::Warning,
|
||||
..
|
||||
} => {}
|
||||
_ => panic!("expected warning note"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state_but_preserves_session_id() {
|
||||
let mut t = CockpitTranscript::new("s-1");
|
||||
t.apply(&frame(1, Event::UserPromptSent { text: "hi".into() }));
|
||||
t.reset();
|
||||
assert_eq!(t.session_id, "s-1");
|
||||
assert_eq!(t.last_seq, 0);
|
||||
assert!(t.rows.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Three-pane render of a cockpit session: transcript / status banner /
|
||||
//! composer. Tool-card breakdowns are intentionally minimal in the MVP
|
||||
//! (one-liner per tool call); rich diff / image / file previews are
|
||||
//! deferred to the followup issues called out in the implementation
|
||||
//! plan. Press `o` from the transcript pane to open the web cockpit
|
||||
//! for full-fidelity inspection.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::input::Focus;
|
||||
use super::reducer::{ActivityRow, CockpitTranscript, NoteKind, ToolCallRow};
|
||||
use super::state::CockpitViewState;
|
||||
use crate::cockpit::approvals::ApprovalDecision;
|
||||
use crate::tui::styles::Theme;
|
||||
|
||||
pub fn render(frame: &mut Frame, area: Rect, theme: &Theme, state: &CockpitViewState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(5), // transcript
|
||||
Constraint::Length(1), // status line
|
||||
Constraint::Length(composer_height(state)),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_transcript(frame, chunks[0], theme, state);
|
||||
render_status(frame, chunks[1], theme, state);
|
||||
render_composer(frame, chunks[2], theme, state);
|
||||
}
|
||||
|
||||
/// Top + bottom border rows wrapping the composer textarea.
|
||||
const COMPOSER_BORDER_ROWS: u16 = 2;
|
||||
/// Maximum content rows the composer is allowed to take before the
|
||||
/// transcript starts losing space. Multi-line prompts beyond this
|
||||
/// scroll inside the textarea instead of growing the pane.
|
||||
const COMPOSER_MAX_CONTENT_ROWS: u16 = 6;
|
||||
|
||||
fn composer_height(state: &CockpitViewState) -> u16 {
|
||||
// Composer is `1 + COMPOSER_BORDER_ROWS = 3` rows tall by default,
|
||||
// growing one row per typed newline up to
|
||||
// `COMPOSER_MAX_CONTENT_ROWS + COMPOSER_BORDER_ROWS = 8` rows so
|
||||
// multi-line prompts don't squash the transcript.
|
||||
let lines = state.composer.lines().len().max(1) as u16;
|
||||
lines.clamp(1, COMPOSER_MAX_CONTENT_ROWS) + COMPOSER_BORDER_ROWS
|
||||
}
|
||||
|
||||
fn render_transcript(frame: &mut Frame, area: Rect, theme: &Theme, state: &CockpitViewState) {
|
||||
let title = format!(
|
||||
" Cockpit · {}{} ",
|
||||
state.session_id,
|
||||
match state.transcript.current_mode.as_deref() {
|
||||
Some(m) => format!(" · mode: {m}"),
|
||||
None => String::new(),
|
||||
}
|
||||
);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style(theme, state, Focus::Transcript));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let lines = transcript_lines(&state.transcript, state.selected_approval, state.focus);
|
||||
// Clamp scroll against the *wrapped* visual row count, not
|
||||
// `lines.len()`. Streaming `AgentMessage` rows grew text inside
|
||||
// a single logical line: Paragraph's wrap inflated the
|
||||
// rendered row count while `lines.len()` stayed constant, so
|
||||
// `state.scroll_offset = u16::MAX` (stick to bottom) clipped
|
||||
// short of the newest chunk. Tool calls didn't show the bug
|
||||
// because each call adds whole new Line entries.
|
||||
let total = visual_line_count(&lines, inner.width);
|
||||
let max = total.saturating_sub(inner.height);
|
||||
let scroll = (state.scroll_offset.min(max), 0);
|
||||
let para = Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll(scroll);
|
||||
frame.render_widget(para, inner);
|
||||
}
|
||||
|
||||
/// Estimate the number of terminal rows `lines` will occupy when
|
||||
/// rendered into a paragraph of width `width`. Each `Line`'s display
|
||||
/// width divided by the available columns, rounded up, summed. Used
|
||||
/// to keep `scroll_offset = u16::MAX` pinned to the bottom as
|
||||
/// streaming chunks grow inside a single logical line.
|
||||
fn visual_line_count(lines: &[Line], width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return lines.len() as u16;
|
||||
}
|
||||
let w = width as usize;
|
||||
let mut total: usize = 0;
|
||||
for line in lines {
|
||||
let lw = line.width().max(1);
|
||||
total = total.saturating_add(lw.div_ceil(w));
|
||||
}
|
||||
total.min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame, area: Rect, theme: &Theme, state: &CockpitViewState) {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
if let Some(toast) = &state.toast {
|
||||
let color = match toast.kind {
|
||||
super::state::ToastKind::Info => theme.title,
|
||||
super::state::ToastKind::Error => theme.error,
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", toast.text),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
if let Some(banner) = &state.transcript.status_text {
|
||||
spans.push(Span::styled(
|
||||
format!(" {banner} "),
|
||||
Style::default().fg(theme.title),
|
||||
));
|
||||
}
|
||||
if state.transcript.context_primer_pending {
|
||||
spans.push(Span::styled(
|
||||
" context lost; next prompt re-primes ",
|
||||
Style::default().fg(theme.error),
|
||||
));
|
||||
}
|
||||
if state.transcript.lagged {
|
||||
spans.push(Span::styled(
|
||||
" broadcast lagged; refetching ",
|
||||
Style::default().fg(theme.error),
|
||||
));
|
||||
}
|
||||
if !state.transcript.pending_approvals.is_empty() {
|
||||
let n = state.transcript.pending_approvals.len();
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
" {n} pending approval{}; Tab to focus ",
|
||||
if n == 1 { "" } else { "s" }
|
||||
),
|
||||
Style::default().fg(theme.error),
|
||||
));
|
||||
}
|
||||
if spans.is_empty() {
|
||||
// Footer help when nothing else is going on.
|
||||
spans.push(Span::styled(
|
||||
help_hint(state.focus),
|
||||
Style::default().fg(theme.hint),
|
||||
));
|
||||
}
|
||||
let para = Paragraph::new(Line::from(spans));
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_composer(frame: &mut Frame, area: Rect, theme: &Theme, state: &CockpitViewState) {
|
||||
let title = match state.focus {
|
||||
Focus::Composer => " Composer (Enter=send, Shift+Enter=newline, Esc=back) ",
|
||||
_ => " Composer (Tab/i to focus) ",
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style(theme, state, Focus::Composer));
|
||||
// ratatui-textarea borrows the Frame's buffer indirectly via
|
||||
// widget impl; render the block first, then the textarea inside.
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(&state.composer, inner);
|
||||
}
|
||||
|
||||
fn transcript_lines<'a>(
|
||||
transcript: &'a CockpitTranscript,
|
||||
selected_approval: Option<usize>,
|
||||
focus: Focus,
|
||||
) -> Vec<Line<'a>> {
|
||||
let mut out: Vec<Line<'a>> = Vec::new();
|
||||
let mut approval_render_idx: usize = 0;
|
||||
for row in &transcript.rows {
|
||||
match row {
|
||||
ActivityRow::UserPrompt(text) => {
|
||||
out.push(Line::from(Span::styled(
|
||||
format!("you ▸ {text}"),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
out.push(Line::default());
|
||||
}
|
||||
ActivityRow::AgentMessage(text) => {
|
||||
for chunk_line in text.lines() {
|
||||
out.push(Line::from(format!("aoe {chunk_line}")));
|
||||
}
|
||||
if text.is_empty() {
|
||||
out.push(Line::from("aoe …"));
|
||||
}
|
||||
out.push(Line::default());
|
||||
}
|
||||
ActivityRow::ToolCall(tool) => {
|
||||
out.extend(render_tool_lines(tool));
|
||||
out.push(Line::default());
|
||||
}
|
||||
ActivityRow::Approval(row) => {
|
||||
let highlighted = focus == Focus::Approval
|
||||
&& selected_approval
|
||||
.map(|i| i == approval_render_idx)
|
||||
.unwrap_or(false);
|
||||
approval_render_idx += 1;
|
||||
let mut header = Vec::new();
|
||||
header.push(Span::raw(if highlighted { "▶ " } else { " " }));
|
||||
header.push(Span::styled(
|
||||
format!("approval · {} ", row.title),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if row.destructive {
|
||||
header.push(Span::styled(
|
||||
"[destructive] ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
));
|
||||
}
|
||||
header.push(Span::styled(
|
||||
format!("nonce={}", row.nonce),
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
));
|
||||
out.push(Line::from(header));
|
||||
let body = match row.decision {
|
||||
Some(ApprovalDecision::Allow) => " → allowed",
|
||||
Some(ApprovalDecision::AllowAlways) => " → allow-always",
|
||||
Some(ApprovalDecision::Deny) => " → denied",
|
||||
None => " press a / A / d to resolve, Esc to leave",
|
||||
};
|
||||
out.push(Line::from(body));
|
||||
out.push(Line::default());
|
||||
}
|
||||
ActivityRow::Plan(steps) => {
|
||||
out.push(Line::from(Span::styled(
|
||||
"plan",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for step in steps {
|
||||
let marker = match step.status {
|
||||
crate::cockpit::state::PlanStepStatus::Pending => "[ ]",
|
||||
crate::cockpit::state::PlanStepStatus::InProgress => "[~]",
|
||||
crate::cockpit::state::PlanStepStatus::Done => "[x]",
|
||||
crate::cockpit::state::PlanStepStatus::Cancelled => "[-]",
|
||||
};
|
||||
out.push(Line::from(format!(" {marker} {}", step.title)));
|
||||
}
|
||||
out.push(Line::default());
|
||||
}
|
||||
ActivityRow::Note { kind, text } => {
|
||||
let modifier = match kind {
|
||||
NoteKind::Info => Modifier::DIM,
|
||||
NoteKind::Warning => Modifier::BOLD,
|
||||
NoteKind::Error => Modifier::BOLD,
|
||||
};
|
||||
out.push(Line::from(Span::styled(
|
||||
format!("· {text}"),
|
||||
Style::default().add_modifier(modifier),
|
||||
)));
|
||||
out.push(Line::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(Line::from(Span::styled(
|
||||
"(no events yet, waiting for the agent…)",
|
||||
Style::default().add_modifier(Modifier::DIM),
|
||||
)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return the first `max_chars` characters of `s`, or `None` if `s`
|
||||
/// is already short enough. Char-safe so an LLM response that places a
|
||||
/// multi-byte codepoint at the truncation boundary doesn't panic the
|
||||
/// TUI (byte-slicing `&s[..N]` would).
|
||||
fn truncate_chars(s: &str, max_chars: usize) -> Option<String> {
|
||||
let mut iter = s.char_indices();
|
||||
if let Some((byte_idx, _)) = iter.nth(max_chars) {
|
||||
Some(s[..byte_idx].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tool_lines(tool: &ToolCallRow) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
let header = format!(
|
||||
"tool {} · {}",
|
||||
match tool.completed.as_ref() {
|
||||
None => "▶",
|
||||
Some(c) if c.ok => "✓",
|
||||
Some(_) => "✗",
|
||||
},
|
||||
tool.name
|
||||
);
|
||||
lines.push(Line::from(Span::styled(
|
||||
header,
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
if !tool.args.is_empty() {
|
||||
let truncated = match truncate_chars(&tool.args, 200) {
|
||||
Some(head) => format!(" $ {head}…"),
|
||||
None => format!(" $ {}", tool.args),
|
||||
};
|
||||
lines.push(Line::from(truncated));
|
||||
}
|
||||
if let Some(completion) = &tool.completed {
|
||||
let content = if completion.content.is_empty() {
|
||||
if completion.ok {
|
||||
" (no output)".to_string()
|
||||
} else {
|
||||
" (tool failed; press `o` for details)".to_string()
|
||||
}
|
||||
} else if let Some(head) = truncate_chars(&completion.content, 400) {
|
||||
format!(" {head}…\n (output truncated; press `o` for full)")
|
||||
} else {
|
||||
completion
|
||||
.content
|
||||
.lines()
|
||||
.map(|l| format!(" {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
for line in content.lines() {
|
||||
lines.push(Line::from(line.to_string()));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn border_style(theme: &Theme, state: &CockpitViewState, this_focus: Focus) -> Style {
|
||||
if state.focus == this_focus {
|
||||
Style::default().fg(theme.title)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
}
|
||||
}
|
||||
|
||||
fn help_hint(focus: Focus) -> &'static str {
|
||||
match focus {
|
||||
Focus::Composer => " Enter=send · Shift+Enter=newline · Esc=back · Ctrl-C=cancel ",
|
||||
Focus::Transcript => " j/k=scroll · i=compose · Tab=approvals · o=browser · Esc=exit ",
|
||||
Focus::Approval => " a=allow · A=always · d=deny · Esc=back ",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn visual_line_count_counts_wrapped_rows() {
|
||||
// 40 chars at width 10 wraps to 4 visual rows.
|
||||
let lines = vec![Line::from("a".repeat(40))];
|
||||
assert_eq!(visual_line_count(&lines, 10), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_line_count_floors_empty_line_to_one() {
|
||||
// A logical empty line still occupies one row.
|
||||
let lines = vec![Line::default()];
|
||||
assert_eq!(visual_line_count(&lines, 10), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_line_count_handles_zero_width() {
|
||||
// Degenerate area (e.g. during teardown); fall back to logical
|
||||
// line count so we don't divide by zero.
|
||||
let lines = vec![Line::from("x"), Line::from("y")];
|
||||
assert_eq!(visual_line_count(&lines, 0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_line_count_streaming_growth_advances_max_scroll() {
|
||||
// Regression for the agent-message auto-scroll bug: as a
|
||||
// single logical line grows, the visual row count must
|
||||
// grow so `scroll_offset = u16::MAX` keeps tracking the
|
||||
// bottom.
|
||||
let short = vec![Line::from("a".repeat(20))];
|
||||
let long = vec![Line::from("a".repeat(200))];
|
||||
assert!(visual_line_count(&long, 40) > visual_line_count(&short, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_returns_none_when_already_short() {
|
||||
assert_eq!(truncate_chars("hi", 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_respects_utf8_codepoint_boundaries() {
|
||||
// Regression for the byte-slice panic: a 4-byte codepoint
|
||||
// straddling the requested byte boundary used to crash the
|
||||
// TUI with `byte index N is not a char boundary`.
|
||||
// 3 ASCII + 4-byte emoji (U+1F600) repeated; ask for 4 chars.
|
||||
let s = "abc😀def😀ghi😀";
|
||||
let head = truncate_chars(s, 4).expect("longer than 4 chars");
|
||||
assert_eq!(head, "abc😀");
|
||||
assert!(s.chars().count() > 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_handles_pure_multibyte_input() {
|
||||
// Pure non-ASCII (CJK ideographs are 3 bytes each in UTF-8).
|
||||
let s = "日本語のテスト";
|
||||
let head = truncate_chars(s, 3).expect("longer than 3 chars");
|
||||
assert_eq!(head, "日本語");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Owned state for an open cockpit view: the focus, the reducer-
|
||||
//! produced transcript, the composer text, and the websocket handle.
|
||||
//! All side-effects (HTTP requests, browser opens, focus changes)
|
||||
//! happen from [`super::mod`]'s async loop; this struct stays a plain
|
||||
//! POD so the render layer can borrow it freely.
|
||||
|
||||
use ratatui_textarea::TextArea;
|
||||
|
||||
use super::input::Focus;
|
||||
use super::reducer::CockpitTranscript;
|
||||
use crate::cockpit::client::{DaemonEndpoint, HttpClient, WsHandle};
|
||||
|
||||
pub struct CockpitViewState {
|
||||
pub session_id: String,
|
||||
pub endpoint: DaemonEndpoint,
|
||||
pub http: HttpClient,
|
||||
pub transcript: CockpitTranscript,
|
||||
pub composer: TextArea<'static>,
|
||||
pub focus: Focus,
|
||||
pub scroll_offset: u16,
|
||||
/// Index into `transcript.pending_approvals` for the highlighted
|
||||
/// approval card when focus is `Approval`. None when the list is
|
||||
/// empty.
|
||||
pub selected_approval: Option<usize>,
|
||||
pub ws: Option<WsHandle>,
|
||||
/// Toast banner that appears briefly above the composer, e.g.
|
||||
/// "prompt sent" or an HTTP error.
|
||||
pub toast: Option<ToastBanner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToastBanner {
|
||||
pub text: String,
|
||||
pub kind: ToastKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl CockpitViewState {
|
||||
pub fn new(
|
||||
session_id: String,
|
||||
endpoint: DaemonEndpoint,
|
||||
http: HttpClient,
|
||||
ws: Option<WsHandle>,
|
||||
) -> Self {
|
||||
let mut composer = TextArea::default();
|
||||
composer.set_placeholder_text(" Message the agent…");
|
||||
composer.set_cursor_line_style(ratatui::style::Style::default());
|
||||
Self {
|
||||
transcript: CockpitTranscript::new(session_id.clone()),
|
||||
session_id,
|
||||
endpoint,
|
||||
http,
|
||||
composer,
|
||||
focus: Focus::Transcript,
|
||||
scroll_offset: u16::MAX, // stick to bottom by default; render clamps to last row
|
||||
selected_approval: None,
|
||||
ws,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the composer's current text and clear it so the user can
|
||||
/// start the next prompt.
|
||||
pub fn take_composer_text(&mut self) -> String {
|
||||
let text = self.composer.lines().join("\n").trim().to_string();
|
||||
// Replace with a fresh textarea so cursor + selection state
|
||||
// also reset; ratatui-textarea has no public "clear" today.
|
||||
let mut next = TextArea::default();
|
||||
next.set_placeholder_text(" Message the agent…");
|
||||
next.set_cursor_line_style(ratatui::style::Style::default());
|
||||
self.composer = next;
|
||||
text
|
||||
}
|
||||
|
||||
/// Bring the selected-approval index back into bounds whenever the
|
||||
/// pending list changes underneath us (a resolution removed one,
|
||||
/// a new request added one, etc.).
|
||||
pub fn reconcile_selection(&mut self) {
|
||||
let len = self.transcript.pending_approvals.len();
|
||||
if len == 0 {
|
||||
self.selected_approval = None;
|
||||
if matches!(self.focus, Focus::Approval) {
|
||||
self.focus = Focus::Transcript;
|
||||
}
|
||||
return;
|
||||
}
|
||||
match self.selected_approval {
|
||||
Some(i) if i >= len => self.selected_approval = Some(len - 1),
|
||||
None => self.selected_approval = Some(0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -1093,10 +1093,17 @@ impl HomeView {
|
||||
return None;
|
||||
}
|
||||
if inst.is_cockpit_mode() {
|
||||
return Some(Action::SetTransientStatus(
|
||||
"Cockpit session: open the web dashboard (aoe serve) to attach"
|
||||
.to_string(),
|
||||
));
|
||||
#[cfg(feature = "serve")]
|
||||
{
|
||||
return Some(Action::OpenCockpit(id.clone()));
|
||||
}
|
||||
#[cfg(not(feature = "serve"))]
|
||||
{
|
||||
return Some(Action::SetTransientStatus(
|
||||
"Cockpit session: rebuild with --features serve to attach"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return match self.view_mode {
|
||||
|
||||
@@ -647,7 +647,10 @@ impl HomeView {
|
||||
// the host terminal still works against the worktree.
|
||||
let badge_text: Option<&'static str> =
|
||||
if inst.is_cockpit_mode() && self.view_mode != ViewMode::Terminal {
|
||||
Some(" [web]")
|
||||
// Renamed from `[web]` now that the TUI renders
|
||||
// cockpit sessions natively; `[cockpit]` better
|
||||
// describes the substrate the badge marks.
|
||||
Some(" [cockpit]")
|
||||
} else if self.view_mode == ViewMode::Terminal && inst.is_sandboxed() {
|
||||
Some(match self.get_terminal_mode(id) {
|
||||
TerminalMode::Container => " [container]",
|
||||
|
||||
@@ -358,7 +358,7 @@ fn test_enter_on_session_returns_attach_action() {
|
||||
#[cfg(feature = "serve")]
|
||||
#[test]
|
||||
#[serial]
|
||||
fn test_enter_on_cockpit_session_returns_toast() {
|
||||
fn test_enter_on_cockpit_session_opens_cockpit_view() {
|
||||
use crate::session::config::GroupByMode;
|
||||
let temp = TempDir::new().unwrap();
|
||||
setup_test_home(&temp);
|
||||
@@ -380,17 +380,14 @@ fn test_enter_on_cockpit_session_returns_toast() {
|
||||
|
||||
let action = view.handle_key(key(KeyCode::Enter), None);
|
||||
match action {
|
||||
Some(Action::SetTransientStatus(msg)) => {
|
||||
Some(Action::OpenCockpit(id)) => {
|
||||
// Should target the cockpit instance, not the plain ones.
|
||||
assert!(
|
||||
msg.to_lowercase().contains("cockpit"),
|
||||
"toast should mention cockpit, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("dashboard") || msg.contains("aoe serve"),
|
||||
"toast should point at the dashboard, got: {msg}"
|
||||
id.contains("cockpit") || !id.is_empty(),
|
||||
"OpenCockpit carried an empty session id"
|
||||
);
|
||||
}
|
||||
other => panic!("expected SetTransientStatus toast for cockpit session, got {other:?}"),
|
||||
other => panic!("expected Action::OpenCockpit for cockpit session, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
//! Terminal User Interface module
|
||||
|
||||
mod app;
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod cockpit_view;
|
||||
mod components;
|
||||
mod creation_poller;
|
||||
mod deletion_poller;
|
||||
pub mod dialogs;
|
||||
pub mod diff;
|
||||
mod home;
|
||||
#[cfg(feature = "serve")]
|
||||
pub(crate) mod remote_home;
|
||||
pub(crate) mod responsive;
|
||||
pub mod settings;
|
||||
mod status_poller;
|
||||
@@ -28,6 +32,18 @@ use crate::session::get_update_settings;
|
||||
use crate::update::check_for_update;
|
||||
|
||||
pub async fn run(profile: &str, startup_warning: Option<String>) -> Result<()> {
|
||||
// Cross-machine entrypoint: when `AOE_DAEMON_URL` is set, swap the
|
||||
// local home view for the remote cockpit picker so the user never
|
||||
// sees a session list that doesn't reflect the daemon they pointed
|
||||
// us at. Tmux check + migrations are intentionally skipped here:
|
||||
// the remote machine owns those, this side is a pure client.
|
||||
#[cfg(feature = "serve")]
|
||||
if let Some(endpoint) = crate::cockpit::client::discovery::discover_env() {
|
||||
let _ = startup_warning; // remote mode skips the local startup-warning channel
|
||||
let _ = profile;
|
||||
return remote_home::run_standalone(endpoint).await;
|
||||
}
|
||||
|
||||
// Run pending migrations with a spinner so users see progress
|
||||
if migrations::has_pending_migrations() {
|
||||
const SPINNER_FRAMES: &[char] = &['◐', '◓', '◑', '◒'];
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Remote home screen for cross-machine cockpit attach.
|
||||
//!
|
||||
//! Activated when `AOE_DAEMON_URL` is set at startup (or `--daemon-url`
|
||||
//! is passed on the CLI). Fetches the daemon's session list via
|
||||
//! `GET /api/sessions`, filters to cockpit-mode sessions (the only
|
||||
//! kind that's meaningful to drive cross-machine; tmux PTYs can't be
|
||||
//! attached remotely without SSH'ing into the host first), and lets
|
||||
//! the user open one with Enter.
|
||||
//!
|
||||
//! Local-only operations are absent rather than disabled: a remote
|
||||
//! session can't be `tmux attach`-ed from this machine, can't run
|
||||
//! `aoe stop`, can't have its files edited locally. The web dashboard
|
||||
//! covers the long-tail of remote management; this view's only job is
|
||||
//! to be a fast lane into the cockpit transcript + composer for a
|
||||
//! known remote session.
|
||||
|
||||
mod render;
|
||||
|
||||
use std::io::Stdout;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{Event as CrosstermEvent, EventStream, KeyCode, KeyEventKind};
|
||||
use futures_util::StreamExt;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::cockpit::client::discovery::DaemonEndpoint;
|
||||
use crate::cockpit::client::HttpClient;
|
||||
use crate::tui::styles::Theme;
|
||||
|
||||
/// Subset of `/api/sessions`'s `SessionResponse` we need. `serde` skips
|
||||
/// unknown fields by default; we capture only the columns the remote
|
||||
/// picker renders, so server-side additions don't break clients.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RemoteSession {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub project_path: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
/// Only present in builds compiled with `--features serve`.
|
||||
/// Default `false` so an older daemon's response (pre-cockpit)
|
||||
/// still deserialises.
|
||||
#[serde(default)]
|
||||
pub cockpit_mode: bool,
|
||||
}
|
||||
|
||||
pub struct RemoteHomeState {
|
||||
pub endpoint: DaemonEndpoint,
|
||||
pub sessions: Vec<RemoteSession>,
|
||||
pub cursor: usize,
|
||||
pub status_text: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
pub loading: bool,
|
||||
}
|
||||
|
||||
impl RemoteHomeState {
|
||||
pub fn new(endpoint: DaemonEndpoint) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
sessions: Vec::new(),
|
||||
cursor: 0,
|
||||
status_text: None,
|
||||
last_error: None,
|
||||
loading: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_cursor(&mut self, delta: i32) {
|
||||
let len = self.sessions.len();
|
||||
if len == 0 {
|
||||
self.cursor = 0;
|
||||
return;
|
||||
}
|
||||
let cur = self.cursor as i32;
|
||||
let next = (cur + delta).rem_euclid(len as i32);
|
||||
self.cursor = next as usize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up alternate-screen terminal, run the remote home loop, tear it
|
||||
/// down. Invoked from `tui::run` when `AOE_DAEMON_URL` is set.
|
||||
pub async fn run_standalone(endpoint: DaemonEndpoint) -> Result<()> {
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use std::io;
|
||||
use std::io::IsTerminal;
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
anyhow::bail!("stdin is not a terminal; `aoe` needs an interactive TTY");
|
||||
}
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(
|
||||
stdout,
|
||||
EnterAlternateScreen,
|
||||
EnableBracketedPaste,
|
||||
EnableMouseCapture
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let mut event_stream = EventStream::new();
|
||||
let theme = crate::tui::styles::load_theme_with_mode("empire", false);
|
||||
|
||||
let result = run(&mut terminal, &mut event_stream, &theme, endpoint).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
event_stream: &mut EventStream,
|
||||
theme: &Theme,
|
||||
endpoint: DaemonEndpoint,
|
||||
) -> Result<()> {
|
||||
let mut state = RemoteHomeState::new(endpoint);
|
||||
refresh(&mut state).await;
|
||||
terminal.draw(|f| render::render(f, f.area(), theme, &state))?;
|
||||
|
||||
while let Some(evt) = event_stream.next().await {
|
||||
let Ok(evt) = evt else { return Ok(()) };
|
||||
let CrosstermEvent::Key(key) = evt else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||
KeyCode::Char('r') => {
|
||||
state.loading = true;
|
||||
state.status_text = Some("refreshing…".to_string());
|
||||
terminal.draw(|f| render::render(f, f.area(), theme, &state))?;
|
||||
refresh(&mut state).await;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
|
||||
KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
|
||||
KeyCode::Enter => {
|
||||
if let Some(session) = state.sessions.get(state.cursor).cloned() {
|
||||
// Hand off to the cockpit view. Local-only actions
|
||||
// are out of scope by design; tmux PTYs, file edits,
|
||||
// and the like aren't reachable on this machine.
|
||||
let endpoint = state.endpoint.clone();
|
||||
super::cockpit_view::run_for_endpoint(
|
||||
terminal,
|
||||
event_stream,
|
||||
theme,
|
||||
endpoint,
|
||||
&session.id,
|
||||
)
|
||||
.await?;
|
||||
terminal.clear()?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
terminal.draw(|f| render::render(f, f.area(), theme, &state))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh(state: &mut RemoteHomeState) {
|
||||
state.loading = true;
|
||||
state.last_error = None;
|
||||
let client = match HttpClient::new(state.endpoint.clone()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
state.loading = false;
|
||||
state.last_error = Some(format!("http client init failed: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match client.list_sessions::<RemoteSession>().await {
|
||||
Ok(sessions) => {
|
||||
// Only cockpit sessions are meaningful here: tmux sessions
|
||||
// can't be attached from another machine without SSH.
|
||||
let mut list: Vec<RemoteSession> =
|
||||
sessions.into_iter().filter(|s| s.cockpit_mode).collect();
|
||||
list.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
if state.cursor >= list.len() {
|
||||
state.cursor = list.len().saturating_sub(1);
|
||||
}
|
||||
state.sessions = list;
|
||||
state.status_text = Some(format!("{} session(s)", state.sessions.len()));
|
||||
}
|
||||
Err(e) => {
|
||||
state.last_error = Some(format!("{e}"));
|
||||
state.status_text = None;
|
||||
}
|
||||
}
|
||||
state.loading = false;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Render the remote-home session picker.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use super::RemoteHomeState;
|
||||
use crate::tui::styles::Theme;
|
||||
|
||||
pub fn render(frame: &mut Frame, area: Rect, theme: &Theme, state: &RemoteHomeState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(area);
|
||||
render_header(frame, chunks[0], theme, state);
|
||||
render_list(frame, chunks[1], theme, state);
|
||||
render_footer(frame, chunks[2], theme, state);
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, area: Rect, theme: &Theme, state: &RemoteHomeState) {
|
||||
let spans = vec![
|
||||
Span::styled(
|
||||
" Remote cockpit · ",
|
||||
Style::default()
|
||||
.fg(theme.title)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
state.endpoint.base_url.clone(),
|
||||
Style::default().fg(theme.text),
|
||||
),
|
||||
Span::raw(" "),
|
||||
];
|
||||
let block = Block::default().borders(Borders::BOTTOM);
|
||||
let para = Paragraph::new(Line::from(spans)).block(block);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut Frame, area: Rect, theme: &Theme, state: &RemoteHomeState) {
|
||||
if let Some(err) = &state.last_error {
|
||||
let para = Paragraph::new(format!(
|
||||
"Could not reach daemon at {}:\n\n{}\n\nPress r to retry, q to quit.",
|
||||
state.endpoint.base_url, err
|
||||
))
|
||||
.style(Style::default().fg(theme.error));
|
||||
frame.render_widget(para, area);
|
||||
return;
|
||||
}
|
||||
if state.loading && state.sessions.is_empty() {
|
||||
let para = Paragraph::new("loading remote cockpit sessions…")
|
||||
.style(Style::default().fg(theme.hint));
|
||||
frame.render_widget(para, area);
|
||||
return;
|
||||
}
|
||||
if state.sessions.is_empty() {
|
||||
let para = Paragraph::new(
|
||||
"No cockpit sessions on this daemon.\n\nPress r to refresh, q to quit.\n\nCockpit sessions are created via `aoe add --cockpit` on the host\n(or the web dashboard's New Session dialog).",
|
||||
)
|
||||
.style(Style::default().fg(theme.hint));
|
||||
frame.render_widget(para, area);
|
||||
return;
|
||||
}
|
||||
let items: Vec<ListItem> = state
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<24} ", truncate(&s.title, 24)),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!("{:<10} ", s.status),
|
||||
Style::default().fg(theme.hint),
|
||||
),
|
||||
Span::styled(s.project_path.clone(), Style::default().fg(theme.dimmed)),
|
||||
]);
|
||||
ListItem::new(line)
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items)
|
||||
.block(Block::default())
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(theme.session_selection)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("▸ ");
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(
|
||||
state.cursor.min(state.sessions.len().saturating_sub(1)),
|
||||
));
|
||||
frame.render_stateful_widget(list, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn render_footer(frame: &mut Frame, area: Rect, theme: &Theme, state: &RemoteHomeState) {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
if let Some(text) = &state.status_text {
|
||||
spans.push(Span::styled(
|
||||
format!(" {text} · "),
|
||||
Style::default().fg(theme.hint),
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
" j/k=navigate · Enter=open · r=refresh · q=quit ",
|
||||
Style::default().fg(theme.hint),
|
||||
));
|
||||
let block = Block::default().borders(Borders::TOP);
|
||||
let para = Paragraph::new(Line::from(spans)).block(block);
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let take = max.saturating_sub(1);
|
||||
let truncated: String = s.chars().take(take).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user