diff --git a/CHANGELOG.md b/CHANGELOG.md index cc452d8c..1d23635f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +- **Fleet dashboard pane (Phase C1).** The TUI consumes fleet SSE (`stream_id` + + `depth`) into an in-memory tree and paints a right-rail dashboard: depth + indent, agent, active tool, token totals, and a `◇` marker on reconcile JIT + workers. `Ctrl-w f` / click focuses the conversation pane bound to that + stream; Esc on a running row uses the existing in-flight cancel. Reconcile + `mode=ensure` waves show residual vs held plus the current wave in the same + pane. See [Fleet](docs/tui/fleet.md). + ## [0.13.10] — 2026-09-21 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b84556c..fc1dd658 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -353,9 +353,11 @@ target_sources(arbiter PRIVATE src/tui/sidebar.cpp src/tui/sidebar_format.cpp src/tui/history_sidebar.cpp + src/tui/fleet.cpp src/tui/menu.cpp src/tui/opentui/sidebar_frame.cpp src/tui/opentui/history_sidebar_frame.cpp + src/tui/opentui/fleet_frame.cpp src/tui/opentui/menu_frame.cpp src/tui/opentui/pane_frame.cpp ) @@ -921,6 +923,26 @@ if(INDEX_BUILD_TESTS) ) add_test(NAME unit_sidebar COMMAND unit_sidebar) + # Fleet tree state machine: SSE/event → rows, JIT teardown, tokens. + add_executable(unit_fleet + tests/test_fleet.cpp + src/tui/fleet.cpp + src/tui/sidebar_format.cpp + src/model_catalog.cpp + src/model_context.cpp + src/styled_text.cpp + src/theme.cpp + src/tui/tui_design.cpp + src/json.cpp + ) + target_include_directories(unit_fleet PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/third_party/doctest + ) + target_compile_definitions(unit_fleet PRIVATE + ARBITER_THEMES_DIR="${CMAKE_SOURCE_DIR}/themes") + add_test(NAME unit_fleet COMMAND unit_fleet) + add_executable(unit_todo_resolve tests/test_todo_resolve.cpp src/todo_resolve.cpp @@ -1848,7 +1870,6 @@ if(INDEX_BUILD_TESTS) ) add_test(NAME unit_remote_connect COMMAND unit_remote_connect) -<<<<<<< HEAD # JSON number grammar (incomplete frac/exp). Separate from #342's # unit_json (surrogate pairs in parse_string) so the two PRs do not # share tests/test_json.cpp or the CMake insert after unit_logger. @@ -1861,7 +1882,7 @@ if(INDEX_BUILD_TESTS) ${CMAKE_SOURCE_DIR}/third_party/doctest ) add_test(NAME unit_json_numbers COMMAND unit_json_numbers) -======= + # Unary --connect DELETE/PATCH body cap. Separate binary so # unit_remote_connect stays network-free; this one stands up a # loopback server against RemoteApiClient's one-shot curl handles. @@ -1882,7 +1903,6 @@ if(INDEX_BUILD_TESTS) Threads::Threads ) add_test(NAME unit_remote_http COMMAND unit_remote_http) ->>>>>>> origin/main # tui_design.cpp includes themes_data.h — wire every unit target that # compiles it to the embed generator (same header as the main binary). @@ -1902,6 +1922,7 @@ if(INDEX_BUILD_TESTS) unit_conversation_titling unit_message_replay_roundtrip unit_remote_connect + unit_fleet ) arbiter_use_embedded_themes(${_t}) endforeach() diff --git a/ROADMAP.md b/ROADMAP.md index 994182a8..c7b52094 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -59,16 +59,16 @@ calendar commitments. ### Phase 5 — Multi-agent mission control - [x] **Intent engine foundations-** Hybrid classify/route before dispatch (heuristic, then advisor-model LLM); seed slots for plans/todos; fail-open into index; `POST /v1/intent` + SSE `intent` event - [x] **Intent reconcile (Phase A)-** `POST /v1/reconcile`: compile `target_state` + invariants into a state contract, observe a bound workspace, require verification, optional snapshot rollback; `@arbiter/sdk` `IntentClient`; durable `reconcile_runs` + SSE -- [x] **JIT ΔS waves (Phase B / #208)-** `mode=ensure` wave loop: resolve `agent_map` / clause.agent / capability fallback, spawn ephemeral clones for active ΔS only, re-observe as success proof, teardown before the next wave; budgets `max_waves` / `max_wall_ms` / `max_agents_per_wave` / `max_retries_per_clause`; SSE `agent.spawned` / `agent.teardown`. Observe mode and `/v1/orchestrate` unchanged. Fleet UI remains Phase C +- [x] **JIT ΔS waves (Phase B / #208)-** `mode=ensure` wave loop: resolve `agent_map` / clause.agent / capability fallback, spawn ephemeral clones for active ΔS only, re-observe as success proof, teardown before the next wave; budgets `max_waves` / `max_wall_ms` / `max_agents_per_wave` / `max_retries_per_clause`; SSE `agent.spawned` / `agent.teardown`. Observe mode and `/v1/orchestrate` unchanged - [x] **Always-on presence-** Constitution `presence.mode: always_on`; a pair colleague looks over a peer's shoulder after each tool batch and may inject `[PRESENCE: …]` context. Fail-open; cannot halt. SSE `presence` + TUI `◎ presence`. Opposite lifetime of JIT (#208). -- [ ] **Fleet dashboard pane-** Live tree of depth, agent, tools, tokens; click-to-focus/Ctrl-W bindings +- [x] **Fleet dashboard pane-** Live tree of depth, agent, tools, tokens; click-to-focus / `Ctrl-w f` ([docs](docs/tui/fleet.md)) - [ ] **Plan to execution observability-** Planner plans as first-class objects with progress against todos - [ ] **Delegation policies-** Consitutions declare max depth, allowed callees, budget caps (tokens/$) - [ ] ~~**Workflow recipes-** Checked-in “crews” (JSON): ordered/parallel graphs of agents + shared todo board~~ - [ ] **Advisor policy packs-** Reusable gate profiles (strict / coding / research) **Acceptance criteria:** -- [ ] A user can watch and steer a 5-agent job. +- [x] A user can watch and steer a 5-agent job. ### Phase 6 — 1.0 - [ ] **Security defaults pass-** Safer TUI exec prompts; clearer sandbox docs; threat model refresh in SECURITY.md diff --git a/docs/concepts/fleet-streaming.md b/docs/concepts/fleet-streaming.md index ab61e630..5150da15 100644 --- a/docs/concepts/fleet-streaming.md +++ b/docs/concepts/fleet-streaming.md @@ -70,4 +70,5 @@ The master orchestrator's provider deltas stream live (same as sub-agents). Inco ## See also - [SSE event catalog](sse-events.md) +- [TUI fleet dashboard](../tui/fleet.md) - [`POST /v1/orchestrate`](../api/orchestrate.md) diff --git a/docs/concepts/sse-events.md b/docs/concepts/sse-events.md index 559f95c4..329fca14 100644 --- a/docs/concepts/sse-events.md +++ b/docs/concepts/sse-events.md @@ -61,6 +61,7 @@ Spec-compatible A2A clients hit [`POST /v1/a2a/agents/:id`](../api/a2a/dispatch. - [A2A protocol](a2a.md) — the Agent2Agent counterpart to this catalog. - [Fleet streaming](fleet-streaming.md) +- [TUI fleet dashboard](../tui/fleet.md) - [Advisor](advisor.md) — gate signal grammar, modes, redirect budget. - [Presence](presence.md) — always-on peer review and mid-turn injection. - [Intent](intent.md) — pre-dispatch classify/route. diff --git a/docs/tui/commands.md b/docs/tui/commands.md index c5126ab0..269bf0bf 100644 --- a/docs/tui/commands.md +++ b/docs/tui/commands.md @@ -42,6 +42,7 @@ Each pane is an independent conversation view. See [panes.md](panes.md) for the | `Ctrl-w c` | Close the focused pane. The pane's exec thread is joined cleanly; in-flight turn is cancelled. | | `Ctrl-w t` | Toggle the conversation-history sidebar (left rail). | | `Ctrl-w b` | Enter the sidebar to pick a prior conversation or start a new one. | +| `Ctrl-w f` | Show the fleet dashboard (live multi-agent tree) and enter it. | ## Background loops diff --git a/docs/tui/fleet.md b/docs/tui/fleet.md new file mode 100644 index 00000000..cc6a81bf --- /dev/null +++ b/docs/tui/fleet.md @@ -0,0 +1,74 @@ +# Fleet dashboard + +The fleet pane is a live tree of every agent turn in the current job — master, `/agent` / `/parallel` children, and reconcile JIT workers. It consumes the existing [fleet streaming](../concepts/fleet-streaming.md) SSE model (`stream_id` + `depth`); it is not a second orchestrator. + +Presence (`◎ presence`) is the opposite lifetime: residents stay in the room. Fleet rows are request-scoped and go away when the job ends (JIT clones disappear on `agent.teardown`). + +## When it appears + +The pane occupies the **right rail** (~24–28 columns) once a job has at least one stream, the same width breakpoint as the session sidebar (96 remaining columns). Unlike the session sidebar, it **stays up in multi-pane layouts** — that is when you need it. + +Idle sessions keep the rail free so conversation panes are not crowded. An empty Fleet box is not shown unless you force focus with `Ctrl-w f`. + +When both Fleet and Session want the rail (single pane, wide terminal, a job is live), Fleet stacks on top and Session sits below. + +## What each row shows + +``` +╭ Fleet ──────────────╮ +│ ensure · wave 2 │ +│ Δ 3 residual · 2 held│ +│ │ +│ ● index fetch│ +│ ├ ● researcher_a 1.2k│ +│ └ ◇ writer │ +╰─────────────────────╯ +``` + +- **Indent** follows `depth` (0 master, 1 delegated, 2 sub-sub). The depth cap is 2; the tree does not invent a third level. +- **Status:** `●` running, `✓` ok, `✗` failed, `○` opened but not yet started. +- **Name** of the agent. Reconcile JIT workers get a `◇` marker (ephemeral clone). +- **Active tool** while a `/cmd` is in flight; otherwise **token totals** for that stream. + +The overlay at the top is reconcile-only: `mode=ensure` residual vs held clause counts and the current wave, next to spawn/teardown rows. The pane does not run reconcile (that is still `POST /v1/reconcile`); it only displays events. + +## Steer / focus + +Selecting a row focuses the conversation pane bound to that stream so you can watch its output. Bindings follow the existing pane / sidebar conventions: + +| Input | Effect | +|-------|--------| +| `Ctrl-w f` | Show the fleet pane and enter it (second press hides). | +| Click a fleet row | Select that agent and focus its pane. | +| `↑` / `↓` (or `k` / `j`) | Move the selection (while fleet is focused). | +| `Enter` | Focus / open the pane bound to the selected stream. | +| `Esc` | On a **running** row: cancel that pane's in-flight turn (existing kill-switch). Otherwise leave fleet focus. | +| Wheel over the tree | Scroll the list. | + +`/parallel` children stream into the **host** pane (interim `→ agent` headers). Selecting them focuses that host — the dashboard does not auto-split a pane per child (that would drown the layout). `/pane` spawns already have their own leaf; selecting that agent focuses the spawned pane. + +Cancel does **not** invent a per-stream kill protocol. Esc maps to the same `cancel_pane_turn` path as interrupting a focused conversation pane (local `CancelToken` or remote `POST /v1/requests/:id/cancel`). + +## Event → tree + +| Event | Tree | +|-------|------| +| `stream_start` | Open a row (`stream_id`, `depth`, agent). | +| `agent.spawned` | Open a JIT row (`clone_id`, `◇`). | +| `agent_start` / `text` | Mark running. | +| `tool_call` | Set / clear the active tool. | +| `token_usage` | Add input + output on that stream. | +| `reconcile.delta` | Overlay: residual, held, wave. | +| `stream_end` | Mark ended; keep the row until the next job. | +| `agent.teardown` | Remove the JIT row immediately. | +| `done` / new `request_id` | End leftovers / clear the previous job. | + +See [SSE events](../concepts/sse-events.md) and [Fleet streaming](../concepts/fleet-streaming.md). + +## See also + +- [Panes](panes.md) — split / focus / `/pane` +- [Keybindings](keybindings.md) +- [Streaming](streaming.md) +- [Presence](../concepts/presence.md) — always-on residents +- [Reconcile](../concepts/reconcile.md) — JIT ΔS waves diff --git a/docs/tui/index.md b/docs/tui/index.md index c0d82ff9..383f4a3a 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -6,7 +6,7 @@ Rendering uses [OpenTUI](https://github.com/anomalyco/opentui) (native cell-buff A pane is to a conversation what a tab is to a browser. A new pane is a new conversation against an agent of your choosing; multiple panes run side-by-side or stacked, each independently typing, streaming, and waiting for its agent. Background loops (long-running agent processes) live alongside; foreground panes can spawn child panes (`/pane `) whose results land back in the spawner when done. -Start with `arbiter`. The default layout is a single pane covering the main terminal area. A **left-hand conversation sidebar** lists prior threads and lets you start a new one (`Ctrl-w b` to enter, `Ctrl-w t` to toggle visibility). A **right-hand sidebar** shows session usage, agent/model, todos, and recent tool activity when the terminal is wide enough (MCP appears only after MCP tools are used); `Ctrl-w s` toggles it. `Ctrl-w v` / `Ctrl-w h` split the main area. +Start with `arbiter`. The default layout is a single pane covering the main terminal area. A **left-hand conversation sidebar** lists prior threads and lets you start a new one (`Ctrl-w b` to enter, `Ctrl-w t` to toggle visibility). A **right-hand sidebar** shows session usage, agent/model, todos, and recent tool activity when the terminal is wide enough (MCP appears only after MCP tools are used); `Ctrl-w s` toggles it. A **fleet dashboard** (`Ctrl-w f`) occupies that same right rail while a multi-agent job is running — live tree of depth, agent, tool, and tokens; it stays visible with several conversation panes. `Ctrl-w v` / `Ctrl-w h` split the main area. ## Screen anatomy @@ -55,6 +55,7 @@ Token totals also appear in the pane header stats row (right side of row 1) when - **[Slash commands](commands.md)** — the full `/cmd` catalogue, grouped by category. - **[Keybindings](keybindings.md)** — every key, chord, and modifier the editor recognizes. - **[Panes](panes.md)** — multi-pane layouts: split, focus, close, `/pane` spawn semantics. +- **[Fleet](fleet.md)** — live multi-agent tree: depth, tools, tokens, click-to-focus. - **[Streaming](streaming.md)** — what you see during a turn: thinking spinner, tool-call indicator, verbose mode, cancellation. - **[Sessions](sessions.md)** — global conversations, autosave / mid-turn checkpoints, compaction. - **[Output UX](output-ux.md)** — tool timeline, thinking rows, permission cards, replay chrome. diff --git a/docs/tui/keybindings.md b/docs/tui/keybindings.md index 004daa28..34ef8284 100644 --- a/docs/tui/keybindings.md +++ b/docs/tui/keybindings.md @@ -12,6 +12,7 @@ Arbiter ships its own line editor. Bindings below are the complete set the edito | Wheel | Scroll pane / history list under the pointer | | Drag split gutter | Resize adjacent panes | | Left-click history row | Switch to that conversation | +| Left-click fleet row | Focus the pane bound to that stream | | Right sidebar | Ignored (display-only) | Disable with `"mouse": false` under `layout` in `~/.arbiter/tui.json`. @@ -85,9 +86,21 @@ cards. See [output-ux.md](output-ux.md). | `^W c` | Close the focused pane. Its exec thread is joined; in-flight turn is cancelled. Last remaining pane cannot be closed. | | `^W t` | Toggle the conversation-history sidebar (left rail). Preference is saved to `~/.arbiter/tui.json`. | | `^W b` | Enter the conversation sidebar for selection. Auto-shows the sidebar if hidden. Use `↑`/`↓` and `Enter`; `Esc` cancels. | +| `^W f` | Show the fleet dashboard and enter it. Second press hides it. Click a row or `Enter` to focus that stream's pane; `Esc` on a running row cancels that pane's turn. | If the chord byte doesn't match any of the above, it's silently dropped — the editor returns to the normal input state. `^W` followed by a regular character does *not* fall through to insertion; the chord window always consumes its byte. +## Fleet dashboard (when focused via `^W f` or a click) + +| Key | Action | +|-----------|--------| +| `↑` / `↓` | Move selection (`j` / `k` aliases). | +| `Enter` | Focus the conversation pane bound to that stream. | +| `Esc` | Cancel the selected in-flight turn if it is running; otherwise leave fleet focus. | +| `PgUp` / `PgDn` | Page the tree. | + +See [Fleet](fleet.md). + ## Conversation sidebar (when focused via `^W b`) | Key | Action | diff --git a/docs/tui/panes.md b/docs/tui/panes.md index 8f1b2ae2..20d3a7dd 100644 --- a/docs/tui/panes.md +++ b/docs/tui/panes.md @@ -103,6 +103,7 @@ When `layout.mouse` is enabled in `~/.arbiter/tui.json` (the default), the TUI e | Wheel over scrollback | Scroll that pane (does not steal keyboard focus) | | Left-click a conversation in the history sidebar | Select and switch to it | | Drag a split gutter | Resize the two adjacent panes asymmetrically | +| Left-click a fleet row | Focus the conversation pane bound to that stream | | Right sidebar | Display-only — clicks and wheel over it are ignored | Set `"layout": { "mouse": false }` to keep keyboard-only input (useful inside tmux without `set -g mouse on`, or when the host terminal fights with mouse capture). Text selection copy uses OSC 52; under tmux enable `set -g set-clipboard on` (or an equivalent clipboard passthrough). diff --git a/docs/tui/streaming.md b/docs/tui/streaming.md index 0741de3f..1a5bec16 100644 --- a/docs/tui/streaming.md +++ b/docs/tui/streaming.md @@ -26,7 +26,7 @@ This indicator runs in the output pump and updates at ~80 ms cadence. It clears Model deltas land in the scroll region as they arrive via `StreamRenderer`: `BlockParser` suppresses `/cmd` lines (unless `/verbose`) and forwards unambiguous incomplete prose immediately, `MarkdownRenderer` commits styled spans on newline and exposes a replaceable **live tail** for the in-progress line so tokens paint word-by-word, non-diff fenced code streams into `CodeSegment` (draw-time collapse — first eight body lines plus a live `… +N lines` summary, no placeholder artifacts in scrollback), and ` ```diff ` patches become `DiffSegment` panels (full-width for file adds/deletes; split for edits) with a `Patch #N` action line. Streamed patches **pause the model stream** on an interactive review card (↑↓/Enter, or `[a]`/`[r]`/`[A]`/Esc; PgUp/PgDn scrolls). Use [`/diff`](commands.md) for manual review of remaining pending patches, `/accept-edits` to keep auto-applying, or `/diff apply` to write immediately under the conversation workspace directory (creates missing files; exact hunk-header match; `/diff undo` restores when unchanged). Styling resolves from `tui.json`'s `content` tokens through `StyleId` → OpenTUI highlights — no ANSI round-trip on the master stream path. User echoes, advisor/loop/presence banners, and interrupt markers use the same prose path. Always-on `CONTEXT` notes paint as `◎ presence · `; `SILENT` reviews stay quiet. Vertical rhythm (`block_gap` / `panel_gap` / `prose_paragraph_gap`) and panel surfaces (`code_bg`, `diff_bg_*`) are theme tokens — see [themes.md](themes.md). -Sub-agent progress uses the same pipeline with `kInterim` policy: dimmed prose, collapsed fences, capped at eight display rows / 480 columns. The master's synthesis turn carries the full result. +Sub-agent progress uses the same pipeline with `kInterim` policy: dimmed prose, collapsed fences, capped at eight display rows / 480 columns. The master's synthesis turn carries the full result. The [fleet dashboard](fleet.md) (`Ctrl-w f`) is the live tree of those streams — depth, agent, active tool, tokens — so you do not have to read raw SSE. ### 3. Tool calls diff --git a/include/repl/session.h b/include/repl/session.h index 402867d7..2174d485 100644 --- a/include/repl/session.h +++ b/include/repl/session.h @@ -17,6 +17,7 @@ #include "repl/pane_history.h" #include "sandbox.h" #include "scheduler.h" +#include "tui/fleet.h" #include "tui/history_sidebar.h" #include "tui/interactive_prompt.h" #include "tui/opentui/mouse_decode.h" @@ -79,6 +80,11 @@ struct ReplSession { std::string folder_id; }; + struct PendingFleetSteer { + bool pending = false; + bool cancel = false; + }; + struct PendingAfterCancel { enum class Kind { None, Switch, Delete } kind = Kind::None; bool create_new = false; @@ -120,6 +126,8 @@ struct ReplSession { HistorySidebarState history_sidebar; MenuState overlay_menu; SidebarState sidebar; + FleetTree fleet; + FleetSidebarState fleet_sidebar; std::mutex pending_closes_mu; std::vector pending_closes; @@ -129,6 +137,7 @@ struct ReplSession { MouseDragState mouse_drag; MouseSelectState mouse_select; PendingMouseSwitch mouse_switch; + PendingFleetSteer mouse_fleet; PendingAfterCancel pending_after_cancel; std::atomic pending_cancel_wait{false}; @@ -269,9 +278,17 @@ struct ReplSession { void clear_all_selections(); void scroll_pane(Pane& pane, int direction, int step); Rect right_sidebar_rect(); + Rect fleet_sidebar_rect(); int history_visible_rows(const Rect& hb); + int fleet_visible_rows(const Rect& fb, const FleetSnapshot& snap); + bool fleet_has_content() const; + void enter_fleet_focus(); + Pane* pane_for_fleet_row(const FleetPaintRow& row); + bool steer_fleet_selection(); + bool cancel_fleet_selection(); bool route_mouse(const opentui::MouseEvent& ev); bool service_mouse_switch(); + bool service_fleet_steer(); // ── input_loop.cpp ───────────────────────────────────────────────────── void run_input_loop(); diff --git a/include/tui/fleet.h b/include/tui/fleet.h new file mode 100644 index 00000000..90100e29 --- /dev/null +++ b/include/tui/fleet.h @@ -0,0 +1,225 @@ +#pragma once +// In-memory fleet tree + TUI sidebar state for multi-agent mission control. +// +// Consumers open a row on stream_start / agent.spawned, update it from +// agent_start / tool_call / token_usage / text / reconcile.delta, and +// close it on stream_end (keep until the job clears) or agent.teardown +// (remove immediately — JIT ephemeral lifetime). +// +// The tree is a consumer of the existing SSE / orchestrator event model. +// It does not spawn agents or change orchestrate/reconcile behavior. + +#include "json.h" +#include "tui/tui.h" + +#include +#include +#include +#include +#include + +namespace arbiter { + +// One live or recently-ended agent slot, keyed by stream_id and/or JIT +// clone_id. Depth is 0 (master), 1 (delegated / JIT), or 2 (sub-sub). +struct FleetNode { + int stream_id = -1; // -1 until stream_start (JIT spawn first) + std::string clone_id; // reconcile JIT clone; empty otherwise + std::string agent; + int depth = 0; + int parent_stream_id = -1; + bool ephemeral = false; // reconcile JIT worker + bool running = false; + bool ended = false; + bool ok = true; + std::string active_tool; + int input_tokens = 0; + int output_tokens = 0; + std::string request_id; + std::string conversation_id; + std::string pane_agent; // host pane's current_agent at bind + std::vector clauses; // JIT cover clause ids +}; + +struct FleetReconcileOverlay { + bool active = false; + std::string mode; // "ensure" | "observe" + int wave = 0; + int residual = 0; + int held = 0; + bool empty = true; + std::string request_id; + std::string phase; // last reconcile.progress phase +}; + +// Flattened, depth-indented row for paint / hit-test / keyboard. +struct FleetPaintRow { + std::string key; // "s:" or "c:" + int stream_id = -1; + std::string clone_id; + std::string agent; + int depth = 0; + bool ephemeral = false; + bool running = false; + bool ended = false; + bool ok = true; + std::string conversation_id; + std::string pane_agent; + std::string line; // single scannable display line +}; + +struct FleetSnapshot { + bool visible = false; + bool focused = false; + int selected = 0; + int scroll_offset = 0; + std::string request_id; + FleetReconcileOverlay overlay; + std::vector rows; + int overlay_lines = 0; // painted header rows above the tree + int total_input = 0; + int total_output = 0; + bool has_live = false; +}; + +enum class FleetKey { + None, + Up, + Down, + Enter, // focus/open the pane bound to the selected row + Escape, // leave fleet focus (no cancel) + Cancel, // Esc on a running row → existing pane kill-switch + PageUp, + PageDown, +}; + +// Thread-safe event → tree state machine. Exec threads ingest; the output +// pump snapshots for paint. +class FleetTree { +public: + void clear(); + void on_request_received(const std::string& request_id, + const std::string& agent); + void on_stream_start(const std::string& agent, + int stream_id, + int depth, + const std::string& request_id = {}, + const std::string& conversation_id = {}, + const std::string& pane_agent = {}); + void on_agent_start(const std::string& agent, int stream_id, int depth); + void on_text(int stream_id); + void on_tool_call(int stream_id, const std::string& tool, bool started); + void on_token_usage(int stream_id, int input_tokens, int output_tokens); + void on_stream_end(int stream_id, bool ok); + void on_agent_spawned(const std::string& agent, + const std::string& clone_id, + const std::vector& clauses, + const std::string& request_id = {}); + void on_agent_teardown(const std::string& clone_id); + void on_reconcile_delta(int residual, int held, bool empty, int wave, + const std::string& request_id = {}); + void on_reconcile_progress(const std::string& phase, + const std::string& detail = {}, + const std::string& request_id = {}); + void on_done(); + + // Parse one SSE frame (tests + remote TUI). Unknown events are ignored. + void ingest_sse(const std::string& event, const JsonValue& payload); + void ingest_sse(const std::string& event, const std::string& data); + + void bind_host(int stream_id, + const std::string& conversation_id, + const std::string& pane_agent); + + [[nodiscard]] std::vector nodes() const; + [[nodiscard]] FleetReconcileOverlay overlay() const; + [[nodiscard]] std::string request_id() const; + [[nodiscard]] bool empty() const; + [[nodiscard]] bool has_live() const; + [[nodiscard]] int total_input() const; + [[nodiscard]] int total_output() const; + + // Depth-preorder rows with indent / JIT marker / tool / tokens. + [[nodiscard]] std::vector paint_rows(int max_cells = 24) const; + + [[nodiscard]] std::optional find_stream(int stream_id) const; + [[nodiscard]] std::optional find_clone(const std::string& clone_id) const; + +private: + FleetNode* find_stream_locked(int stream_id); + FleetNode* find_clone_locked(const std::string& clone_id); + FleetNode* ensure_stream_locked(int stream_id); + int infer_parent_locked(int depth) const; + void attach_stream_to_clone_locked(int stream_id, const std::string& agent); + + mutable std::mutex mu_; + std::string request_id_; + std::vector nodes_; + FleetReconcileOverlay overlay_; +}; + +// Right-rail fleet dashboard. Shown when the tree has rows (or the user +// forced it on); stays visible with multiple conversation panes. Focus +// is a sidebar mode like ^W b, not a layout leaf — conversation panes +// keep their split tree. +class FleetSidebarState { +public: + static constexpr int kOuterGutter = 1; + static constexpr int kMinRemaining = 96; + + static int breakpoint_width(int cols) { + if (cols < kMinRemaining) return 0; + if (cols < 120) return 24; + return 28; + } + + void toggle_visible(); + void set_visible(bool on); + [[nodiscard]] bool user_visible() const; + void enter_focus(); + void exit_focus(); + [[nodiscard]] bool focused() const; + + // pane_count is ignored: fleet stays up during a multi-pane job. + [[nodiscard]] int effective_width(int cols, int leading_cols, + bool has_content) const; + [[nodiscard]] Rect rect_for_terminal(int cols, int rows, int leading_cols, + bool has_content) const; + + void move_selection(int delta, int visible_rows); + void page_selection(int direction, int visible_rows); + void select_at_index(int index, int visible_rows); + [[nodiscard]] int selected_index() const; + [[nodiscard]] int scroll_offset() const; + + FleetKey handle_key(int key_byte, char csi_final = 0, + const std::string& csi_params = {}); + + [[nodiscard]] FleetSnapshot snapshot(const FleetTree& tree, + int cols, int leading_cols) const; + +private: + void clamp_scroll_locked(int idx, int visible_rows, int row_count); + + mutable std::mutex mu_; + bool user_visible_ = true; + bool focused_ = false; + // Clamped during snapshot() so paint always gets an in-range index. + mutable int selected_ = 0; + mutable int scroll_offset_ = 0; + int last_visible_rows_ = 0; + mutable int last_row_count_ = 0; +}; + +// One-line tree row: depth indent, status, name, optional jit tag, tool +// or token totals. Width-capped for a ~24–28 col rail. +std::string format_fleet_row(const FleetNode& node, + bool last_sibling, + bool parent_last, + int max_cells); + +// Overlay summary lines (reconcile ensure). Empty when overlay is inactive. +std::vector format_fleet_overlay(const FleetReconcileOverlay& ov, + int max_cells); + +} // namespace arbiter diff --git a/include/tui/opentui/fleet_frame.h b/include/tui/opentui/fleet_frame.h new file mode 100644 index 00000000..87d038d7 --- /dev/null +++ b/include/tui/opentui/fleet_frame.h @@ -0,0 +1,27 @@ +#pragma once + +#include "tui/fleet.h" +#include "tui/opentui/c_api.h" +#include "tui/tui.h" + +namespace arbiter::opentui { + +// Rows available for the tree list (below title + overlay). +[[nodiscard]] int fleet_sidebar_visible_rows(const Rect& sidebar_rect, + const Rect& pane_rect, + int pane_input_rows, + int overlay_lines, + int pane_bottom_pad_rows = TUI::kBottomPadRows); + +// Absolute y of the first tree row (matches the frame drawer). +[[nodiscard]] int fleet_sidebar_list_top(const Rect& sidebar_rect, + int overlay_lines); + +void draw_fleet_sidebar(OpenTuiHandle frame, + const FleetSnapshot& snap, + const Rect& sidebar_rect, + const Rect& pane_rect, + int pane_input_rows, + int pane_bottom_pad_rows = TUI::kBottomPadRows); + +} // namespace arbiter::opentui diff --git a/include/tui/opentui/mouse_hit.h b/include/tui/opentui/mouse_hit.h index 680b49ef..698b4656 100644 --- a/include/tui/opentui/mouse_hit.h +++ b/include/tui/opentui/mouse_hit.h @@ -7,6 +7,7 @@ #include "tui/opentui/mouse_decode.h" #include "tui/tui.h" +#include #include #include @@ -16,6 +17,7 @@ enum class HitKind { Outside, HistorySidebar, RightSidebar, + FleetSidebar, SplitSeparator, PaneScroll, PaneInput, @@ -30,6 +32,8 @@ struct HitTarget { // Absolute list-row index for HistorySidebar (0 = "+ New"), or -1 when // the click landed in sidebar chrome / empty list space. int history_row = -1; + // Absolute tree-row index for FleetSidebar, or -1 for chrome / overlay. + int fleet_row = -1; }; inline bool rect_contains(const Rect& r, int x, int y) { @@ -65,10 +69,29 @@ inline int history_sidebar_row_at(const Rect& sidebar_rect, return -1; } +// Map y into a fleet tree row. Overlay lines sit below the title and are +// not selectable (matches fleet_frame.cpp: list_top = y+3 + overlay+gap). +inline int fleet_sidebar_row_at(const Rect& sidebar_rect, + int y, + int scroll_offset, + int list_height_lines, + int overlay_lines, + int row_count) { + int top = sidebar_rect.y + 3; + if (overlay_lines > 0) top += overlay_lines + 1; + if (y < top || list_height_lines <= 0 || row_count <= 0) return -1; + const int band_end = top + list_height_lines; + if (y >= band_end) return -1; + const int idx = scroll_offset + (y - top); + if (idx < 0 || idx >= row_count) return -1; + if (idx >= scroll_offset + list_height_lines) return -1; + return idx; +} + // Classify which interactive region contains (x, y). -// `history_rect` / `right_rect` may be empty (w==0) when those sidebars are off. +// `history_rect` / `right_rect` / `fleet_rect` may be empty (w==0) when off. // `history_list_height` / `history_rows` clamp history list hits to painted, -// real rows only. +// real rows only. Fleet tree rows are height 1. inline HitTarget hit_test(LayoutTree& layout, const Rect& history_rect, const Rect& right_rect, @@ -76,7 +99,12 @@ inline HitTarget hit_test(LayoutTree& layout, int history_list_height, const std::vector& history_rows, int x, - int y) { + int y, + const Rect& fleet_rect = {}, + int fleet_scroll_offset = 0, + int fleet_list_height = 0, + int fleet_overlay_lines = 0, + int fleet_row_count = 0) { HitTarget hit; if (history_rect.w > 0 && rect_contains(history_rect, x, y)) { @@ -86,6 +114,13 @@ inline HitTarget hit_test(LayoutTree& layout, history_rows); return hit; } + if (fleet_rect.w > 0 && rect_contains(fleet_rect, x, y)) { + hit.kind = HitKind::FleetSidebar; + hit.fleet_row = fleet_sidebar_row_at( + fleet_rect, y, fleet_scroll_offset, fleet_list_height, + fleet_overlay_lines, fleet_row_count); + return hit; + } if (right_rect.w > 0 && rect_contains(right_rect, x, y)) { hit.kind = HitKind::RightSidebar; return hit; diff --git a/src/repl/chords.cpp b/src/repl/chords.cpp index dcdf0652..4afda414 100644 --- a/src/repl/chords.cpp +++ b/src/repl/chords.cpp @@ -203,6 +203,27 @@ void ReplSession::dispatch_chord(char cmd) { sync_layout_to_terminal(); } break; + case 'f': + if (fleet_sidebar.focused()) { + fleet_sidebar.exit_focus(); + fleet_sidebar.toggle_visible(); + } else if (fleet_sidebar.user_visible() + && fleet_has_content()) { + // Visible already — enter the tree (like ^W b). + enter_fleet_focus(); + lk.unlock(); + refresh_focused_input.store(true); + if (pump_notify) pump_notify(); + return; + } else { + fleet_sidebar.set_visible(true); + enter_fleet_focus(); + lk.unlock(); + refresh_focused_input.store(true); + if (pump_notify) pump_notify(); + return; + } + break; case 'h': if (layout_ptr->pane_count() >= kMaxLayoutSnapshotLeaves) { layout_ptr->focused().tui.set_status( diff --git a/src/repl/input_loop.cpp b/src/repl/input_loop.cpp index 989f16fc..5d60431d 100644 --- a/src/repl/input_loop.cpp +++ b/src/repl/input_loop.cpp @@ -30,7 +30,9 @@ #include "tui/opentui/session.h" #include "tui/opentui/sidebar_frame.h" #include "tui/opentui/history_sidebar_frame.h" +#include "tui/opentui/fleet_frame.h" #include "tui/opentui/menu_frame.h" +#include "tui/fleet.h" #include "model_catalog.h" #include "model_context.h" #include "tui/opentui/mouse_decode.h" @@ -87,6 +89,7 @@ void ReplSession::run_input_loop() { while (service_pending_closes()) {} while (service_pending_conv_ops()) {} while (service_mouse_switch()) {} + while (service_fleet_steer()) {} while (service_pending_after_cancel()) {} // A wake arrived while we were draining services (no active @@ -222,6 +225,73 @@ void ReplSession::run_input_loop() { continue; } + if (fleet_sidebar.focused()) { + if (pump_notify) pump_notify(); + const int cols = arbiter::term_cols(); + const int rows = arbiter::term_rows(); + const int leading = HistorySidebarState::width_for_terminal( + cols, history_sidebar.enabled()); + const Rect fb = fleet_sidebar_rect(); + const auto snap = fleet_sidebar.snapshot(fleet, cols, leading); + const int visible_rows = fleet_visible_rows(fb, snap); + + char csi = 0; + std::string csi_params; + const int key = read_history_sidebar_key(csi, csi_params); + if (key < 0) break; + + if (key == 0x1B && (csi == 'M' || csi == 'm') + && !csi_params.empty() && csi_params[0] == '<') { + if (auto ev = arbiter::opentui::decode_sgr_mouse(csi_params, csi)) { + (void)route_mouse(*ev); + } + continue; + } + + FleetKey action = fleet_sidebar.handle_key(key, csi, csi_params); + if (action == FleetKey::Escape) { + bool running = false; + if (snap.selected >= 0 + && snap.selected < static_cast(snap.rows.size())) { + running = snap.rows[static_cast(snap.selected)].running; + } + if (running) { + cancel_fleet_selection(); + if (pump_notify) pump_notify(); + continue; + } + fleet_sidebar.exit_focus(); + refresh_focused_input.store(true); + if (pump_notify) pump_notify(); + continue; + } + if (action == FleetKey::Up) { + fleet_sidebar.move_selection(-1, visible_rows); + if (pump_notify) pump_notify(); + continue; + } + if (action == FleetKey::Down) { + fleet_sidebar.move_selection(1, visible_rows); + if (pump_notify) pump_notify(); + continue; + } + if (action == FleetKey::PageUp) { + fleet_sidebar.page_selection(-1, visible_rows); + if (pump_notify) pump_notify(); + continue; + } + if (action == FleetKey::PageDown) { + fleet_sidebar.page_selection(1, visible_rows); + if (pump_notify) pump_notify(); + continue; + } + if (action == FleetKey::Enter) { + steer_fleet_selection(); + continue; + } + continue; + } + if (history_sidebar.focused()) { if (pump_notify) pump_notify(); const int cols = arbiter::term_cols(); @@ -430,6 +500,7 @@ void ReplSession::run_input_loop() { if (service_pending_closes()) continue; if (service_pending_conv_ops()) continue; if (service_mouse_switch()) continue; + if (service_fleet_steer()) continue; if (service_pending_after_cancel()) continue; if (overlay_menu.active()) continue; // Layout mutation woke us up just to repaint the focused diff --git a/src/repl/interactive.cpp b/src/repl/interactive.cpp index a2aa4040..be86bcf9 100644 --- a/src/repl/interactive.cpp +++ b/src/repl/interactive.cpp @@ -22,10 +22,12 @@ #include "tui/opentui/session.h" #include "tui/opentui/sidebar_frame.h" #include "tui/opentui/history_sidebar_frame.h" +#include "tui/opentui/fleet_frame.h" #include "tui/opentui/menu_frame.h" #include "tui/opentui/overlay_scrim.h" #include "tui/sidebar.h" #include "tui/history_sidebar.h" +#include "tui/fleet.h" #include "theme.h" #include "config.h" @@ -147,9 +149,12 @@ Rect ReplSession::layout_bounds() { cols, history_sidebar.enabled()); const int panes = layout_ptr ? static_cast(layout_ptr->pane_count()) : 1; const int session_w = sidebar.effective_width(cols, panes, leading); - // Reserve a trailing gutter so the session box isn't flush to the edge. - const int trailing = session_w > 0 - ? session_w + SidebarState::kOuterGutter + const int fleet_w = fleet_sidebar.effective_width( + cols, leading, fleet_has_content() || fleet_sidebar.focused()); + const int rail = std::max(session_w, fleet_w); + // Reserve a trailing gutter so the session/fleet box isn't flush to the edge. + const int trailing = rail > 0 + ? rail + SidebarState::kOuterGutter : 0; // Full terminal height — no top header bar. return Rect{leading, 0, std::max(1, cols - leading - trailing), std::max(1, rows)}; @@ -288,13 +293,17 @@ void ReplSession::setup_pane_hooks() { const int panes = static_cast(layout_ptr->pane_count()); const int leading = HistorySidebarState::width_for_terminal( cols, history_sidebar.enabled()); + const Rect fleet_r = fleet_sidebar_rect(); + if (fleet_r.w > 0) { + const FleetSnapshot fs = fleet_sidebar.snapshot(fleet, cols, leading); + opentui::draw_fleet_sidebar( + frame, fs, fleet_r, outer, sidebar_input_rows, outer_bottom_pad); + } + int sw = sidebar.effective_width(cols, panes, leading); if (sw > 0) { - int pane_x = outer.x; - int pane_w = outer.w; - int gap = cols - pane_x - pane_w; - if (gap >= sw) { - const Rect sb = {pane_x + pane_w, 0, sw, std::max(1, rows)}; + const Rect sb = right_sidebar_rect(); + if (sb.w > 0) { Pane& focused = layout_ptr->focused(); sidebar.set_focus_context(focused.current_agent, focused.current_model); diff --git a/src/repl/mouse_input.cpp b/src/repl/mouse_input.cpp index 7bb0e445..f4a3e337 100644 --- a/src/repl/mouse_input.cpp +++ b/src/repl/mouse_input.cpp @@ -30,9 +30,11 @@ #include "tui/opentui/session.h" #include "tui/opentui/sidebar_frame.h" #include "tui/opentui/history_sidebar_frame.h" +#include "tui/opentui/fleet_frame.h" #include "tui/opentui/menu_frame.h" #include "tui/opentui/mouse_decode.h" #include "tui/opentui/mouse_hit.h" +#include "tui/fleet.h" #include "repl/pane.h" #include "repl/layout.h" #include "repl/layout_snapshot.h" @@ -133,7 +135,113 @@ Rect ReplSession::right_sidebar_rect() { const int pane_w = layout_ptr->outer_bounds().w; const int gap = cols - pane_x - pane_w; if (gap < sw) return {}; - return Rect{pane_x + pane_w, 0, sw, std::max(1, rows)}; + Rect rail{pane_x + pane_w, 0, sw, std::max(1, rows)}; + const Rect fleet = fleet_sidebar_rect(); + if (fleet.w > 0 && fleet.h > 0 && fleet.h < rows) { + rail.y = fleet.y + fleet.h; + rail.h = std::max(1, rows - rail.y); + } + return rail; +} + +bool ReplSession::fleet_has_content() const { + return !fleet.empty(); +} + +Rect ReplSession::fleet_sidebar_rect() { + const int cols = arbiter::term_cols(); + const int rows = arbiter::term_rows(); + const int leading = HistorySidebarState::width_for_terminal( + cols, history_sidebar.enabled()); + const int fw = fleet_sidebar.effective_width( + cols, leading, fleet_has_content() || fleet_sidebar.focused()); + if (fw <= 0) return {}; + const int pane_x = layout_ptr->outer_bounds().x; + const int pane_w = layout_ptr->outer_bounds().w; + const int gap = cols - pane_x - pane_w; + if (gap < fw) return {}; + Rect rail{pane_x + pane_w, 0, fw, std::max(1, rows)}; + const int panes = static_cast(layout_ptr->pane_count()); + const int sw = sidebar.effective_width(cols, panes, leading); + if (sw > 0 && rows > 16) { + // Stack fleet above the session box so neither drowns the other. + rail.h = std::max(10, rows * 2 / 5); + } + return rail; +} + +int ReplSession::fleet_visible_rows(const Rect& fb, const FleetSnapshot& snap) { + const Rect outer = layout_ptr->outer_bounds(); + const int outer_bottom_pad = + arbiter::tui_outer_bottom_pad_rows(arbiter::tui_design()); + return arbiter::opentui::fleet_sidebar_visible_rows( + fb, outer, outer_bottom_input_rows(), snap.overlay_lines, + outer_bottom_pad); +} + +void ReplSession::enter_fleet_focus() { + if (history_sidebar.focused()) history_sidebar.exit_focus(); + fleet_sidebar.enter_focus(); +} + +Pane* ReplSession::pane_for_fleet_row(const FleetPaintRow& row) { + if (!layout_ptr) return nullptr; + Pane* host = nullptr; + Pane* agent_match = nullptr; + layout_ptr->for_each_pane([&](Pane& p) { + if (!row.conversation_id.empty() + && p.conversation_id != row.conversation_id) { + return; + } + if (!host) host = &p; + if (!row.agent.empty() && p.current_agent == row.agent) { + agent_match = &p; + } else if (!row.pane_agent.empty() + && p.current_agent == row.pane_agent && !agent_match) { + host = &p; + } + }); + if (agent_match) return agent_match; + return host ? host : &layout_ptr->focused(); +} + +bool ReplSession::steer_fleet_selection() { + const int cols = arbiter::term_cols(); + const int leading = HistorySidebarState::width_for_terminal( + cols, history_sidebar.enabled()); + const auto snap = fleet_sidebar.snapshot(fleet, cols, leading); + if (snap.rows.empty()) return false; + int idx = snap.selected; + if (idx < 0 || idx >= static_cast(snap.rows.size())) return false; + Pane* target = pane_for_fleet_row(snap.rows[static_cast(idx)]); + fleet_sidebar.exit_focus(); + if (target && layout_ptr) { + layout_ptr->focus_pane(target); + if (!target->conversation_id.empty()) { + conversation_store.set_active(target->conversation_id); + bind_tools_conversation(target->conversation_id); + } + g_getc_state.pane = target; + ui_ctx.focused_pane = target; + } + refresh_focused_input.store(true); + if (pump_notify) pump_notify(); + return true; +} + +bool ReplSession::cancel_fleet_selection() { + const int cols = arbiter::term_cols(); + const int leading = HistorySidebarState::width_for_terminal( + cols, history_sidebar.enabled()); + const auto snap = fleet_sidebar.snapshot(fleet, cols, leading); + if (snap.rows.empty()) return false; + int idx = snap.selected; + if (idx < 0 || idx >= static_cast(snap.rows.size())) return false; + const auto& row = snap.rows[static_cast(idx)]; + if (!row.running) return false; + Pane* target = pane_for_fleet_row(row); + if (target) cancel_pane_turn(*target); + return true; } int ReplSession::history_visible_rows(const Rect& hb) { @@ -241,14 +349,22 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { const int rows = arbiter::term_rows(); const Rect hb = HistorySidebarState::rect_for_terminal( cols, rows, history_sidebar.enabled()); + const Rect fb = fleet_sidebar_rect(); const Rect rb = right_sidebar_rect(); const int hist_vis = (hb.w > 0) ? history_visible_rows(hb) : 0; const auto hist_snap = (hb.w > 0) ? history_sidebar.snapshot() : arbiter::HistorySidebarSnapshot{}; + const int leading = HistorySidebarState::width_for_terminal( + cols, history_sidebar.enabled()); + const auto fleet_snap = (fb.w > 0) + ? fleet_sidebar.snapshot(fleet, cols, leading) + : arbiter::FleetSnapshot{}; + const int fleet_vis = (fb.w > 0) ? fleet_visible_rows(fb, fleet_snap) : 0; const auto hit = arbiter::opentui::hit_test( *layout_ptr, hb, rb, hist_snap.scroll_offset, hist_vis, hist_snap.rows, - ev.x, ev.y); + ev.x, ev.y, fb, fleet_snap.scroll_offset, fleet_vis, + fleet_snap.overlay_lines, static_cast(fleet_snap.rows.size())); if (ev.type == MouseType::Scroll) { const int dir = (ev.scroll == ScrollDir::Up || ev.scroll == ScrollDir::Left) @@ -258,9 +374,14 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { if (pump_notify) pump_notify(); return false; } + if (hit.kind == HitKind::FleetSidebar && fb.w > 0) { + fleet_sidebar.page_selection(dir, fleet_vis); + if (pump_notify) pump_notify(); + return false; + } // Only scroll when the pointer is over a pane scroll/input/chrome // region — never fall back to the focused pane for Outside / - // RightSidebar / separator hits. + // RightSidebar / FleetSidebar / separator hits. if (hit.pane && (hit.kind == HitKind::PaneScroll || hit.kind == HitKind::PaneInput @@ -308,6 +429,20 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { if (pump_notify) pump_notify(); return true; } + if (hit.kind == HitKind::FleetSidebar) { + clear_mouse_select(); + clear_all_selections(); + if (history_sidebar.focused()) history_sidebar.exit_focus(); + if (!fleet_sidebar.focused()) enter_fleet_focus(); + if (hit.fleet_row >= 0) { + fleet_sidebar.select_at_index(hit.fleet_row, fleet_vis); + mouse_fleet.pending = true; + mouse_fleet.cancel = false; + } + refresh_focused_input.store(true); + if (pump_notify) pump_notify(); + return true; + } if (hit.kind == HitKind::RightSidebar) { // Display-only telemetry panel — clicks are intentionally // ignored (documented in docs/tui/panes.md). @@ -319,7 +454,9 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { && hit.pane) { const bool focus_changed = (hit.pane != layout_ptr->focused_ptr()); const bool was_history = history_sidebar.focused(); + const bool was_fleet = fleet_sidebar.focused(); if (was_history) history_sidebar.exit_focus(); + if (was_fleet) fleet_sidebar.exit_focus(); layout_ptr->focus_pane(hit.pane); if (hit.kind == HitKind::PaneInput || hit.kind == HitKind::PaneChrome) { @@ -328,10 +465,10 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { if (hit.kind == HitKind::PaneInput) { hit.pane->editor.set_cursor_from_click(ev.x, ev.y); } - if (focus_changed || was_history) { + if (focus_changed || was_history || was_fleet) { refresh_focused_input.store(true); if (pump_notify) pump_notify(); - return focus_changed || was_history; + return focus_changed || was_history || was_fleet; } if (pump_notify) pump_notify(); return false; @@ -348,10 +485,10 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { } else { clear_mouse_select(); } - if (focus_changed || was_history) { + if (focus_changed || was_history || was_fleet) { refresh_focused_input.store(true); if (pump_notify) pump_notify(); - return focus_changed || was_history; + return focus_changed || was_history || was_fleet; } if (pump_notify) pump_notify(); return false; @@ -363,8 +500,8 @@ bool ReplSession::route_mouse(const opentui::MouseEvent& ev) { } return false; - } + bool ReplSession::service_mouse_switch() { if (!mouse_switch.pending) return false; @@ -388,4 +525,13 @@ bool ReplSession::service_mouse_switch() { return true; } +bool ReplSession::service_fleet_steer() { + if (!mouse_fleet.pending) return false; + const bool cancel = mouse_fleet.cancel; + mouse_fleet.pending = false; + mouse_fleet.cancel = false; + if (cancel) return cancel_fleet_selection(); + return steer_fleet_selection(); +} + } // namespace arbiter diff --git a/src/repl/remote_session.cpp b/src/repl/remote_session.cpp index 746fe451..8152232e 100644 --- a/src/repl/remote_session.cpp +++ b/src/repl/remote_session.cpp @@ -5,6 +5,7 @@ #include "remote/sse_turn.h" #include "render_policy.h" #include "stream_renderer.h" +#include "json.h" #include "tui/tui_design.h" #include @@ -258,6 +259,20 @@ ApiResponse ReplSession::run_remote_turn(Pane& pane, const std::string& line, std::move(attachments), [&](const std::string& ev, const std::string& data) { consumer.on_event(ev, data); + fleet.ingest_sse(ev, data); + if (ev == "stream_start") { + try { + auto parsed = json_parse(data); + if (parsed) { + const int sid = parsed->get_int("stream_id", -1); + if (sid >= 0) { + fleet.bind_host(sid, pane.conversation_id, + pane.current_agent); + } + } + } catch (...) {} + } + if (pump_notify) pump_notify(); }, gate->stream_cancel); diff --git a/src/repl/session_callbacks.cpp b/src/repl/session_callbacks.cpp index ba00c672..b95bfc9c 100644 --- a/src/repl/session_callbacks.cpp +++ b/src/repl/session_callbacks.cpp @@ -283,11 +283,11 @@ std::unique_ptr ReplSession::make_pane() { }); // Chord prefix: Ctrl-w. Recognized follow-ups: w (next pane), // h (horizontal split), v (vertical split), s (sidebar toggle), - // c (close pane), z (zoom), t/b (history sidebar); Ctrl-w itself - // is a synonym for 'w'. + // c (close pane), z (zoom), t/b (history sidebar), f (fleet); + // Ctrl-w itself is a synonym for 'w'. p->editor.set_chord_handler([](char cmd) -> bool { return cmd == 'w' || cmd == 'h' || cmd == 's' || cmd == 'v' || cmd == 'c' - || cmd == 'z' || cmd == 't' || cmd == 'b' + || cmd == 'z' || cmd == 't' || cmd == 'b' || cmd == 'f' || cmd == 0x17; }); p->editor.set_mouse_handler([this](const opentui::MouseEvent& ev) { @@ -382,6 +382,9 @@ void ReplSession::install_orch_callbacks() { p->tool_indicator.bump(view.label, ev.ok); } } + fleet.on_tool_call(orch.current_stream_id(), view.label, + ev.phase == arbiter::ToolActivityEvent::Phase::Started); + if (pump_notify) pump_notify(); if (ev.phase == arbiter::ToolActivityEvent::Phase::Finished) { // Persist for conversation-switch replay. Pane history is what // apply_conversation_to_pane rebuilds (usually "index"), so the @@ -408,6 +411,8 @@ void ReplSession::install_orch_callbacks() { const std::string& model, const arbiter::ApiResponse& resp) { sidebar.record_turn(agent_id, model, resp); + fleet.on_token_usage(orch.current_stream_id(), + resp.input_tokens, resp.output_tokens); // Prefer the ConversationScope key — cost_cb runs on the pane exec // thread inside handle_line()'s scope, even when g_active_pane is unset // (e.g. nested /parallel workers that re-pin late). @@ -416,10 +421,30 @@ void ReplSession::install_orch_callbacks() { if (delta > 0 && !cid.empty()) { conversation_store.add_tokens(cid, delta); } + if (pump_notify) pump_notify(); }); - orch.set_agent_start_callback([&](const std::string& /*agent_id*/) { + orch.set_agent_start_callback([&](const std::string& agent_id) { Pane* p = g_active_pane; if (p) p->thinking.start(); + fleet.on_agent_start(agent_id, orch.current_stream_id(), + orch.current_stream_depth()); + if (pump_notify) pump_notify(); + }); + orch.set_stream_start_callback([&](const std::string& agent_id, + int stream_id, + int depth) { + if (depth == 0) fleet.clear(); + Pane* p = g_active_pane; + const std::string conv = p ? p->conversation_id : std::string{}; + const std::string pane_agent = p ? p->current_agent : std::string{}; + fleet.on_stream_start(agent_id, stream_id, depth, {}, conv, pane_agent); + if (pump_notify) pump_notify(); + }); + orch.set_stream_end_callback([&](const std::string& /*agent_id*/, + int stream_id, + bool ok) { + fleet.on_stream_end(stream_id, ok); + if (pump_notify) pump_notify(); }); // Mid-turn durability: after each committed model iteration and each // tool-result envelope, queue an autosave so quit/cancel/SIGKILL cannot diff --git a/src/tui/fleet.cpp b/src/tui/fleet.cpp new file mode 100644 index 00000000..d12b75ad --- /dev/null +++ b/src/tui/fleet.cpp @@ -0,0 +1,814 @@ +#include "tui/fleet.h" + +#include "styled_text.h" +#include "tui/sidebar_format.h" + +#include + +namespace arbiter { + +namespace { + +std::string node_key(const FleetNode& n) { + if (n.stream_id >= 0) return "s:" + std::to_string(n.stream_id); + if (!n.clone_id.empty()) return "c:" + n.clone_id; + return "u:" + n.agent; +} + +int clamp_depth(int depth) { + if (depth < 0) return 0; + if (depth > 2) return 2; // cap is 2; paint honestly + return depth; +} + +std::vector string_array(const JsonValue& payload, + const std::string& key) { + std::vector out; + auto v = payload.get(key); + if (!v || !v->is_array()) return out; + for (const auto& item : v->as_array()) { + if (!item) continue; + if (item->is_string()) out.push_back(item->as_string()); + else if (item->is_object()) { + const std::string id = item->get_string("id"); + if (!id.empty()) out.push_back(id); + } + } + return out; +} + +int array_len(const JsonValue& payload, const std::string& key) { + auto v = payload.get(key); + if (!v || !v->is_array()) return 0; + return static_cast(v->as_array().size()); +} + +std::string status_glyph(const FleetNode& n) { + if (n.running) return "\u25CF"; // ● + if (n.ended && n.ok) return "\u2713"; // ✓ + if (n.ended && !n.ok) return "\u2717"; // ✗ + return "\u25CB"; // ○ pending +} + +std::string tree_prefix(int depth, bool last_sibling, bool parent_last) { + if (depth <= 0) return {}; + std::string p; + if (depth >= 2) { + p += parent_last ? " " : "\u2502 "; // │ + } + p += last_sibling ? "\u2514 " : "\u251C "; // └ ├ + return p; +} + +} // namespace + +std::string format_fleet_row(const FleetNode& node, + bool last_sibling, + bool parent_last, + int max_cells) { + if (max_cells < 4) max_cells = 4; + std::string line = tree_prefix(node.depth, last_sibling, parent_last); + line += status_glyph(node); + line += " "; + std::string name = node.agent.empty() ? "?" : node.agent; + if (node.ephemeral) name += " \u25C7"; // ◇ JIT marker + line += name; + + std::string tail; + if (!node.active_tool.empty() && node.running) { + tail = node.active_tool; + } else { + const int tok = node.input_tokens + node.output_tokens; + if (tok > 0) tail = format_token_count(tok); + } + if (!tail.empty()) { + const int used = static_cast(display_width(line)); + const int gap = 1; + const int tail_w = static_cast(display_width(tail)); + int pad = max_cells - used - gap - tail_w; + if (pad < 1) { + // Trim the name so the tail still fits. + const int name_budget = std::max(1, max_cells - tail_w - gap + - static_cast(display_width(tree_prefix(node.depth, + last_sibling, + parent_last) + + status_glyph(node) + " "))); + name = trim_to_display_cols(name, name_budget); + line = tree_prefix(node.depth, last_sibling, parent_last) + + status_glyph(node) + " " + name; + pad = max_cells - static_cast(display_width(line)) + - gap - tail_w; + } + if (pad < 1) pad = 1; + line.append(static_cast(pad), ' '); + line += tail; + } + return trim_to_display_cols(line, max_cells); +} + +std::vector format_fleet_overlay(const FleetReconcileOverlay& ov, + int max_cells) { + std::vector lines; + if (!ov.active) return lines; + if (max_cells < 4) max_cells = 4; + + std::string head; + if (!ov.mode.empty()) head = ov.mode; + else head = "reconcile"; + if (ov.wave > 0) { + if (!head.empty()) head += " \u00b7 "; + head += "wave " + std::to_string(ov.wave); + } else if (!ov.phase.empty()) { + if (!head.empty()) head += " \u00b7 "; + head += ov.phase; + } + lines.push_back(trim_to_display_cols(head, max_cells)); + + std::string delta = "\u0394 " + std::to_string(ov.residual) + " residual"; + delta += " \u00b7 " + std::to_string(ov.held) + " held"; + lines.push_back(trim_to_display_cols(delta, max_cells)); + return lines; +} + +void FleetTree::clear() { + std::lock_guard lk(mu_); + nodes_.clear(); + overlay_ = {}; + request_id_.clear(); +} + +void FleetTree::on_request_received(const std::string& request_id, + const std::string& agent) { + std::lock_guard lk(mu_); + if (!request_id.empty() && request_id != request_id_) { + nodes_.clear(); + overlay_ = {}; + } + if (!request_id.empty()) request_id_ = request_id; + if (agent == "reconcile") { + overlay_.active = true; + overlay_.request_id = request_id_; + if (overlay_.mode.empty()) overlay_.mode = "ensure"; + } +} + +FleetNode* FleetTree::find_stream_locked(int stream_id) { + if (stream_id < 0) return nullptr; + for (auto& n : nodes_) { + if (n.stream_id == stream_id) return &n; + } + return nullptr; +} + +FleetNode* FleetTree::find_clone_locked(const std::string& clone_id) { + if (clone_id.empty()) return nullptr; + for (auto& n : nodes_) { + if (n.clone_id == clone_id) return &n; + } + return nullptr; +} + +int FleetTree::infer_parent_locked(int depth) const { + if (depth <= 0) return -1; + const int want = depth - 1; + int fallback = -1; + for (auto it = nodes_.rbegin(); it != nodes_.rend(); ++it) { + if (it->depth != want) continue; + if (it->stream_id < 0) continue; + if (it->running || !it->ended) return it->stream_id; + if (fallback < 0) fallback = it->stream_id; + } + return fallback; +} + +FleetNode* FleetTree::ensure_stream_locked(int stream_id) { + if (auto* n = find_stream_locked(stream_id)) return n; + FleetNode n; + n.stream_id = stream_id; + n.request_id = request_id_; + nodes_.push_back(std::move(n)); + return &nodes_.back(); +} + +void FleetTree::attach_stream_to_clone_locked(int stream_id, + const std::string& agent) { + if (stream_id < 0 || agent.empty()) return; + if (find_stream_locked(stream_id)) return; + // Prefer an open JIT clone with this agent that has no stream yet. + for (auto& n : nodes_) { + if (!n.ephemeral) continue; + if (n.stream_id >= 0) continue; + if (n.agent != agent) continue; + if (n.ended) continue; + n.stream_id = stream_id; + return; + } +} + +void FleetTree::on_stream_start(const std::string& agent, + int stream_id, + int depth, + const std::string& request_id, + const std::string& conversation_id, + const std::string& pane_agent) { + std::lock_guard lk(mu_); + if (!request_id.empty() && request_id != request_id_) { + nodes_.clear(); + overlay_ = {}; + request_id_ = request_id; + } else if (!request_id.empty()) { + request_id_ = request_id; + } + depth = clamp_depth(depth); + attach_stream_to_clone_locked(stream_id, agent); + auto* n = ensure_stream_locked(stream_id); + n->agent = agent; + n->depth = depth; + n->running = true; + n->ended = false; + n->ok = true; + n->active_tool.clear(); + if (n->parent_stream_id < 0) n->parent_stream_id = infer_parent_locked(depth); + if (!request_id_.empty()) n->request_id = request_id_; + if (!conversation_id.empty()) n->conversation_id = conversation_id; + if (!pane_agent.empty()) n->pane_agent = pane_agent; +} + +void FleetTree::on_agent_start(const std::string& agent, + int stream_id, + int depth) { + std::lock_guard lk(mu_); + auto* n = find_stream_locked(stream_id); + if (!n) { + FleetNode fresh; + fresh.stream_id = stream_id; + fresh.agent = agent; + fresh.depth = clamp_depth(depth); + fresh.running = true; + fresh.parent_stream_id = infer_parent_locked(fresh.depth); + fresh.request_id = request_id_; + nodes_.push_back(std::move(fresh)); + return; + } + if (!agent.empty()) n->agent = agent; + n->running = true; + n->ended = false; +} + +void FleetTree::on_text(int stream_id) { + std::lock_guard lk(mu_); + if (auto* n = find_stream_locked(stream_id)) { + n->running = true; + n->ended = false; + } +} + +void FleetTree::on_tool_call(int stream_id, const std::string& tool, bool started) { + std::lock_guard lk(mu_); + auto* n = find_stream_locked(stream_id); + if (!n) return; + if (started) { + n->active_tool = tool; + n->running = true; + n->ended = false; + } else if (n->active_tool == tool || n->active_tool.empty()) { + n->active_tool.clear(); + } +} + +void FleetTree::on_token_usage(int stream_id, int input_tokens, int output_tokens) { + std::lock_guard lk(mu_); + auto* n = find_stream_locked(stream_id); + if (!n) return; + if (input_tokens > 0) n->input_tokens += input_tokens; + if (output_tokens > 0) n->output_tokens += output_tokens; +} + +void FleetTree::on_stream_end(int stream_id, bool ok) { + std::lock_guard lk(mu_); + auto* n = find_stream_locked(stream_id); + if (!n) return; + n->running = false; + n->ended = true; + n->ok = ok; + n->active_tool.clear(); +} + +void FleetTree::on_agent_spawned(const std::string& agent, + const std::string& clone_id, + const std::vector& clauses, + const std::string& request_id) { + std::lock_guard lk(mu_); + if (!request_id.empty() && request_id != request_id_) { + nodes_.clear(); + overlay_ = {}; + request_id_ = request_id; + } else if (!request_id.empty()) { + request_id_ = request_id; + } + overlay_.active = true; + if (overlay_.mode.empty()) overlay_.mode = "ensure"; + overlay_.request_id = request_id_; + + if (auto* existing = find_clone_locked(clone_id)) { + existing->agent = agent; + existing->ephemeral = true; + existing->running = true; + existing->ended = false; + existing->clauses = clauses; + return; + } + FleetNode n; + n.clone_id = clone_id; + n.agent = agent; + n.depth = 1; + n.ephemeral = true; + n.running = true; + n.clauses = clauses; + n.request_id = request_id_; + n.parent_stream_id = infer_parent_locked(1); + nodes_.push_back(std::move(n)); +} + +void FleetTree::on_agent_teardown(const std::string& clone_id) { + std::lock_guard lk(mu_); + nodes_.erase(std::remove_if(nodes_.begin(), nodes_.end(), + [&](const FleetNode& n) { + return !clone_id.empty() + && n.clone_id == clone_id; + }), + nodes_.end()); +} + +void FleetTree::on_reconcile_delta(int residual, int held, bool empty, int wave, + const std::string& request_id) { + std::lock_guard lk(mu_); + overlay_.active = true; + overlay_.residual = residual; + overlay_.held = held; + overlay_.empty = empty; + overlay_.wave = wave; + if (!request_id.empty()) { + request_id_ = request_id; + overlay_.request_id = request_id; + } + if (overlay_.mode.empty()) overlay_.mode = "ensure"; +} + +void FleetTree::on_reconcile_progress(const std::string& phase, + const std::string& /*detail*/, + const std::string& request_id) { + std::lock_guard lk(mu_); + overlay_.active = true; + overlay_.phase = phase; + if (!request_id.empty()) { + request_id_ = request_id; + overlay_.request_id = request_id; + } + if (phase == "observe") overlay_.mode = "observe"; + else if (phase == "ensure" || phase == "wave" || phase == "implement") { + overlay_.mode = "ensure"; + } +} + +void FleetTree::on_done() { + std::lock_guard lk(mu_); + for (auto& n : nodes_) { + if (!n.ended) { + n.running = false; + n.ended = true; + } + n.active_tool.clear(); + } +} + +void FleetTree::bind_host(int stream_id, + const std::string& conversation_id, + const std::string& pane_agent) { + std::lock_guard lk(mu_); + auto* n = find_stream_locked(stream_id); + if (!n) return; + if (!conversation_id.empty()) n->conversation_id = conversation_id; + if (!pane_agent.empty()) n->pane_agent = pane_agent; +} + +void FleetTree::ingest_sse(const std::string& event, const std::string& data) { + if (data.empty()) { + ingest_sse(event, JsonValue{}); + return; + } + try { + auto parsed = json_parse(data); + if (parsed) ingest_sse(event, *parsed); + else ingest_sse(event, JsonValue{}); + } catch (...) { + ingest_sse(event, JsonValue{}); + } +} + +void FleetTree::ingest_sse(const std::string& event, const JsonValue& payload) { + const std::string rid = payload.is_object() + ? payload.get_string("request_id") : std::string{}; + const std::string agent = payload.is_object() + ? payload.get_string("agent") : std::string{}; + const int sid = payload.is_object() + ? payload.get_int("stream_id", -1) : -1; + const int depth = payload.is_object() + ? payload.get_int("depth", 0) : 0; + + if (event == "request_received") { + on_request_received(rid, agent); + return; + } + if (event == "stream_start") { + on_stream_start(agent, sid, depth, rid); + return; + } + if (event == "agent_start") { + on_agent_start(agent, sid, depth); + return; + } + if (event == "text") { + if (sid >= 0) on_text(sid); + return; + } + if (event == "tool_call") { + std::string tool = payload.get_string("tool"); + if (tool.empty()) tool = payload.get_string("label"); + // SSE tool_call is Finished; Started is TUI-only. + if (sid >= 0) on_tool_call(sid, tool, /*started=*/false); + return; + } + if (event == "token_usage") { + on_token_usage(sid, + payload.get_int("input_tokens"), + payload.get_int("output_tokens")); + return; + } + if (event == "stream_end") { + on_stream_end(sid, payload.get_bool("ok", true)); + return; + } + if (event == "agent.spawned") { + on_agent_spawned(agent, payload.get_string("clone_id"), + string_array(payload, "clauses"), rid); + return; + } + if (event == "agent.teardown") { + on_agent_teardown(payload.get_string("clone_id")); + return; + } + if (event == "reconcile.delta") { + on_reconcile_delta(array_len(payload, "residual"), + array_len(payload, "held"), + payload.get_bool("empty", false), + payload.get_int("wave", 0), + rid); + return; + } + if (event == "reconcile.progress") { + on_reconcile_progress(payload.get_string("phase"), + payload.get_string("detail"), + rid); + return; + } + if (event == "done" || event == "reconcile.done") { + on_done(); + return; + } +} + +std::vector FleetTree::nodes() const { + std::lock_guard lk(mu_); + return nodes_; +} + +FleetReconcileOverlay FleetTree::overlay() const { + std::lock_guard lk(mu_); + return overlay_; +} + +std::string FleetTree::request_id() const { + std::lock_guard lk(mu_); + return request_id_; +} + +bool FleetTree::empty() const { + std::lock_guard lk(mu_); + return nodes_.empty() && !overlay_.active; +} + +bool FleetTree::has_live() const { + std::lock_guard lk(mu_); + for (const auto& n : nodes_) { + if (n.running && !n.ended) return true; + } + return overlay_.active && !overlay_.empty && overlay_.mode == "ensure"; +} + +int FleetTree::total_input() const { + std::lock_guard lk(mu_); + int t = 0; + for (const auto& n : nodes_) t += n.input_tokens; + return t; +} + +int FleetTree::total_output() const { + std::lock_guard lk(mu_); + int t = 0; + for (const auto& n : nodes_) t += n.output_tokens; + return t; +} + +std::optional FleetTree::find_stream(int stream_id) const { + std::lock_guard lk(mu_); + for (const auto& n : nodes_) { + if (n.stream_id == stream_id) return n; + } + return std::nullopt; +} + +std::optional FleetTree::find_clone(const std::string& clone_id) const { + std::lock_guard lk(mu_); + for (const auto& n : nodes_) { + if (n.clone_id == clone_id) return n; + } + return std::nullopt; +} + +std::vector FleetTree::paint_rows(int max_cells) const { + std::lock_guard lk(mu_); + std::vector out; + if (nodes_.empty()) return out; + + // Pre-order: depth 0, then its children, then grandchildren. + std::vector order; + order.reserve(nodes_.size()); + auto append_children = [&](auto&& self, int parent_sid, int parent_depth) -> void { + std::vector kids; + for (int i = 0; i < static_cast(nodes_.size()); ++i) { + const auto& n = nodes_[static_cast(i)]; + if (n.depth != parent_depth + 1) continue; + if (parent_sid >= 0 && n.parent_stream_id >= 0 + && n.parent_stream_id != parent_sid) { + continue; + } + if (parent_sid < 0 && n.parent_stream_id >= 0) continue; + kids.push_back(i); + } + for (int i = 0; i < static_cast(kids.size()); ++i) { + order.push_back(kids[static_cast(i)]); + const auto& kid = nodes_[static_cast(kids[static_cast(i)])]; + if (kid.stream_id >= 0) self(self, kid.stream_id, kid.depth); + else self(self, -1, kid.depth); + } + }; + + std::vector roots; + for (int i = 0; i < static_cast(nodes_.size()); ++i) { + if (nodes_[static_cast(i)].depth == 0) roots.push_back(i); + } + if (roots.empty()) { + for (int i = 0; i < static_cast(nodes_.size()); ++i) { + if (nodes_[static_cast(i)].parent_stream_id < 0) + roots.push_back(i); + } + } + if (roots.empty()) { + for (int i = 0; i < static_cast(nodes_.size()); ++i) + roots.push_back(i); + } + + for (int r : roots) { + order.push_back(r); + const auto& root = nodes_[static_cast(r)]; + append_children(append_children, root.stream_id, root.depth); + } + + // Dedup in case a node matched both root and child walks. + std::vector seen(nodes_.size(), 0); + std::vector unique; + unique.reserve(order.size()); + for (int i : order) { + if (i < 0 || i >= static_cast(nodes_.size())) continue; + if (seen[static_cast(i)]) continue; + seen[static_cast(i)] = 1; + unique.push_back(i); + } + for (int i = 0; i < static_cast(nodes_.size()); ++i) { + if (!seen[static_cast(i)]) unique.push_back(i); + } + + auto last_sibling = [&](size_t idx_in_unique) -> bool { + if (idx_in_unique + 1 >= unique.size()) return true; + const int cur_i = unique[idx_in_unique]; + const int nxt_i = unique[idx_in_unique + 1]; + return nodes_[static_cast(nxt_i)].depth + <= nodes_[static_cast(cur_i)].depth; + }; + auto parent_last = [&](size_t idx_in_unique) -> bool { + const int cur_depth = nodes_[static_cast(unique[idx_in_unique])].depth; + if (cur_depth < 2) return true; + // Walk back to the depth-1 ancestor in the flattened order. + for (size_t j = idx_in_unique; j > 0; --j) { + const auto& cand = nodes_[static_cast(unique[j - 1])]; + if (cand.depth == 1) { + // Is there another depth-1 after this ancestor? + for (size_t k = j; k < unique.size(); ++k) { + if (nodes_[static_cast(unique[k])].depth == 1 + && k != j - 1) { + // later sibling of the parent exists if it appears after + // this subtree — approximate: parent is last if no + // other depth-1 follows this node at the same indent. + } + } + return last_sibling(j - 1); + } + if (cand.depth == 0) break; + } + return true; + }; + + out.reserve(unique.size()); + for (size_t u = 0; u < unique.size(); ++u) { + const auto& n = nodes_[static_cast(unique[u])]; + FleetPaintRow row; + row.key = node_key(n); + row.stream_id = n.stream_id; + row.clone_id = n.clone_id; + row.agent = n.agent; + row.depth = n.depth; + row.ephemeral = n.ephemeral; + row.running = n.running; + row.ended = n.ended; + row.ok = n.ok; + row.conversation_id = n.conversation_id; + row.pane_agent = n.pane_agent.empty() ? n.agent : n.pane_agent; + row.line = format_fleet_row(n, last_sibling(u), parent_last(u), max_cells); + out.push_back(std::move(row)); + } + return out; +} + +void FleetSidebarState::toggle_visible() { + std::lock_guard lk(mu_); + user_visible_ = !user_visible_; + if (!user_visible_) focused_ = false; +} + +void FleetSidebarState::set_visible(bool on) { + std::lock_guard lk(mu_); + user_visible_ = on; + if (!user_visible_) focused_ = false; +} + +bool FleetSidebarState::user_visible() const { + std::lock_guard lk(mu_); + return user_visible_; +} + +void FleetSidebarState::enter_focus() { + std::lock_guard lk(mu_); + if (!user_visible_) user_visible_ = true; + focused_ = true; +} + +void FleetSidebarState::exit_focus() { + std::lock_guard lk(mu_); + focused_ = false; +} + +bool FleetSidebarState::focused() const { + std::lock_guard lk(mu_); + return focused_; +} + +int FleetSidebarState::effective_width(int cols, int leading_cols, + bool has_content) const { + std::lock_guard lk(mu_); + if (!user_visible_ && !focused_) return 0; + if (!has_content && !focused_ && !user_visible_) return 0; + // Hidden unless there is a job or the user forced the pane on. + if (!has_content && !focused_) { + // user_visible defaults true — still require content so an idle + // session does not lose conversation columns to an empty Fleet box. + return 0; + } + const int available = cols - std::max(0, leading_cols); + return breakpoint_width(available); +} + +Rect FleetSidebarState::rect_for_terminal(int cols, int rows, int leading_cols, + bool has_content) const { + const int w = effective_width(cols, leading_cols, has_content); + if (w <= 0 || cols <= w + kOuterGutter || rows <= 0) return kEmptyRect; + return Rect{cols - w - kOuterGutter, 0, w, rows}; +} + +void FleetSidebarState::clamp_scroll_locked(int idx, int visible_rows, + int row_count) { + if (visible_rows < 1) visible_rows = 1; + last_visible_rows_ = visible_rows; + if (row_count <= 0) { + selected_ = 0; + scroll_offset_ = 0; + return; + } + if (idx < 0) idx = 0; + if (idx >= row_count) idx = row_count - 1; + selected_ = idx; + if (selected_ < scroll_offset_) scroll_offset_ = selected_; + if (selected_ >= scroll_offset_ + visible_rows) { + scroll_offset_ = selected_ - visible_rows + 1; + } + if (scroll_offset_ < 0) scroll_offset_ = 0; +} + +void FleetSidebarState::move_selection(int delta, int visible_rows) { + std::lock_guard lk(mu_); + const int count = std::max(last_row_count_, selected_ + 1); + clamp_scroll_locked(selected_ + delta, visible_rows, count); +} + +void FleetSidebarState::page_selection(int direction, int visible_rows) { + const int step = std::max(1, visible_rows - 1); + move_selection(direction < 0 ? -step : step, visible_rows); +} + +void FleetSidebarState::select_at_index(int index, int visible_rows) { + std::lock_guard lk(mu_); + const int count = std::max({last_row_count_, index + 1, 1}); + clamp_scroll_locked(index, visible_rows, count); +} + +int FleetSidebarState::selected_index() const { + std::lock_guard lk(mu_); + return selected_; +} + +int FleetSidebarState::scroll_offset() const { + std::lock_guard lk(mu_); + return scroll_offset_; +} + +FleetKey FleetSidebarState::handle_key(int key_byte, char csi_final, + const std::string& csi_params) { + if (key_byte == 'j' || key_byte == 'J') return FleetKey::Down; + if (key_byte == 'k' || key_byte == 'K') return FleetKey::Up; + if (key_byte == '\r' || key_byte == '\n') return FleetKey::Enter; + if (key_byte == 0x1B) { + if (csi_final == 'A') return FleetKey::Up; + if (csi_final == 'B') return FleetKey::Down; + if (csi_final == '~' && csi_params == "5") return FleetKey::PageUp; + if (csi_final == '~' && csi_params == "6") return FleetKey::PageDown; + if (csi_final == 0 && csi_params.empty()) return FleetKey::Escape; + return FleetKey::None; + } + return FleetKey::None; +} + +FleetSnapshot FleetSidebarState::snapshot(const FleetTree& tree, + int cols, int leading_cols) const { + FleetSnapshot snap; + const auto overlay = tree.overlay(); + const bool has_content = !tree.empty(); + { + std::lock_guard lk(mu_); + snap.focused = focused_; + snap.selected = selected_; + snap.scroll_offset = scroll_offset_; + snap.visible = user_visible_ && (has_content || focused_); + if (!user_visible_ && !focused_) snap.visible = false; + } + snap.request_id = tree.request_id(); + snap.overlay = overlay; + snap.total_input = tree.total_input(); + snap.total_output = tree.total_output(); + snap.has_live = tree.has_live(); + + const int w = effective_width(cols, leading_cols, has_content || snap.focused); + const int cells = std::max(8, w - 4); + auto ov_lines = format_fleet_overlay(overlay, cells); + snap.overlay_lines = static_cast(ov_lines.size()); + snap.rows = tree.paint_rows(cells); + + const int row_count = static_cast(snap.rows.size()); + { + std::lock_guard lk(mu_); + last_row_count_ = row_count; + if (row_count <= 0) { + selected_ = 0; + scroll_offset_ = 0; + snap.selected = 0; + snap.scroll_offset = 0; + } else { + if (selected_ >= row_count) selected_ = row_count - 1; + if (selected_ < 0) selected_ = 0; + snap.selected = selected_; + snap.scroll_offset = scroll_offset_; + } + } + return snap; +} + +} // namespace arbiter diff --git a/src/tui/opentui/fleet_frame.cpp b/src/tui/opentui/fleet_frame.cpp new file mode 100644 index 00000000..6d38b9cb --- /dev/null +++ b/src/tui/opentui/fleet_frame.cpp @@ -0,0 +1,191 @@ +#include "tui/opentui/fleet_frame.h" + +#include "styled_text.h" +#include "tui/opentui/engine.h" +#include "tui/opentui/rounded_box.h" +#include "tui/sidebar_format.h" +#include "tui/tui_design.h" + +#include +#include +#include + +namespace arbiter::opentui { + +namespace { + +constexpr std::uint32_t kAttrBold = 1u << 0; +constexpr int kBoxPad = 1; + +int cell_width(std::string_view s) { + return static_cast(arbiter::display_width(s)); +} + +void draw_text(OpenTuiHandle frame, + std::uint32_t x, + std::uint32_t y, + std::string_view text, + const TuiRgba& fg, + const TuiRgba& bg, + std::uint32_t attrs = 0) { + if (text.empty()) return; + bufferDrawText(frame, + text.data(), + static_cast(text.size()), + x, + y, + fg.data(), + bg.data(), + attrs); +} + +std::string trim_to_cells(std::string s, int max_cells) { + return arbiter::trim_to_display_cols(std::move(s), max_cells); +} + +int scroll_bottom_y(const Rect& pane_rect, int pane_input_rows, + int pane_bottom_pad_rows) { + const int bottom_pad = std::max(1, pane_bottom_pad_rows); + const int sep_top = pane_rect.y + pane_rect.h - bottom_pad - pane_input_rows + - TUI::kSepRows; + return sep_top - 1; +} + +} // namespace + +int fleet_sidebar_list_top(const Rect& r, int overlay_lines) { + // Blank row above the box, title border, blank row, optional overlay. + int y = r.y + 3; + if (overlay_lines > 0) y += overlay_lines + 1; // overlay + gap + return y; +} + +int fleet_sidebar_visible_rows(const Rect& sidebar_rect, + const Rect& pane_rect, + int pane_input_rows, + int overlay_lines, + int pane_bottom_pad_rows) { + const int top = fleet_sidebar_list_top(sidebar_rect, overlay_lines); + const int bottom = scroll_bottom_y(pane_rect, pane_input_rows, + pane_bottom_pad_rows); + return std::max(0, bottom - top + 1); +} + +void draw_fleet_sidebar(OpenTuiHandle frame, + const FleetSnapshot& snap, + const Rect& r, + const Rect& pane_rect, + int pane_input_rows, + int pane_bottom_pad_rows) { + if (frame == 0 || r.w <= 0 || r.h <= 0) return; + if (pane_rect.h <= 0) return; + + const TuiDesign& d = tui_design(); + const SidebarColors sc = tui_sidebar_colors(d); + const int bottom_pad = std::max(1, pane_bottom_pad_rows); + const int panel_top_y = r.y + 1; + const int input_bottom_y = pane_rect.y + pane_rect.h - bottom_pad - 1; + if (input_bottom_y < panel_top_y + 1) return; + + const int block_x = r.x; + const int block_w = r.w; + const int content_x = block_x + 1 + kBoxPad; + const int content_w = std::max(1, block_w - 2 - (kBoxPad * 2)); + const int block_h = std::max(2, input_bottom_y - panel_top_y + 1); + const TuiRgba& bg = d.bg.scroll; + const TuiRgba& border = snap.focused ? d.border.focus : d.text.muted; + const TuiRgba* title_fg = snap.focused ? &d.accent.primary : &d.accent.primary; + + draw_rounded_box(frame, + block_x, + panel_top_y, + block_w, + block_h, + border, + bg, + "Fleet", + title_fg); + + int y = panel_top_y + 2; + const int list_bottom = scroll_bottom_y(pane_rect, pane_input_rows, + pane_bottom_pad_rows); + + const auto overlay_lines = format_fleet_overlay(snap.overlay, content_w); + for (const auto& line : overlay_lines) { + if (y > list_bottom) return; + draw_text(frame, + static_cast(content_x), + static_cast(y), + trim_to_cells(line, content_w), + sc.body, + bg); + ++y; + } + if (!overlay_lines.empty() && y <= list_bottom) { + ++y; // gap before the tree + } + + if (snap.rows.empty()) { + if (y <= list_bottom) { + draw_text(frame, + static_cast(content_x), + static_cast(y), + trim_to_cells("(no agents)", content_w), + sc.label, + bg); + } + return; + } + + const int vis = std::max(0, list_bottom - y + 1); + int idx = std::max(0, snap.scroll_offset); + const int sel = snap.selected; + int painted = 0; + while (idx < static_cast(snap.rows.size()) && painted < vis) { + const auto& row = snap.rows[static_cast(idx)]; + const bool selected = snap.focused && idx == sel; + const TuiRgba row_bg = selected ? d.bg.status : bg; + const TuiRgba& fg = row.running ? d.accent.primary + : (row.ended && !row.ok) ? d.accent.error + : (row.ended) ? d.accent.success + : sc.body; + if (selected) { + bufferFillRect(frame, + static_cast(content_x - 1), + static_cast(y), + static_cast(std::max(1, content_w + 1)), + 1, + row_bg.data()); + } + draw_text(frame, + static_cast(content_x), + static_cast(y), + trim_to_cells(row.line, content_w), + selected ? d.text.primary : fg, + row_bg, + selected ? kAttrBold : 0); + ++y; + ++idx; + ++painted; + } + + if (snap.total_input + snap.total_output > 0) { + const int hint_y = pane_rect.y + pane_rect.h - 2; + if (hint_y > input_bottom_y) { + std::string tot = format_token_count(snap.total_input + snap.total_output); + const std::string label = trim_to_cells(tot, content_w); + const int ver_w = cell_width(label); + const int ver_x = block_x + block_w - 1 - ver_w; + if (ver_x >= block_x + 1 && ver_w > 0) { + draw_text(frame, + static_cast(ver_x), + static_cast(hint_y), + label, + sc.label, + bg); + } + } + } +} + +} // namespace arbiter::opentui diff --git a/tests/test_fleet.cpp b/tests/test_fleet.cpp new file mode 100644 index 00000000..fb53b93b --- /dev/null +++ b/tests/test_fleet.cpp @@ -0,0 +1,305 @@ +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" + +#include "tui/fleet.h" +#include "styled_text.h" + +#include + +using namespace arbiter; + +TEST_CASE("stream_start opens a row; stream_end marks it ended but keeps it") { + FleetTree t; + t.on_stream_start("index", 0, 0); + auto n = t.find_stream(0); + REQUIRE(n); + CHECK(n->agent == "index"); + CHECK(n->depth == 0); + CHECK(n->running); + CHECK_FALSE(n->ended); + + t.on_stream_end(0, true); + n = t.find_stream(0); + REQUIRE(n); + CHECK(n->ended); + CHECK(n->ok); + CHECK_FALSE(n->running); + CHECK_FALSE(t.empty()); +} + +TEST_CASE("parallel children indent by depth and close independently") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_stream_start("researcher_a", 1, 1); + t.on_stream_start("researcher_b", 2, 1); + t.on_stream_start("researcher_c", 3, 1); + + auto rows = t.paint_rows(28); + REQUIRE(rows.size() == 4); + CHECK(rows[0].agent == "index"); + CHECK(rows[0].depth == 0); + CHECK(rows[1].depth == 1); + CHECK(rows[2].depth == 1); + CHECK(rows[3].depth == 1); + + t.on_stream_end(2, true); + auto b = t.find_stream(2); + REQUIRE(b); + CHECK(b->ended); + CHECK(t.find_stream(1)->running); + CHECK(t.has_live()); +} + +TEST_CASE("depth-2 child attaches under a depth-1 parent") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_stream_start("lead", 1, 1); + t.on_stream_start("helper", 2, 2); + auto h = t.find_stream(2); + REQUIRE(h); + CHECK(h->parent_stream_id == 1); + auto rows = t.paint_rows(28); + REQUIRE(rows.size() == 3); + CHECK(rows[0].agent == "index"); + CHECK(rows[1].agent == "lead"); + CHECK(rows[2].agent == "helper"); + CHECK(rows[2].depth == 2); +} + +TEST_CASE("depth cap paints 2 even if an event claims 3") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_stream_start("too_deep", 1, 3); + auto n = t.find_stream(1); + REQUIRE(n); + CHECK(n->depth == 2); +} + +TEST_CASE("tool_call sets and clears the active tool") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_tool_call(0, "fetch", true); + CHECK(t.find_stream(0)->active_tool == "fetch"); + t.on_tool_call(0, "fetch", false); + CHECK(t.find_stream(0)->active_tool.empty()); +} + +TEST_CASE("token_usage aggregates per stream and across the tree") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_stream_start("researcher_a", 1, 1); + t.on_token_usage(0, 100, 20); + t.on_token_usage(0, 50, 10); + t.on_token_usage(1, 800, 120); + CHECK(t.find_stream(0)->input_tokens == 150); + CHECK(t.find_stream(0)->output_tokens == 30); + CHECK(t.find_stream(1)->input_tokens == 800); + CHECK(t.total_input() == 950); + CHECK(t.total_output() == 150); +} + +TEST_CASE("agent.spawned opens a JIT row; agent.teardown removes it") { + FleetTree t; + t.on_agent_spawned("writer", "clone-1", {"readme"}); + auto n = t.find_clone("clone-1"); + REQUIRE(n); + CHECK(n->ephemeral); + CHECK(n->running); + CHECK(n->agent == "writer"); + CHECK(n->clauses.size() == 1); + + auto rows = t.paint_rows(28); + REQUIRE_FALSE(rows.empty()); + CHECK(rows[0].ephemeral); + CHECK(rows[0].line.find("\u25C7") != std::string::npos); // ◇ + + t.on_agent_teardown("clone-1"); + CHECK_FALSE(t.find_clone("clone-1")); + CHECK(t.nodes().empty()); +} + +TEST_CASE("stream_start after spawn attaches stream_id to the clone") { + FleetTree t; + t.on_agent_spawned("writer", "clone-9", {"license"}); + t.on_stream_start("writer", 4, 1); + auto n = t.find_clone("clone-9"); + REQUIRE(n); + CHECK(n->stream_id == 4); + CHECK(t.find_stream(4)->ephemeral); +} + +TEST_CASE("reconcile.delta overlay tracks residual vs held and wave") { + FleetTree t; + t.on_reconcile_delta(3, 2, false, 1, "req-1"); + auto ov = t.overlay(); + CHECK(ov.active); + CHECK(ov.residual == 3); + CHECK(ov.held == 2); + CHECK(ov.wave == 1); + CHECK_FALSE(ov.empty); + auto lines = format_fleet_overlay(ov, 24); + REQUIRE(lines.size() == 2); + CHECK(lines[0].find("wave 1") != std::string::npos); + CHECK(lines[1].find("3 residual") != std::string::npos); + CHECK(lines[1].find("2 held") != std::string::npos); +} + +TEST_CASE("SSE fixtures drive the same transitions") { + FleetTree t; + t.ingest_sse("request_received", + R"({"request_id":"r1","agent":"index"})"); + t.ingest_sse("stream_start", + R"({"agent":"index","stream_id":0,"depth":0})"); + t.ingest_sse("stream_start", + R"({"agent":"a","stream_id":1,"depth":1})"); + t.ingest_sse("stream_start", + R"({"agent":"b","stream_id":2,"depth":1})"); + t.ingest_sse("agent_start", + R"({"agent":"a","stream_id":1,"depth":1})"); + t.ingest_sse("token_usage", + R"({"stream_id":1,"input_tokens":10,"output_tokens":4})"); + t.ingest_sse("stream_end", + R"({"stream_id":1,"ok":true})"); + t.ingest_sse("stream_end", + R"({"stream_id":2,"ok":false})"); + + CHECK(t.find_stream(1)->ended); + CHECK(t.find_stream(1)->ok); + CHECK_FALSE(t.find_stream(2)->ok); + CHECK(t.total_input() == 10); + CHECK(t.total_output() == 4); +} + +TEST_CASE("SSE agent.spawned / teardown and reconcile.delta") { + FleetTree t; + t.ingest_sse("request_received", + R"({"request_id":"rec-1","agent":"reconcile"})"); + t.ingest_sse("reconcile.delta", + R"({"residual":[{"id":"c1"}],"held":[{"id":"c2"},{"id":"c3"}],"empty":false,"wave":0})"); + t.ingest_sse("agent.spawned", + R"({"agent":"nexus","clone_id":"cl-a","clauses":["c1"]})"); + CHECK(t.overlay().residual == 1); + CHECK(t.overlay().held == 2); + REQUIRE(t.find_clone("cl-a")); + t.ingest_sse("agent.teardown", R"({"agent":"nexus","clone_id":"cl-a"})"); + CHECK_FALSE(t.find_clone("cl-a")); +} + +TEST_CASE("new request_id clears the previous job") { + FleetTree t; + t.on_stream_start("index", 0, 0, "r1"); + t.on_stream_start("child", 1, 1, "r1"); + CHECK(t.nodes().size() == 2); + t.on_request_received("r2", "index"); + CHECK(t.nodes().empty()); +} + +TEST_CASE("format_fleet_row is scannable and width-capped") { + FleetNode n; + n.agent = "researcher_a"; + n.depth = 1; + n.running = true; + n.active_tool = "fetch"; + n.input_tokens = 1200; + const std::string line = format_fleet_row(n, true, true, 24); + CHECK(line.size() > 0); + CHECK(static_cast(display_width(line)) <= 24); + CHECK(line.find("researcher_a") != std::string::npos); +} + +TEST_CASE("fleet sidebar stays hidden until there is a job") { + FleetTree t; + FleetSidebarState sb; + CHECK(sb.effective_width(120, 0, false) == 0); + t.on_stream_start("index", 0, 0); + CHECK(sb.effective_width(120, 0, true) == 28); + CHECK(sb.effective_width(80, 0, true) == 0); + sb.toggle_visible(); + CHECK(sb.effective_width(120, 0, true) == 0); +} + +TEST_CASE("done marks leftover running rows ended") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_stream_start("a", 1, 1); + t.on_done(); + CHECK(t.find_stream(0)->ended); + CHECK(t.find_stream(1)->ended); + CHECK_FALSE(t.has_live()); +} + +TEST_CASE("five-agent parallel job paints a depth-indented tree") { + FleetTree t; + t.on_stream_start("index", 0, 0); + t.on_stream_start("researcher_a", 1, 1); + t.on_stream_start("researcher_b", 2, 1); + t.on_stream_start("writer", 3, 1); + t.on_stream_start("reviewer", 4, 1); + t.on_tool_call(1, "fetch", true); + t.on_token_usage(2, 400, 80); + t.on_token_usage(3, 200, 40); + t.on_stream_end(4, true); + + auto rows = t.paint_rows(28); + REQUIRE(rows.size() == 5); + CHECK(rows[0].agent == "index"); + CHECK(rows[0].depth == 0); + CHECK(rows[1].depth == 1); + CHECK(rows[2].depth == 1); + CHECK(rows[3].depth == 1); + CHECK(rows[4].depth == 1); + CHECK(rows[1].running); + CHECK(rows[1].line.find("fetch") != std::string::npos); + CHECK(rows[4].ended); + CHECK(t.has_live()); + CHECK(t.total_input() == 600); + CHECK(t.total_output() == 120); +} + +TEST_CASE("ensure wave overlay sits with JIT spawn and teardown rows") { + FleetTree t; + t.on_request_received("rec-9", "reconcile"); + t.on_reconcile_delta(3, 2, false, 2, "rec-9"); + t.on_agent_spawned("writer", "cl-1", {"readme"}); + t.on_agent_spawned("reviewer", "cl-2", {"license"}); + t.on_stream_start("writer", 7, 1); + + auto ov = t.overlay(); + CHECK(ov.active); + CHECK(ov.mode == "ensure"); + CHECK(ov.wave == 2); + CHECK(ov.residual == 3); + CHECK(ov.held == 2); + + auto rows = t.paint_rows(28); + REQUIRE(rows.size() == 2); + CHECK(rows[0].ephemeral); + CHECK(rows[0].stream_id == 7); + CHECK(rows[1].ephemeral); + CHECK(rows[1].clone_id == "cl-2"); + + t.on_agent_teardown("cl-1"); + CHECK_FALSE(t.find_clone("cl-1")); + CHECK(t.find_clone("cl-2")); + CHECK(t.paint_rows(28).size() == 1); +} + +TEST_CASE("sidebar snapshot clamps selection to live row count") { + FleetTree t; + FleetSidebarState sb; + t.on_stream_start("index", 0, 0); + t.on_stream_start("a", 1, 1); + t.on_stream_start("b", 2, 1); + sb.select_at_index(2, 10); + auto snap = sb.snapshot(t, 120, 0); + CHECK(snap.visible); + CHECK(snap.selected == 2); + CHECK(snap.rows.size() == 3); + + t.on_agent_teardown(""); // no-op + t.clear(); + snap = sb.snapshot(t, 120, 0); + CHECK(snap.selected == 0); + CHECK(snap.rows.empty()); + CHECK_FALSE(snap.visible); +} diff --git a/tests/test_mouse_decode.cpp b/tests/test_mouse_decode.cpp index 885f0689..a5800bb3 100644 --- a/tests/test_mouse_decode.cpp +++ b/tests/test_mouse_decode.cpp @@ -148,3 +148,17 @@ TEST_CASE("allocate_weighted_sizes zero available yields all zeros") { CHECK(sizes[1] == 0); CHECK(sizes[2] == 0); } + +TEST_CASE("fleet_sidebar_row_at skips overlay and uses height-1 rows") { + using arbiter::opentui::fleet_sidebar_row_at; + Rect r{0, 0, 28, 40}; + // No overlay: list starts at y+3 = 3. + CHECK(fleet_sidebar_row_at(r, 3, 0, 5, 0, 3) == 0); + CHECK(fleet_sidebar_row_at(r, 4, 0, 5, 0, 3) == 1); + CHECK(fleet_sidebar_row_at(r, 2, 0, 5, 0, 3) == -1); + // Overlay of 2 lines + gap: list starts at 3+2+1 = 6. + CHECK(fleet_sidebar_row_at(r, 6, 0, 4, 2, 3) == 0); + CHECK(fleet_sidebar_row_at(r, 5, 0, 4, 2, 3) == -1); + CHECK(fleet_sidebar_row_at(r, 7, 1, 4, 2, 3) == 2); + CHECK(fleet_sidebar_row_at(r, 10, 0, 4, 2, 3) == -1); +}