From c0b51a99ddbbe01c179429c1d5a04e645467b691 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 16:31:06 -0400 Subject: [PATCH 01/12] Add TUI orchestration tab bar specs Co-Authored-By: Oz --- .../PRODUCT.md | 105 ++++++++++++ .../TECH.md | 151 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md create mode 100644 specs/code-1822-tui-orchestration-tab-bar/TECH.md diff --git a/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md new file mode 100644 index 00000000000..c5ed342ec25 --- /dev/null +++ b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md @@ -0,0 +1,105 @@ +# PRODUCT: TUI Orchestration Conversation Tab Bar +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Component: [specs/code-1822-tui-tab-bar-component/PRODUCT.md](../code-1822-tui-tab-bar-component/PRODUCT.md) + +## Summary +The Warp TUI shows a tab bar for an orchestration tree so users can see and switch among the orchestrator and its navigable child-agent conversations. The bar supports keyboard and mouse navigation, preserves each session's state, and paginates predictably when all child tabs do not fit. + +## Figma +- Orchestration bar visible but unfocused: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-20498&m=dev +- Orchestration bar focused: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=806-19947&m=dev +- Focused bar with a truncated tab and overflow: https://www.figma.com/design/yg5nbPZuGoAszHS3Rhvehu/TUI?node-id=881-21464&m=dev + +## Goals +- Make every navigable conversation in a local TUI orchestration tree directly reachable without opening the conversation picker. +- Keep keyboard navigation fast while preserving mouse access to every visible tab and overflow control. + +## Non-goals +- Pinning or unpinning agents from the TUI. Existing pin state may affect ordering for parity with the GUI. +- Showing conversations that do not have a retained, navigable TUI session. +- Restoring or materializing a missing session when its conversation would otherwise appear in the tree. +- Killing a selected child with `Ctrl+C`. This will be implemented in a follow-up PR above the tab-bar PR; this PR retains the existing session-level `Ctrl+C` behavior and does not advertise killing in the focused footer. +- Adding GUI-style per-agent menus such as opening a child in another pane or tab. + +## Behavior +### Visibility and contents +1. The tab bar appears at the top of the normal TUI session surface when the focused conversation belongs to an orchestration tree with at least one navigable child-agent conversation. +2. The same orchestration tree is shown while the orchestrator or any navigable descendant session is focused. Switching among members never changes which tree the bar represents. +3. The bar contains: + - The `Agents:` label. + - An optional main tab for the orchestrator, fixed at the leading edge. + - A divider after the orchestrator. + - One tab for each navigable child-agent conversation. +4. A conversation is included only while it maps to a retained TUI session that can be focused immediately. Failed, synthetic, unloaded, removed, or otherwise non-navigable conversations are omitted. +5. The orchestrator tab is labeled `orchestrator`. Each child tab uses the child's stable agent identity glyph and agent name. +6. The active conversation is the selected tab. There is no second pending or highlighted selection distinct from the active conversation. +7. When the bar is unfocused, the selected tab remains visibly emphasized without using the focused selection background. When the bar is focused, the selected tab uses the focused magenta selection treatment from the designs. +8. Tab colors and text styles adapt to the active terminal theme. The bar remains legible in dark, light, and custom themes without fixed foreground or background colors. + +### Dynamic ordering +9. Child tabs use the same canonical ordering as the GUI orchestration pill bar. The TUI must not maintain a separate ordering policy. +10. The GUI ordering is applied exactly: + - Pinned children precede unpinned children. + - Within each pin bucket, blocked children come first. + - Errored children follow blocked children. + - In-progress, transient-error, and waiting children follow errored children. + - Successful and cancelled children follow active children, ordered by most recent modification first. + - Spawn order breaks remaining ties. +11. The row reorders as status, recency, or persisted pin state changes. Labels, selection, keyboard navigation, and pagination all use the same updated order. +12. Reordering never changes the active conversation by itself. + +### Entering and leaving keyboard focus +13. While an orchestration tab bar is available and the input is focused, the normal footer shows the `Shift + ↑ sub-agents` hint from the design. +14. `Shift+Up` focuses the tab bar only when: + - The input cursor is on the first visual row of the wrapped input (display row zero), and + - The input has no active text selection. +15. On any other input row, or while text is selected, `Shift+Up` keeps its existing text-selection behavior. +16. Focusing the bar initially selects the already-active conversation; focus alone never switches sessions. +17. `Shift+Down` leaves the tab bar and focuses the active session's normal interaction target. This is normally the input, but an active blocking interaction or full-screen terminal surface retains its existing focus precedence. +18. While the bar is focused, its footer shows: `Tab or ← → to navigate Shift + ← → to go to start/end Shift + ↓ to send a message`. + +### Keyboard navigation +19. While the bar is focused, `Right` and `Tab` immediately switch to the next conversation in the current canonical order. +20. While the bar is focused, `Left` and `Shift+Tab` immediately switch to the previous conversation in the current canonical order. +21. Previous/next navigation wraps across the complete sequence of the orchestrator followed by the ordered child tabs. +22. `Shift+Left` switches to the first child-agent conversation. +23. `Shift+Right` switches to the last child-agent conversation. +24. The orchestrator is excluded from the `Shift+Left` and `Shift+Right` destinations. +25. A keyboard-driven session switch keeps the tab bar focused in the target session so repeated navigation continues without another `Shift+Up`. +26. If an explicitly selected overflow page does not contain the active tab: + - `Right` switches to the first visible child tab on that page. + - `Left` switches to the last visible child tab on that page. + - `Tab` behaves like `Right`, and `Shift+Tab` behaves like `Left`. +27. Once keyboard navigation switches conversations, the newly active tab becomes the navigation origin and is kept visible. + +### Mouse navigation +28. Every visible orchestrator or child tab is clickable whether or not the tab bar currently has keyboard focus. +29. Clicking a tab immediately switches to that conversation's retained session. +30. If the bar was focused before the click, it remains focused after the switch. +31. If the bar was not focused before the click, the target session's normal interaction target receives focus. The click does not force keyboard focus onto the tab bar. +32. Clicking the already-active tab does not reset its transcript, input draft, cursor, selection, scroll position, or running work. + +### Width, truncation, and overflow +33. The orchestrator remains fixed at the leading edge. Only the child-tab region paginates. +34. The orchestration bar configures the reusable component with a maximum label width of 20 terminal display cells, including the ellipsis. +35. A child label wider than its maximum is truncated with `...`. Truncation is display-cell aware so wide Unicode characters never corrupt alignment. +36. The final visible tab on a page may be truncated further when needed to preserve the applicable overflow arrow, matching the supplied narrow/overflow design. +37. A right overflow arrow appears when later child tabs are hidden. A left overflow arrow appears when earlier child tabs are hidden. An arrow that has no page in its direction is not shown as actionable. +38. Clicking an overflow arrow changes only the visible child page: + - It does not switch conversations. + - It does not select a tab. + - It does not change keyboard focus. In particular, clicking an arrow while the input is focused leaves the input focused. +39. The selected page is shared across all sessions in the same orchestration tree. Switching conversations does not reset it. +40. The active tab is automatically revealed after session selection or dynamic reordering unless the user explicitly paged away with an overflow arrow. +41. After an explicit overflow click, the chosen page remains visible even if the active tab is on another page. The next keyboard selection follows (26). +42. Selecting a tab clears the explicit paged-away state and keeps the selected tab visible. +43. Terminal resizing recomputes which complete or truncated tabs fit, preserves a valid page when possible, and never clips an overflow arrow or writes outside the row. +44. At very narrow widths, the bar prioritizes the `Agents:` label, orchestrator tab, divider, and an applicable overflow control before child labels. It remains a single row. + +### Session and lifecycle behavior +45. Switching tabs focuses the existing retained session; it does not rebuild, restore, clone, or move the conversation. +46. Each session preserves its own transcript position, input draft, cursor, text selection, input scroll, blocking interaction, PTY state, and running agent state while another tab is active. +47. A newly materialized navigable child appears without requiring the user to leave the current session. +48. A removed or failed session disappears without leaving a clickable stale tab. The remaining tabs, page boundaries, and selection are recomputed immediately. +49. Status-driven reordering and child additions/removals never steal keyboard focus from the input or tab bar. +50. The tab bar remains available above normal blocking interactions. Existing alternate-screen behavior continues to own the complete terminal surface while active. diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md new file mode 100644 index 00000000000..3bac5ec604b --- /dev/null +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -0,0 +1,151 @@ +# TECH: TUI Orchestration Conversation Tab Bar +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md](./PRODUCT.md) +Component: [specs/code-1822-tui-tab-bar-component/TECH.md](../code-1822-tui-tab-bar-component/TECH.md) +Inspected commit: `0bfc788907e2b27c3488c581fee92e2f67a18ef1` + +## Context +The preceding stack gives every native local child a retained full TUI session, but only the focused session is projected and there is no navigation chrome between related sessions: +- [`crates/warp_tui/src/sessions.rs (20-175) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/sessions.rs#L20-L175) — `TuiSessionId`, retained `TuiSession` views/managers, and `TuiSessions::focus_session`. `FocusChanged` updates projection state but currently has no focus-routing subscriber. +- [`crates/warp_tui/src/root_view.rs (36-82) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/root_view.rs#L36-L82) — the root renders and exposes only `TuiSessions::focused_session()`, so switching sessions is a full-view swap rather than transcript replacement. +- [`crates/warp_tui/src/orchestration_model.rs (30-266) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/orchestration_model.rs#L30-L266) — `TuiOrchestrationModel` materializes local native children, retains the child-conversation/session mapping needed for failed-launch cleanup, and subscribes to every session's `StartAgentExecutor`. +- [`app/src/ai/blocklist/history_model.rs (403-568) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/history_model.rs#L403-L568) — the shared history model owns loaded conversations, parent resolution, and the restart-durable `children_by_parent` index. +- [`app/src/ai/blocklist/history_model.rs (963-1018) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/history_model.rs#L963-L1018) — `terminal_surface_id_for_conversation` and `active_conversation` provide the authoritative conversation↔surface mapping. This mapping remains correct when a session restores a different conversation, unlike `TuiOrchestrationModel::child_session_by_conversation`. + +The GUI already owns the canonical orchestration ordering and tree traversal: +- [`app/src/ai/blocklist/orchestration_topology.rs (107-180) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/orchestration_topology.rs#L107-L180) — `descendant_conversation_ids_in_pill_order` implements pin/status/recency/spawn ordering, and adjacent navigation establishes wraparound semantics. +- [`app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs (600-670) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs#L600-L670) — the GUI renders an orchestrator first, then descendants in the shared canonical order. Its current active-child lookup climbs one parent; PRODUCT (2) requires the TUI to climb to the top orchestration root so every member session shows the same complete tree. + +The focused session already owns the relevant input and focus transitions: +- [`crates/warp_tui/src/terminal_session_view.rs (746-1037) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/terminal_session_view.rs#L746-L1037) — input construction, history subscriptions, background-session focus guards, blocker precedence, and input restoration. +- [`crates/warp_tui/src/terminal_session_view.rs (2166-2342) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/terminal_session_view.rs#L2166-L2342) — the current session render tree, including alt-screen replacement, transcript, blocker handling, input, and footer. +- [`crates/warp_tui/src/input/view.rs (61-170) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/input/view.rs#L61-L170) and [`crates/warp_tui/src/input/view.rs (584-675) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/input/view.rs#L584-L675) — `Shift+Up` currently dispatches `SelectUp` unconditionally and the input applies vertical selection directly. +- [`crates/warp_tui/src/editor_element.rs (271-408) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/crates/warp_tui/src/editor_element.rs#L271-L408) — the char-cell display lattice is the source of truth for wrapped visual rows and cursor placement. + +## Proposed changes +### 1. Export and reuse the GUI topology policy +Re-export `descendant_conversation_ids_in_pill_order` through `app/src/tui_export.rs`; do not duplicate its sort keys in `warp_tui`. + +`TuiOrchestrationModel` reuses the GUI's descendant ordering while resolving the complete tree required by PRODUCT (2): +1. Receive the focused session's selected conversation ID from `TuiTerminalSessionView`; do not substitute the history model's “most recently streamed” active pointer for the input selection. +2. Follow resolved parent conversation IDs until reaching the top orchestration root, with a visited set to make malformed cycles fail closed. +3. Read all descendants from `descendant_conversation_ids_in_pill_order`. +4. Filter the orchestrator and descendants through the authoritative history surface mapping and a retained-session lookup. +5. Omit the bar unless the orchestrator and at least one child are immediately navigable. + +Filtering occurs after canonical ordering so the relative order of navigable descendants remains identical to the GUI. The model does not use `child_session_by_conversation` for rendering or navigation; that map remains narrowly scoped to failed-child cleanup. + +Add `TuiSessions::session_id_for_surface(EntityId)` and a narrow session-view lookup so the model can compose `BlocklistAIHistoryModel::terminal_surface_id_for_conversation` with retained TUI ownership. This makes restored conversations self-correcting without another conversation/session index. + +### 2. Orchestration-owned tab state and actions +Extend `TuiOrchestrationModel` from launch coordination to the owner of semantic orchestration tab state. Add a per-orchestrator entry containing: +- The child page-anchor conversation ID. +- Whether the user explicitly paged away from the active tab. +- Stable child identity assignments computed in spawn order, independently of the dynamic display order. + +Expose a plain-data snapshot for `TuiTerminalSessionView` containing the main tab, ordered child tabs, active conversation ID, identity indices, page anchor, and explicit-page state. The session view maps identity indices and semantic tab states to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. + +Model operations: +- `select_conversation` resolves the target's retained session, calls `TuiSessions::focus_session`, clears explicit paging, and anchors the page so the selected child is visible. +- `select_first_child`/`select_last_child` exclude the orchestrator. +- `set_explicit_page` records the page anchor emitted by the tab bar and marks it explicit. It never calls `focus_session`. +- A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. +- Removed roots prune their per-tree state; removed children prune identity entries and clamp invalid anchors. + +Subscribe to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, pin order, or active selection, and to all `TuiSessionsEvent` variants. Emit a narrow `TabBarChanged` event so every retained session view in the affected tree redraws. These subscriptions also make child materialization/removal visible without polling. + +### 3. Session switching and focus handoff +The model owns tab selection and page state, but actual responder focus remains view-owned because only a `ViewContext` can focus a view. + +Add orchestration-tab actions and a keymap-context flag to `TuiTerminalSessionView`: +- `FocusTabs` +- `FocusInput` +- `SelectPrevious` / `SelectNext` +- `SelectFirstChild` / `SelectLastChild` +- `SelectConversation(AIConversationId)` +- `SetPage(AIConversationId)` + +Register `Left`, `Right`, `Tab`, `Shift+Tab`, `Shift+Left`, `Shift+Right`, and `Shift+Down` only under the tab-focused context. Keep the existing session-level `Ctrl+C` binding unchanged; the focused footer omits kill copy in this PR. + +`SelectPrevious` and `SelectNext` are forwarded to the tab-bar component. The component resolves the target from its private settled layout and emits `SelectConversation`; the session view then delegates that semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. + +Track whether the session view itself currently owns tab-bar focus. A switch performs two coordinated operations: +1. Ask `TuiOrchestrationModel` to select/focus the target retained session. +2. Focus the target `TuiTerminalSessionView` and set its tab-focused mode for keyboard switches or already-focused mouse switches; otherwise invoke a target-session helper that applies existing precedence: alternate screen, active blocker, then input. + +This closes the current `TuiSessionsEvent::FocusChanged` projection/focus gap for tab-driven switches without letting a background session steal focus. Add an explicit test that the projected root child and responder chain move together. + +### 4. Input boundary handoff +Add a generic `FocusAboveRequested` event/availability flag to `TuiInputView`; do not import orchestration types into the input module. + +When handling `SelectUp`, the input requests focus above only if: +- Its owner marked an above-target available. +- The selection is empty. +- The cursor's display point is display row zero. + +Use the editor's char-cell render state and `display_lattice(...).offset_to_display_point(...)`, the same projection used by `TuiEditorElement`, rather than counting newlines or duplicating soft-wrap math. If any condition is false, call the existing `select_up` path unchanged. `TuiTerminalSessionView` updates availability from the orchestration snapshot and maps `FocusAboveRequested` to `FocusTabs`. + +### 5. Session rendering and owner callbacks +Render the generalized bar from `TuiTerminalSessionView`, matching the architecture that each rendered terminal session asks `TuiOrchestrationModel` for its current tree. + +Restructure the normal render tree into: +- A full-width optional orchestration tab row. +- The existing horizontally padded transcript/input/footer column beneath it. + +The alt-screen early return remains unchanged and therefore owns the complete pane. Blocking cards remain inside the normal session column, so the tab bar stays available above them. + +When the bar is unfocused, render the normal footer with the conditional `Shift + ↑ sub-agents` hint. When focused, keep the input visible but blurred and replace the normal footer with the PRODUCT (18) navigation footer. + +Tab and overflow callbacks dispatch typed actions back to the owning `TuiTerminalSessionView`: +- Tab clicks select immediately. The owner preserves tab focus only if it was already active; otherwise it focuses the target session's normal interaction target. +- Overflow clicks carry the page anchor computed by the component to the model's `set_explicit_page` operation and never call a focus API, so input focus remains unchanged. + +### 6. Styling and identities +Add semantic orchestration-tab style recipes to `TuiUiBuilder` rather than embedding raw colors in the element. Supply those styles to `TuiTabBar`: +- Magenta-tinted bar background. +- Focused selected tab background and contrasting text. +- Unfocused active-tab emphasis. +- Muted divider, overflow, and non-selected labels. + +Reuse `agent_identity_palette` and `assign_agent_identity_indices`. Assign identities from stable spawn order, then project them into dynamic pill order so a status change never changes a child's glyph or color. The generic tab element receives only the resulting optional leading glyph/style. + +## Testing and validation +### Integration tests +Extend `orchestration_model_tests.rs`, `sessions_tests.rs`, `terminal_session_view_tests.rs`, and `input/view_tests.rs`: +- Exact reuse of GUI pin/status/error/active/done-recency/spawn ordering after filtering non-navigable sessions — PRODUCT (4, 9-12). +- The same root and tabs from orchestrator and child sessions; newly materialized and removed sessions — PRODUCT (1-5, 47-49). +- Shared per-root page state, explicit-page persistence, active reveal, and first/last-visible keyboard origin — PRODUCT (26-27, 39-42). +- Wrapped-row and selection-aware `Shift+Up` behavior; `Shift+Down` restoration — PRODUCT (13-18). +- Wrapped adjacent navigation, child-only first/last jumps, and tab focus preserved across full-session projection — PRODUCT (19-25). +- Focused and unfocused mouse switches, no-op active clicks, overflow clicks that neither select nor move focus, and blocker precedence — PRODUCT (28-32, 38, 45-46). +- Tab bar above normal blockers and omitted by alt-screen replacement — PRODUCT (50). +- Existing `Ctrl+C` behavior and the absence of kill-agent footer copy remain regression-covered. + +### Live verification +Use `tui-testing` render-to-lines coverage for 40-, 80-, and 132-column widths and dark/light themes. Then use `tui-verify-change` with `./script/run-tui` to verify: +- `Shift+Up` from wrapped and selected input states. +- Continuous keyboard switching while child agents reorder dynamically. +- Mouse switching from input focus and tab focus. +- Multiple overflow pages, explicit paging with the active tab off-page, and terminal resize. +- Input drafts and running child state preserved across repeated switches. + +Run focused validation first: +- `cargo nextest run -p warp_tui orchestration` +- `cargo nextest run -p warp_tui terminal_session` +- `cargo nextest run -p warp_tui input` + +Before PR submission, run `./script/format`, the repository-prescribed Clippy command, and `./script/presubmit`. + +## Parallelization +The reusable component lands in the lower `harry/code-1822-tui-tab-bar-component` PR. Do not split this integration PR across child agents: topology snapshots, shared page state, full-session focus switching, input handoff, and render tests cross the same TUI views and should be implemented coherently. Implement sequentially in one checkout: model/session lookup and paging, input/focus integration, then rendering and end-to-end validation. Long-running focused test groups may run concurrently after implementation. + +## Risks and mitigations +- **Dynamic order versus explicit paging:** store page anchors by conversation ID, not index, and distinguish automatic reveal from explicit paging. Re-resolve every anchor against the fresh canonical order. +- **Layout/event drift:** the tab bar resolves navigation against the same private settled range and ordered snapshot it rendered, then emits a stable tab ID. The orchestration model resolves that ID against fresh state and no-ops unavailable targets. +- **Focus without projection:** make every tab selection update `TuiSessions` and the target view's focus mode in one owner action; test both root projection and responder focus. +- **Stale child-session cache:** use history's authoritative conversation→surface mapping plus `TuiSessions` lookup for navigation. Keep the existing child map only for launch cleanup. +- **Identity changes during reorder:** assign identities in stable spawn order, independent of canonical display order. +- **Terminal-model deadlock:** tab membership, paging, and focus require no new `TerminalModel::lock()` call; visual-row detection reads the editor's char-cell render state instead. + +## Follow-ups +- Add `Ctrl+C` to kill the selected child while the tab bar is focused, including its focused-footer hint and terminal-state behavior, in the next PR above this one. From 15fab542972ab639225f61db4c98461f0678944c Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 17:14:29 -0400 Subject: [PATCH 02/12] Implement TUI orchestration conversation tabs Co-Authored-By: Oz --- app/src/tui_export.rs | 1 + crates/warp_tui/src/input/view.rs | 40 ++ crates/warp_tui/src/input/view_tests.rs | 65 +++ crates/warp_tui/src/orchestration_model.rs | 243 ++++++++++- .../warp_tui/src/orchestration_model_tests.rs | 113 ++++- crates/warp_tui/src/session_registry.rs | 8 + crates/warp_tui/src/session_registry_tests.rs | 6 + crates/warp_tui/src/terminal_session_view.rs | 412 +++++++++++++++++- crates/warp_tui/src/tui_builder.rs | 37 ++ .../TECH.md | 8 +- 10 files changed, 904 insertions(+), 29 deletions(-) diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 51d6dcfaa6e..6dfebd857f3 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -68,6 +68,7 @@ pub use crate::ai::blocklist::orchestration_event_streamer::{ register_agent_event_consumer, unregister_agent_event_consumer, }; pub use crate::ai::blocklist::orchestration_topology::{ + descendant_conversation_ids_in_pill_order, descendant_conversation_ids_in_spawn_order, orchestrator_agent_id_for_conversation, resolve_orchestration_participant, OrchestrationParticipantKind, ResolvedOrchestrationParticipant, }; diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index f9973dc0f9f..927e2b20a65 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -115,6 +115,8 @@ pub enum TuiInputViewEvent { AcceptedModel(LLMId), /// The user selected an action from the MCP menu. AcceptedMcp(TuiMcpAction), + /// Shift+Up reached an available focus target above the first visual row. + FocusAboveRequested, } // ───────────────────────────────────────────────────────────────────────────── @@ -170,6 +172,8 @@ pub struct TuiInputView { /// construction always provides this; isolated input tests omit it. transcript: Option>, keyboard_enhancement_supported: bool, + /// Whether Shift+Up may hand focus to an owner-provided region above. + focus_above_available: bool, } impl Entity for TuiInputView { @@ -247,6 +251,19 @@ impl TuiInputView { focused: false, transcript, keyboard_enhancement_supported: false, + focus_above_available: false, + } + } + + /// Enables or disables the first-row Shift+Up focus handoff. + pub(crate) fn set_focus_above_available( + &mut self, + available: bool, + ctx: &mut ViewContext, + ) { + if self.focus_above_available != available { + self.focus_above_available = available; + ctx.notify(); } } @@ -463,6 +480,11 @@ impl TypedActionView for TuiInputView { TuiEditorInteractionOutcome::FollowCursor } TuiInputAction::EditorCommand(command) => { + if matches!(*command, TuiEditorCommand::SelectUp) && self.can_focus_above(ctx) { + ctx.emit(TuiInputViewEvent::FocusAboveRequested); + ctx.notify(); + return; + } // Only open the conversation list from normal agent input; in // `!` shell mode the `!` prefix is not part of `plain_text`, so // an empty shell command would otherwise trip this branch and @@ -562,6 +584,24 @@ impl TuiInputView { self.cursor_offset(ctx).as_usize() <= 1 && self.selection_range(ctx).is_none() } + /// Whether Shift+Up should leave the input instead of extending selection. + fn can_focus_above(&self, ctx: &AppContext) -> bool { + if !self.focus_above_available || self.selection_range(ctx).is_some() { + return false; + } + let model = self.model.as_ref(ctx); + let render = model.render_state().as_ref(ctx); + let Some(char_cell) = render.char_cell() else { + return false; + }; + let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); + let hidden = char_cell.hidden_line_ranges(ctx); + char_cell + .display_lattice(&hidden) + .offset_to_display_point(cursor_offset) + .is_some_and(|point| point.row == 0) + } + // ── Scroll ───────────────────────────────────────────────────────────── // // The scroll offset and its clamping/follow policy live on the char-cell diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index f66230dda13..de97c893d3f 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -624,6 +624,71 @@ fn dispatch(view: &ViewHandle, ctx: &mut AppContext, actions: &[Tu }); } +#[test] +fn shift_up_requests_focus_above_only_on_first_row_without_selection() { + App::test((), |mut app| async move { + let (view, requests) = app.update(|ctx| { + let view = build_view(ctx); + view.update(ctx, |view, ctx| { + view.set_focus_above_available(true, ctx); + }); + let requests = Rc::new(RefCell::new(0usize)); + let captured = requests.clone(); + ctx.subscribe_to_view(&view, move |_, event, _| { + if let TuiInputViewEvent::FocusAboveRequested = event { + *captured.borrow_mut() += 1; + } + }); + (view, requests) + }); + + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!(*requests.borrow(), 1); + + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::Editor(TuiEditorAction::InsertText( + "first\nsecond".to_string(), + ))], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!( + *requests.borrow(), + 1, + "second visual row stays in the input" + ); + + app.update(|ctx| { + view.update(ctx, |view, ctx| view.clear(ctx)); + type_str(&view, ctx, "abc"); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectLeft)], + ); + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); + }); + assert_eq!(*requests.borrow(), 1, "active selection stays in the input"); + }); +} + #[test] fn clear_selection_collapses_to_head_without_changing_text() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 15d60927c07..8bda63c6ef5 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -16,17 +16,42 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use warp::tui_export::{ - apply_child_agent_model_override, inherit_child_agent_settings, prepare_local_oz_child_launch, - register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, - BlocklistAIHistoryModel, ConversationStatus, Harness, RenderableAIError, - StartAgentExecutionMode, StartAgentRequest, + apply_child_agent_model_override, descendant_conversation_ids_in_pill_order, + descendant_conversation_ids_in_spawn_order, inherit_child_agent_settings, + prepare_local_oz_child_launch, register_agent_event_consumer, unregister_agent_event_consumer, + AIConversationId, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, + Harness, RenderableAIError, StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; -use crate::session_registry::TuiSessionId; +use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; +use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::terminal_session_view::TuiTerminalSessionView; +/// One navigable child tab in an orchestration snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiOrchestrationTab { + pub(crate) conversation_id: AIConversationId, + pub(crate) label: String, + pub(crate) identity_index: usize, +} + +/// Plain-data tab state for one complete orchestration tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TuiOrchestrationTabSnapshot { + pub(crate) root_conversation_id: AIConversationId, + pub(crate) selected_conversation_id: AIConversationId, + pub(crate) tabs: Vec, + pub(crate) page_anchor: Option, +} + + +#[derive(Default)] +struct TuiOrchestrationTabState { + page_anchor: Option, + explicitly_paged: bool, +} /// The TUI's child-agent coordinator singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension @@ -35,6 +60,76 @@ pub(crate) struct TuiOrchestrationModel { child_session_by_conversation: HashMap, /// Conversations whose event streams are consumed by each live session. event_consumers_by_session: HashMap>, + tab_state_by_root: HashMap, +} + +/// Resolves the topmost loaded conversation in a tree, rejecting cycles. +fn conversation_tree_root( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> Option { + history.conversation(&conversation_id)?; + let mut current = conversation_id; + let mut visited = HashSet::new(); + while visited.insert(current) { + let conversation = history.conversation(¤t)?; + let Some(parent) = history.resolved_parent_conversation_id_for_conversation(conversation) + else { + return Some(current); + }; + current = parent; + } + None +} +/// Resolves a child-bearing orchestration root for tab navigation. +fn orchestration_root( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> Option { + let root = conversation_tree_root(history, conversation_id)?; + (!history.child_conversation_ids_of(&root).is_empty()).then_some(root) +} + +/// Resolves a conversation through history ownership to a retained TUI session. +fn navigable_session_id( + history: &BlocklistAIHistoryModel, + sessions: &TuiSessions, + conversation_id: AIConversationId, +) -> Option { + let surface_id = history.terminal_surface_id_for_conversation(&conversation_id)?; + sessions.session_id_for_surface(surface_id) +} + +/// Returns the child-agent label used by orchestration tabs. +fn conversation_label( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> String { + history + .conversation(&conversation_id) + .and_then(|conversation| conversation.agent_name()) + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_string() +} + +/// Whether a history event can change tab membership, order, label, or selection. +fn history_event_changes_tab_bar(event: &BlocklistAIHistoryEvent) -> bool { + matches!( + event, + BlocklistAIHistoryEvent::StartedNewConversation { .. } + | BlocklistAIHistoryEvent::UpdatedConversationStatus { .. } + | BlocklistAIHistoryEvent::SetActiveConversation { .. } + | BlocklistAIHistoryEvent::ClearedActiveConversation { .. } + | BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } + | BlocklistAIHistoryEvent::SplitConversation { .. } + | BlocklistAIHistoryEvent::RemoveConversation { .. } + | BlocklistAIHistoryEvent::DeletedConversation { .. } + | BlocklistAIHistoryEvent::RestoredConversations { .. } + | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } + | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } + | BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { .. } + ) } pub(crate) enum TuiOrchestrationEvent { CreateLocalOzChildSession { @@ -46,6 +141,7 @@ pub(crate) enum TuiOrchestrationEvent { conversation_name: String, }, RemoveChildSession(TuiSessionId), + TabBarChanged, } pub(crate) struct MaterializedLocalOzChildSession { pub(crate) parent_session_id: TuiSessionId, @@ -66,12 +162,141 @@ impl SingletonEntity for TuiOrchestrationModel {} impl TuiOrchestrationModel { /// Registers the singleton before sessions are created and wired to it. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { - ctx.add_singleton_model(|_| Self { + let history = BlocklistAIHistoryModel::handle(ctx); + let model = ctx.add_singleton_model(|_| Self { child_session_by_conversation: HashMap::new(), event_consumers_by_session: HashMap::new(), + tab_state_by_root: HashMap::new(), + }); + let model_for_history = model.clone(); + ctx.subscribe_to_model(&history, move |_, event, ctx| { + if !history_event_changes_tab_bar(event) { + return; + } + model_for_history.update(ctx, |model, ctx| { + model.prune_tab_state(ctx); + ctx.emit(TuiOrchestrationEvent::TabBarChanged); + ctx.notify(); + }); + }); + model + } + + /// Builds the current navigable tab tree for a selected conversation. + pub(crate) fn tab_snapshot( + &self, + selected_conversation_id: AIConversationId, + identity_palette_len: usize, + ctx: &AppContext, + ) -> Option { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let root_conversation_id = orchestration_root(history, selected_conversation_id)?; + let sessions = TuiSessions::as_ref(ctx); + navigable_session_id(history, sessions, root_conversation_id)?; + + let spawn_order = descendant_conversation_ids_in_spawn_order(history, root_conversation_id) + .into_iter() + .filter(|conversation_id| { + navigable_session_id(history, sessions, *conversation_id).is_some() + }) + .collect::>(); + if spawn_order.is_empty() { + return None; + } + let names = spawn_order + .iter() + .map(|conversation_id| conversation_label(history, *conversation_id)); + let identity_indices = assign_agent_identity_indices(names, identity_palette_len); + let identity_by_conversation = spawn_order + .iter() + .copied() + .zip(identity_indices) + .collect::>(); + + let tabs = descendant_conversation_ids_in_pill_order(history, root_conversation_id) + .into_iter() + .filter_map(|conversation_id| { + navigable_session_id(history, sessions, conversation_id)?; + Some(TuiOrchestrationTab { + conversation_id, + label: conversation_label(history, conversation_id), + identity_index: *identity_by_conversation.get(&conversation_id)?, + }) + }) + .collect::>(); + if tabs.is_empty() { + return None; + } + let state = self.tab_state_by_root.get(&root_conversation_id); + let explicitly_paged = state.is_some_and(|state| state.explicitly_paged); + let stored_anchor = state.and_then(|state| state.page_anchor); + let selected_child = (selected_conversation_id != root_conversation_id) + .then_some(selected_conversation_id) + .filter(|selected| tabs.iter().any(|tab| tab.conversation_id == *selected)); + let page_anchor = if explicitly_paged { + stored_anchor.filter(|anchor| tabs.iter().any(|tab| tab.conversation_id == *anchor)) + } else { + selected_child + } + .or_else(|| tabs.first().map(|tab| tab.conversation_id)); + Some(TuiOrchestrationTabSnapshot { + root_conversation_id, + selected_conversation_id, + tabs, + page_anchor, }) } + /// Stores an explicitly selected secondary page without switching sessions. + pub(crate) fn set_explicit_page( + &mut self, + root_conversation_id: AIConversationId, + page_anchor: AIConversationId, + ctx: &mut ModelContext, + ) { + self.tab_state_by_root.insert( + root_conversation_id, + TuiOrchestrationTabState { + page_anchor: Some(page_anchor), + explicitly_paged: true, + }, + ); + ctx.emit(TuiOrchestrationEvent::TabBarChanged); + ctx.notify(); + } + + /// Focuses the retained session for a conversation and clears explicit paging. + pub(crate) fn select_conversation( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) -> Option { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let root_conversation_id = orchestration_root(history, conversation_id)?; + let session_id = navigable_session_id(history, TuiSessions::as_ref(ctx), conversation_id)?; + self.tab_state_by_root.insert( + root_conversation_id, + TuiOrchestrationTabState { + page_anchor: (conversation_id != root_conversation_id).then_some(conversation_id), + explicitly_paged: false, + }, + ); + TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { + sessions.focus_session(session_id, ctx); + }); + ctx.emit(TuiOrchestrationEvent::TabBarChanged); + ctx.notify(); + Some(session_id) + } + + /// Drops page state for orchestration roots no longer present in history. + fn prune_tab_state(&mut self, ctx: &AppContext) { + let history = BlocklistAIHistoryModel::as_ref(ctx); + self.tab_state_by_root + .retain(|root, _| history.conversation(root).is_some()); + } + + /// Routes a `CreateAgent` request the same two ways as the GUI's /// per-mode dispatch, with unsupported modes resolving as clean per-child /// failures. @@ -195,6 +420,9 @@ impl TuiOrchestrationModel { self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); self.register_event_consumer(session_id, conversation_id, ctx); + session_view.update(ctx, |view, ctx| { + view.activate_orchestration_child_conversation(conversation_id, ctx); + }); let prompt = request.prompt; session_view.update(ctx, |view, ctx| { @@ -203,6 +431,7 @@ impl TuiOrchestrationModel { self.child_session_by_conversation .insert(conversation_id, session_id); + ctx.emit(TuiOrchestrationEvent::TabBarChanged); ctx.notify(); } @@ -278,6 +507,8 @@ impl TuiOrchestrationModel { } self.child_session_by_conversation .retain(|_, child_session_id| *child_session_id != session_id); + ctx.emit(TuiOrchestrationEvent::TabBarChanged); + ctx.notify(); } } diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 1d4b7e33ddb..3e6bde89331 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,5 +1,5 @@ use warp::tui_export::{ - register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, Harness, StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, }; use warpui::platform::WindowStyle; @@ -42,6 +42,33 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { } } +/// Adds a retained child session linked to `parent_conversation_id`. +fn add_child_session( + app: &mut App, + fixture: &OrchestrationFixture, + parent_conversation_id: AIConversationId, + name: &str, +) -> (TuiSessionId, AIConversationId) { + let (session, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update(|ctx| { + TuiSessions::register_session(&fixture.sessions, session, manager, false, ctx) + }); + let conversation_id = app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + session_id.surface_id(), + name.to_string(), + parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + history.set_active_conversation_id(conversation_id, session_id.surface_id(), ctx); + conversation_id + }) + }); + (session_id, conversation_id) +} + /// Registers a session with a live active conversation. fn add_dispatching_session( app: &mut App, @@ -166,6 +193,90 @@ fn local_harness_children_fail_cleanly() { }); } +#[test] +fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { + App::test((), |mut app| async move { + let fixture = orchestration_fixture(&mut app); + let parent_session_id = add_dispatching_session(&mut app, &fixture, true); + let parent_conversation_id = app.read(|ctx| { + BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(parent_session_id.surface_id()) + .unwrap() + .id() + }); + let (first_session_id, first_child_id) = + add_child_session(&mut app, &fixture, parent_conversation_id, "first-child"); + let (_, second_child_id) = + add_child_session(&mut app, &fixture, parent_conversation_id, "second-child"); + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.start_new_child_conversation( + warpui::EntityId::new(), + "missing-session".to_string(), + parent_conversation_id, + Some(Harness::Oz), + ctx, + ); + }); + }); + + app.read(|ctx| { + let model = TuiOrchestrationModel::as_ref(ctx); + let parent = model + .tab_snapshot(parent_conversation_id, 32, ctx) + .expect("parent has navigable children"); + let child = model + .tab_snapshot(first_child_id, 32, ctx) + .expect("child resolves the same tree"); + assert_eq!(parent.root_conversation_id, parent_conversation_id); + assert_eq!(child.root_conversation_id, parent_conversation_id); + assert_eq!( + parent + .tabs + .iter() + .map(|tab| tab.conversation_id) + .collect::>(), + vec![first_child_id, second_child_id] + ); + }); + + app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.set_explicit_page(parent_conversation_id, second_child_id, ctx); + }); + }); + app.read(|ctx| { + assert_eq!( + TuiOrchestrationModel::as_ref(ctx) + .tab_snapshot(parent_conversation_id, 32, ctx) + .unwrap() + .page_anchor, + Some(second_child_id) + ); + }); + + app.update(|ctx| { + let selected = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.select_conversation(first_child_id, ctx) + }); + assert_eq!(selected, Some(first_session_id)); + }); + app.read(|ctx| { + assert_eq!( + TuiSessions::as_ref(ctx).focused_session_id(), + Some(first_session_id) + ); + assert_eq!( + TuiOrchestrationModel::as_ref(ctx) + .tab_snapshot(first_child_id, 32, ctx) + .unwrap() + .page_anchor, + Some(first_child_id) + ); + }); + }); +} + #[test] fn remote_children_fail_cleanly() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index bb2e9c3d817..96a6adfaa20 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -238,6 +238,7 @@ impl TuiSessions { sessions.remove_session(*session_id, ctx); }); } + TuiOrchestrationEvent::TabBarChanged => {} }); } @@ -324,6 +325,13 @@ impl TuiSessions { pub(crate) fn session(&self, id: TuiSessionId) -> Option<&TuiSession> { self.sessions.iter().find(|session| session.id == id) } + /// Looks up a retained session by its terminal surface id. + pub(crate) fn session_id_for_surface(&self, surface_id: EntityId) -> Option { + self.sessions + .iter() + .find(|session| session.id.surface_id() == surface_id) + .map(|session| session.id) + } /// Whether no session has been registered. pub(crate) fn is_empty(&self) -> bool { diff --git a/crates/warp_tui/src/session_registry_tests.rs b/crates/warp_tui/src/session_registry_tests.rs index 49cdb1af86f..4855dfa6835 100644 --- a/crates/warp_tui/src/session_registry_tests.rs +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -66,6 +66,12 @@ fn focus_drives_events() { TuiSessions::register_session(&sessions, second, second_manager, false, ctx) }); assert_eq!(second_id.surface_id(), second_view_id); + assert_eq!( + app.read_model(&sessions, |sessions, _| { + sessions.session_id_for_surface(second_view_id) + }), + Some(second_id), + ); assert!(std::mem::take(&mut *events.borrow_mut()).is_empty()); assert_eq!( app.read_model(&sessions, |sessions, _| sessions.focused_session_id()), diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index befaea02f1d..e58f97c8c74 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -71,10 +71,16 @@ use crate::keybindings::{ use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::orchestration_block::TuiOrchestrationBlock; +use crate::orchestration_model::{ + TuiOrchestrationEvent, TuiOrchestrationModel, TuiOrchestrationTabSnapshot, +}; use crate::resume::TuiExitSummaryHandle; use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; +use crate::tab_bar::{ + TuiTab, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, TuiTabBarView, +}; use crate::terminal_content_element::TuiTerminalContentElement; use crate::terminal_use::{ hide_agent_requested_command_from_top_level, terminal_use_conversation_to_resume, @@ -97,6 +103,8 @@ use self::input_detection::InputDetectionState; /// Width used before the first layout pass pushes the real terminal width into the editor. const INITIAL_INPUT_WIDTH: u16 = 80; const MAX_INPUT_TEXT_ROWS: u16 = 6; +const ORCHESTRATION_TAB_BAR_FOCUSED_FLAG: &str = "TuiOrchestrationTabBarFocused"; +const ORCHESTRATION_TAB_LABEL_MAX_COLUMNS: u16 = 20; /// The footer hint shown while the ctrl-c exit confirmation is armed. const CTRL_C_EXIT_HINT: &str = "ctrl-c again to exit"; @@ -240,6 +248,16 @@ pub(crate) enum TuiTerminalSessionAction { ForwardUserPtyBytes(Vec), /// Toggle the latest exposed inline plan. TogglePlan, + /// Return keyboard focus from tabs to the session interaction. + FocusSessionInteraction, + /// Select the previous tab using the component's settled layout. + SelectPreviousOrchestrationTab, + /// Select the next tab using the component's settled layout. + SelectNextOrchestrationTab, + /// Select the first child tab, excluding the orchestrator. + SelectFirstOrchestrationChild, + /// Select the last child tab, excluding the orchestrator. + SelectLastOrchestrationChild, } /// The authenticated terminal/session surface rendered inside [`RootTuiView`]. @@ -296,6 +314,8 @@ pub(crate) struct TuiTerminalSessionView { /// detect blocker transitions in [`Self::sync_blocker_focus`]. Input /// visibility itself is derived at render time, never stored. active_blocker_view_id: Option, + orchestration_tab_bar: ViewHandle, + orchestration_tabs_focused: bool, } /// Registers the session surface's keybindings. Called once at TUI startup @@ -344,6 +364,65 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP) .with_key_binding("ctrl-p"), ]); + let tab_context = id!("TuiTerminalSessionView") & id!(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); + app.register_editable_bindings([ + EditableBinding::new( + "tui:orchestration_tabs:previous", + "Select the previous orchestration tab", + TuiTerminalSessionAction::SelectPreviousOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("left"), + EditableBinding::new( + "tui:orchestration_tabs:previous", + "Select the previous orchestration tab", + TuiTerminalSessionAction::SelectPreviousOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-tab"), + EditableBinding::new( + "tui:orchestration_tabs:next", + "Select the next orchestration tab", + TuiTerminalSessionAction::SelectNextOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("right"), + EditableBinding::new( + "tui:orchestration_tabs:next", + "Select the next orchestration tab", + TuiTerminalSessionAction::SelectNextOrchestrationTab, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("tab"), + EditableBinding::new( + "tui:orchestration_tabs:first_child", + "Select the first child agent", + TuiTerminalSessionAction::SelectFirstOrchestrationChild, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-left"), + EditableBinding::new( + "tui:orchestration_tabs:last_child", + "Select the last child agent", + TuiTerminalSessionAction::SelectLastOrchestrationChild, + ) + .with_context_predicate(tab_context.clone()) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-right"), + EditableBinding::new( + "tui:orchestration_tabs:focus_input", + "Return focus to the session input", + TuiTerminalSessionAction::FocusSessionInteraction, + ) + .with_context_predicate(tab_context) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-down"), + ]); } impl TuiTerminalSessionView { @@ -358,12 +437,15 @@ impl TuiTerminalSessionView { self.focus_current_owner_if_active(ctx); } - fn focus_current_owner(&self, ctx: &mut ViewContext) { + fn focus_current_owner(&mut self, ctx: &mut ViewContext) { match self.input_target() { TuiInputTarget::Disabled | TuiInputTarget::Pty => ctx.focus_self(), TuiInputTarget::AgentEditor => { if let Some(blocker) = self.active_blocking_child(ctx) { + self.orchestration_tabs_focused = false; ctx.focus(&blocker); + } else if self.orchestration_tabs_focused { + ctx.focus_self(); } else { ctx.focus(&self.input_view); } @@ -371,7 +453,7 @@ impl TuiTerminalSessionView { } } - fn focus_current_owner_if_active(&self, ctx: &mut ViewContext) { + fn focus_current_owner_if_active(&mut self, ctx: &mut ViewContext) { if self.is_focused_session(ctx) { self.focus_current_owner(ctx); } @@ -382,6 +464,7 @@ impl TuiTerminalSessionView { ctx.focus(&self.input_view); } } + fn resume_after_user_controlled_command( &mut self, block_id: &BlockId, @@ -527,7 +610,7 @@ impl TuiTerminalSessionView { fn hand_back_terminal_use_control(&mut self, ctx: &mut ViewContext) { if self.active_user_controlled_target(ctx).is_none() { return; - } + }; self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.handoff_active_command_control_to_agent(ctx); }); @@ -833,6 +916,8 @@ impl TuiTerminalSessionView { ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) }); + let orchestration_tab_bar = + ctx.add_typed_action_tui_view(|_| TuiTabBarView::new(TuiTabBarConfig::new(Vec::new()))); ctx.subscribe_to_view(&transcript, |view, _, event, ctx| match event { TuiTranscriptViewEvent::SelectionStarted => { @@ -865,6 +950,9 @@ impl TuiTerminalSessionView { TuiInputViewEvent::AcceptedMcp(action) => { view.handle_accepted_mcp_action(*action, ctx); } + TuiInputViewEvent::FocusAboveRequested => { + view.focus_orchestration_tabs(ctx); + } }); ctx.subscribe_to_model(&action_model, |view, action_model, event, ctx| { let BlocklistAIActionEvent::FinishedAction { action_id, .. } = event else { @@ -880,6 +968,43 @@ impl TuiTerminalSessionView { ctx.focus(&view.input_view); } }); + ctx.subscribe_to_view(&orchestration_tab_bar, |view, _, event, ctx| match event { + TuiTabBarEvent::SelectTab(conversation_id) => { + let Ok(conversation_id) = AIConversationId::try_from(conversation_id.clone()) + else { + return; + }; + view.select_orchestration_conversation( + conversation_id, + view.orchestration_tabs_focused, + ctx, + ); + } + TuiTabBarEvent::PageChanged(page_anchor) => { + let Ok(page_anchor) = AIConversationId::try_from(page_anchor.clone()) else { + return; + }; + let palette_len = TuiUiBuilder::from_app(ctx).agent_identity_palette().len(); + let Some(snapshot) = view.orchestration_tab_snapshot(palette_len, ctx) else { + return; + }; + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.set_explicit_page(snapshot.root_conversation_id, page_anchor, ctx); + }); + } + }); + if ctx.has_singleton_model::() { + ctx.subscribe_to_model( + &TuiOrchestrationModel::handle(ctx), + |view, _, event, ctx| match event { + TuiOrchestrationEvent::TabBarChanged => { + view.handle_orchestration_tab_change(ctx); + } + TuiOrchestrationEvent::CreateLocalOzChildSession { .. } + | TuiOrchestrationEvent::RemoveChildSession(_) => {} + }, + ); + } // The input box border color and the footer's shell-mode hint depend // on the input mode. ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| ctx.notify()); @@ -1104,6 +1229,8 @@ impl TuiTerminalSessionView { next_restore_request_id: 0, exit_summary, active_blocker_view_id: None, + orchestration_tab_bar, + orchestration_tabs_focused: false, } } @@ -1122,6 +1249,206 @@ impl TuiTerminalSessionView { }); } + /// Selects a newly materialized child conversation without changing view focus. + pub(crate) fn activate_orchestration_child_conversation( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + self.conversation_selection.update(ctx, |selection, ctx| { + selection.select_existing_conversation(conversation_id, AgentViewEntryOrigin::Tui, ctx); + }); + ctx.notify(); + } + + /// The conversation selected for prompts and tab highlighting. + fn selected_conversation_id(&self, ctx: &AppContext) -> Option { + self.conversation_selection + .as_ref(ctx) + .selected_conversation_id(ctx) + } + + /// Resolves the orchestration tabs for this session's selected conversation. + fn orchestration_tab_snapshot( + &self, + identity_palette_len: usize, + ctx: &AppContext, + ) -> Option { + if !ctx.has_singleton_model::() + || !ctx.has_singleton_model::() + { + return None; + } + let selected_conversation_id = self.selected_conversation_id(ctx)?; + TuiOrchestrationModel::as_ref(ctx).tab_snapshot( + selected_conversation_id, + identity_palette_len, + ctx, + ) + } + + /// Reconciles input/tab focus availability after orchestration state changes. + fn handle_orchestration_tab_change(&mut self, ctx: &mut ViewContext) { + let builder = TuiUiBuilder::from_app(ctx); + let snapshot = self.orchestration_tab_snapshot(builder.agent_identity_palette().len(), ctx); + let available = snapshot.is_some(); + self.input_view.update(ctx, |input, ctx| { + input.set_focus_above_available(available, ctx); + }); + if let Some(snapshot) = snapshot.as_ref() { + self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + } + if !available && self.orchestration_tabs_focused { + self.focus_session_interaction(ctx); + } + ctx.notify(); + } + + /// Gives keyboard focus to the orchestration tab bar when it is available. + fn focus_orchestration_tabs(&mut self, ctx: &mut ViewContext) { + let builder = TuiUiBuilder::from_app(ctx); + let Some(snapshot) = + self.orchestration_tab_snapshot(builder.agent_identity_palette().len(), ctx) + else { + return; + }; + self.orchestration_tabs_focused = true; + self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); + ctx.focus_self(); + ctx.notify(); + } + + /// Focuses the session's blocker or input and leaves tab-navigation mode. + fn focus_session_interaction(&mut self, ctx: &mut ViewContext) { + self.orchestration_tabs_focused = false; + let builder = TuiUiBuilder::from_app(ctx); + if let Some(snapshot) = + self.orchestration_tab_snapshot(builder.agent_identity_palette().len(), ctx) + { + self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); + } + self.focus_current_owner(ctx); + ctx.notify(); + } + + /// Switches retained sessions and applies the requested target focus mode. + fn select_orchestration_conversation( + &mut self, + conversation_id: AIConversationId, + keep_tab_focus: bool, + ctx: &mut ViewContext, + ) { + let session_id = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.select_conversation(conversation_id, ctx) + }); + let Some(session_id) = session_id else { + return; + }; + if session_id.surface_id() == self.terminal_surface_id { + if keep_tab_focus { + self.focus_orchestration_tabs(ctx); + } else { + self.focus_session_interaction(ctx); + } + return; + } + let target_view = TuiSessions::as_ref(ctx) + .session(session_id) + .map(|session| session.view().clone()); + let Some(target_view) = target_view else { + return; + }; + target_view.update(ctx, |target, target_ctx| { + if keep_tab_focus { + target.focus_orchestration_tabs(target_ctx); + } else { + target.focus_session_interaction(target_ctx); + } + target.handle_orchestration_tab_change(target_ctx); + }); + } + + /// Selects the first or last navigable child from the current snapshot. + fn select_orchestration_edge_child(&mut self, first: bool, ctx: &mut ViewContext) { + let palette_len = TuiUiBuilder::from_app(ctx).agent_identity_palette().len(); + let Some(snapshot) = self.orchestration_tab_snapshot(palette_len, ctx) else { + return; + }; + let target = if first { + snapshot.tabs.first() + } else { + snapshot.tabs.last() + }; + if let Some(target) = target { + self.select_orchestration_conversation(target.conversation_id, true, ctx); + } + } + + /// Builds the tab child-view configuration for an orchestration snapshot. + fn orchestration_tab_bar_config( + &self, + snapshot: &TuiOrchestrationTabSnapshot, + builder: &TuiUiBuilder, + ) -> TuiTabBarConfig { + let palette = builder.agent_identity_palette(); + let tabs = snapshot + .tabs + .iter() + .map(|tab| { + let identity = palette + .get(tab.identity_index) + .or_else(|| palette.first()) + .cloned() + .unwrap_or_default(); + let glyph = identity.glyph.to_owned(); + let style = identity.style; + TuiTab::new(tab.conversation_id.to_string(), tab.label.clone()) + .with_leading_text(glyph, style) + }) + .collect(); + let mut config = TuiTabBarConfig::new(tabs); + config.leading = Some(" Agents: ".to_owned()); + config.main_tab = Some(TuiTab::new( + snapshot.root_conversation_id.to_string(), + "orchestrator", + )); + config.selected_key = Some(snapshot.selected_conversation_id.to_string()); + config.focused = self.orchestration_tabs_focused; + config.page_anchor = snapshot.page_anchor.map(|id| id.to_string()); + config.maximum_label_columns = Some(ORCHESTRATION_TAB_LABEL_MAX_COLUMNS); + config.secondary_gap_columns = 3; + config.styles = builder.orchestration_tab_bar_styles(); + config + } + + /// Synchronizes the retained tab child view from current orchestration state. + fn sync_orchestration_tab_bar( + &self, + snapshot: &TuiOrchestrationTabSnapshot, + builder: &TuiUiBuilder, + ctx: &mut ViewContext, + ) { + let config = self.orchestration_tab_bar_config(snapshot, builder); + self.orchestration_tab_bar + .update(ctx, |tab_bar, ctx| tab_bar.set_config(config, ctx)); + } + + /// Footer shown while orchestration tabs own keyboard focus. + fn render_orchestration_tab_footer(&self, builder: &TuiUiBuilder) -> Box { + let primary = builder.primary_text_style(); + let muted = builder.muted_text_style(); + TuiText::from_spans([ + ("Tab or ← →".to_string(), primary), + (" to navigate ".to_string(), muted), + ("Shift + ← →".to_string(), primary), + (" to go to start/end ".to_string(), muted), + ("Shift + ↓".to_string(), primary), + (" to send a message".to_string(), muted), + ]) + .truncate() + .finish() + } + /// The active front-of-queue blocking interaction, if any. fn active_blocking_child(&self, ctx: &AppContext) -> Option> { self.transcript.as_ref(ctx).active_blocking_child(ctx) @@ -1565,11 +1892,11 @@ impl TuiTerminalSessionView { /// Builds the status footer under the input box. The left slot shows one /// hint at a time — the ctrl-c exit confirmation while armed, else a /// transient notice, else the shell-mode callout, else the conversations - /// callout while the input is empty and no inline menu is visible; the - /// active model and working directory are pushed to the right edge behind a - /// flex spacer. Every child truncates to a single row, so the row lays out - /// one row tall. - fn render_footer(&self, ctx: &AppContext) -> TuiFlex { + /// callout while the input is empty and no inline menu is visible, else the + /// orchestration-tab callout; the active model and working directory are + /// pushed to the right edge behind a flex spacer. Every child truncates to + /// a single row, so the row lays out one row tall. + fn render_footer(&self, orchestration_tabs_available: bool, ctx: &AppContext) -> TuiFlex { let builder = TuiUiBuilder::from_app(ctx); let muted = builder.muted_text_style(); let mut left = TuiFlex::row(); @@ -1593,6 +1920,8 @@ impl TuiTerminalSessionView { Some((transient, style)) } else if self.is_shell_mode(ctx) { Some((SHELL_MODE_HINT, builder.shell_mode_accent_style())) + } else if orchestration_tabs_available { + Some(("Shift + ↑ sub-agents", muted)) } else { None }; @@ -2302,11 +2631,18 @@ impl TuiView for TuiTerminalSessionView { } fn child_view_ids(&self, _ctx: &AppContext) -> Vec { - vec![self.transcript.id(), self.input_view.id()] + vec![ + self.transcript.id(), + self.input_view.id(), + self.orchestration_tab_bar.id(), + ] } fn keymap_context(&self, ctx: &AppContext) -> keymap::Context { let mut context = Self::default_keymap_context(); + if self.orchestration_tabs_focused { + context.set.insert(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); + } if self.is_conversation_restore_loading() { context.set.insert(SESSION_CAN_CANCEL_RESTORE_FLAG); } @@ -2366,6 +2702,10 @@ impl TuiView for TuiTerminalSessionView { .and_then(|menu| menu.render(ctx)) }) .flatten(); + let builder = TuiUiBuilder::from_app(ctx); + let identity_palette_len = builder.agent_identity_palette().len(); + let orchestration_tabs = self.orchestration_tab_snapshot(identity_palette_len, ctx); + let orchestration_tabs_available = orchestration_tabs.is_some(); // Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the // fixed binding registered in [`Self::init`], so no element-level key @@ -2457,7 +2797,6 @@ impl TuiView for TuiTerminalSessionView { .finish(), ); } - let builder = TuiUiBuilder::from_app(ctx); let border_style = if self.is_shell_mode(ctx) { builder.shell_mode_accent_style() } else { @@ -2473,11 +2812,13 @@ impl TuiView for TuiTerminalSessionView { .with_max_rows(MAX_INPUT_TEXT_ROWS + 2) .finish(), ); - content = content.child( - TuiConstrainedBox::new(self.render_footer(ctx).finish()) - .with_max_rows(1) - .finish(), - ); + let footer = if self.orchestration_tabs_focused { + self.render_orchestration_tab_footer(&builder) + } else { + self.render_footer(orchestration_tabs_available, ctx) + .finish() + }; + content = content.child(TuiConstrainedBox::new(footer).with_max_rows(1).finish()); } let content = content.finish(); let terminal_content = @@ -2492,11 +2833,19 @@ impl TuiView for TuiTerminalSessionView { // the PTY's columns match the width block content actually renders at // (the GUI wraps its view root, but its padding is sub-cell; here it is // 4 whole columns). - TuiContainer::new(terminal_content.finish()) + let session = TuiContainer::new(terminal_content.finish()) .with_padding_x(2) .with_padding_top(2) .with_padding_bottom(1) - .finish() + .finish(); + if orchestration_tabs.is_some() { + TuiFlex::column() + .child(TuiChildView::new(&self.orchestration_tab_bar).finish()) + .flex_child(session) + .finish() + } else { + session + } } } @@ -2513,6 +2862,35 @@ impl TypedActionView for TuiTerminalSessionView { self.hand_back_terminal_use_control(ctx) } TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), + TuiTerminalSessionAction::FocusSessionInteraction => { + self.focus_session_interaction(ctx) + } + TuiTerminalSessionAction::SelectPreviousOrchestrationTab => { + if let Some(conversation_id) = self + .orchestration_tab_bar + .as_ref(ctx) + .navigation_target(TuiTabBarNavigationDirection::Previous) + .and_then(|key| AIConversationId::try_from(key).ok()) + { + self.select_orchestration_conversation(conversation_id, true, ctx); + } + } + TuiTerminalSessionAction::SelectNextOrchestrationTab => { + if let Some(conversation_id) = self + .orchestration_tab_bar + .as_ref(ctx) + .navigation_target(TuiTabBarNavigationDirection::Next) + .and_then(|key| AIConversationId::try_from(key).ok()) + { + self.select_orchestration_conversation(conversation_id, true, ctx); + } + } + TuiTerminalSessionAction::SelectFirstOrchestrationChild => { + self.select_orchestration_edge_child(true, ctx) + } + TuiTerminalSessionAction::SelectLastOrchestrationChild => { + self.select_orchestration_edge_child(false, ctx) + } TuiTerminalSessionAction::ForwardUserPtyBytes(bytes) => { // Raw passthrough: the bytes are already the app's escape // sequence, so write them to the PTY unmodified. diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 1def68907f3..1e1d0735882 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -18,6 +18,7 @@ use warpui_core::elements::{Fill as CoreFill, MouseStateHandle}; use warpui_core::AppContext; use crate::orchestrated_agent_identity_styling::{agent_identity_palette, AgentIdentity}; +use crate::tab_bar::TuiTabBarStyles; use crate::terminal_background::probed_colors; /// Theme-derived styles and components for the TUI, mirroring the GUI's @@ -255,6 +256,42 @@ impl TuiUiBuilder { self.primary_text_style().add_modifier(Modifier::BOLD) } + /// Styles for the reusable component when rendered as orchestration tabs. + pub(crate) fn orchestration_tab_bar_styles(&self) -> TuiTabBarStyles { + let background = self.orchestration_surface_background(); + let selected_fill = ThemeFill::from(self.warp_theme.terminal_colors().normal.magenta); + let selected_background = cell_color(selected_fill); + let selected_foreground = + cell_color(self.warp_theme.font_color(selected_fill.into_solid())); + TuiTabBarStyles { + bar: self.primary_text_style().bg(background), + leading: self.orchestration_tab_bar_label_style(), + chrome: self.orchestration_tab_bar_chrome_style(), + tab: self.muted_text_style().bg(background), + selected_focused: TuiStyle::default() + .fg(selected_foreground) + .bg(selected_background) + .add_modifier(Modifier::BOLD), + selected_unfocused: self + .primary_text_style() + .bg(background) + .add_modifier(Modifier::BOLD), + } + } + + /// Bold fixed-label style over the orchestration tab-bar background. + pub(crate) fn orchestration_tab_bar_label_style(&self) -> TuiStyle { + self.primary_text_style() + .bg(self.orchestration_surface_background()) + .add_modifier(Modifier::BOLD) + } + + /// Muted divider/overflow style over the orchestration tab background. + pub(crate) fn orchestration_tab_bar_chrome_style(&self) -> TuiStyle { + self.muted_text_style() + .bg(self.orchestration_surface_background()) + } + /// The deterministic agent identity palette for this theme. See /// [`crate::orchestrated_agent_identity_styling`]. pub(crate) fn agent_identity_palette(&self) -> Vec { diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md index 3bac5ec604b..98041b30fee 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/TECH.md +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -41,16 +41,15 @@ Add `TuiSessions::session_id_for_surface(EntityId)` and a narrow session-view lo Extend `TuiOrchestrationModel` from launch coordination to the owner of semantic orchestration tab state. Add a per-orchestrator entry containing: - The child page-anchor conversation ID. - Whether the user explicitly paged away from the active tab. -- Stable child identity assignments computed in spawn order, independently of the dynamic display order. -Expose a plain-data snapshot for `TuiTerminalSessionView` containing the main tab, ordered child tabs, active conversation ID, identity indices, page anchor, and explicit-page state. The session view maps identity indices and semantic tab states to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. +Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn-order-derived identity indices, and page anchor. The session view maps identity indices and semantic tab states to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. Model operations: - `select_conversation` resolves the target's retained session, calls `TuiSessions::focus_session`, clears explicit paging, and anchors the page so the selected child is visible. - `select_first_child`/`select_last_child` exclude the orchestrator. - `set_explicit_page` records the page anchor emitted by the tab bar and marks it explicit. It never calls `focus_session`. - A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. -- Removed roots prune their per-tree state; removed children prune identity entries and clamp invalid anchors. +- Removed roots prune their per-tree state; removed children clamp invalid anchors through fresh snapshot resolution. Subscribe to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, pin order, or active selection, and to all `TuiSessionsEvent` variants. Emit a narrow `TabBarChanged` event so every retained session view in the affected tree redraws. These subscriptions also make child materialization/removal visible without polling. @@ -58,7 +57,6 @@ Subscribe to `BlocklistAIHistoryModel` events that can change membership, parent The model owns tab selection and page state, but actual responder focus remains view-owned because only a `ViewContext` can focus a view. Add orchestration-tab actions and a keymap-context flag to `TuiTerminalSessionView`: -- `FocusTabs` - `FocusInput` - `SelectPrevious` / `SelectNext` - `SelectFirstChild` / `SelectLastChild` @@ -67,7 +65,7 @@ Add orchestration-tab actions and a keymap-context flag to `TuiTerminalSessionVi Register `Left`, `Right`, `Tab`, `Shift+Tab`, `Shift+Left`, `Shift+Right`, and `Shift+Down` only under the tab-focused context. Keep the existing session-level `Ctrl+C` binding unchanged; the focused footer omits kill copy in this PR. -`SelectPrevious` and `SelectNext` are forwarded to the tab-bar component. The component resolves the target from its private settled layout and emits `SelectConversation`; the session view then delegates that semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. +The input's `FocusAboveRequested` event directly enters tab focus. `SelectPrevious` and `SelectNext` ask the tab-bar component for a target from its private settled layout; the session view then delegates that semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. Track whether the session view itself currently owns tab-bar focus. A switch performs two coordinated operations: 1. Ask `TuiOrchestrationModel` to select/focus the target retained session. From 631b8a2816ccbe30850e413a822270a1f826a2b2 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 18:04:55 -0400 Subject: [PATCH 03/12] Preserve orchestration tab page selection Co-Authored-By: Oz --- crates/warp_tui/src/orchestration_model.rs | 25 +++++++------------ .../warp_tui/src/orchestration_model_tests.rs | 24 ++++++++---------- crates/warp_tui/src/terminal_session_view.rs | 1 + .../PRODUCT.md | 2 +- .../TECH.md | 4 +-- 5 files changed, 23 insertions(+), 33 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 8bda63c6ef5..33c56591ee2 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -44,6 +44,7 @@ pub(crate) struct TuiOrchestrationTabSnapshot { pub(crate) selected_conversation_id: AIConversationId, pub(crate) tabs: Vec, pub(crate) page_anchor: Option, + pub(crate) reveal_selected: bool, } @@ -230,20 +231,15 @@ impl TuiOrchestrationModel { let state = self.tab_state_by_root.get(&root_conversation_id); let explicitly_paged = state.is_some_and(|state| state.explicitly_paged); let stored_anchor = state.and_then(|state| state.page_anchor); - let selected_child = (selected_conversation_id != root_conversation_id) - .then_some(selected_conversation_id) - .filter(|selected| tabs.iter().any(|tab| tab.conversation_id == *selected)); - let page_anchor = if explicitly_paged { - stored_anchor.filter(|anchor| tabs.iter().any(|tab| tab.conversation_id == *anchor)) - } else { - selected_child - } - .or_else(|| tabs.first().map(|tab| tab.conversation_id)); + let page_anchor = stored_anchor + .filter(|anchor| tabs.iter().any(|tab| tab.conversation_id == *anchor)) + .or_else(|| tabs.first().map(|tab| tab.conversation_id)); Some(TuiOrchestrationTabSnapshot { root_conversation_id, selected_conversation_id, tabs, page_anchor, + reveal_selected: !explicitly_paged, }) } @@ -274,13 +270,10 @@ impl TuiOrchestrationModel { let history = BlocklistAIHistoryModel::as_ref(ctx); let root_conversation_id = orchestration_root(history, conversation_id)?; let session_id = navigable_session_id(history, TuiSessions::as_ref(ctx), conversation_id)?; - self.tab_state_by_root.insert( - root_conversation_id, - TuiOrchestrationTabState { - page_anchor: (conversation_id != root_conversation_id).then_some(conversation_id), - explicitly_paged: false, - }, - ); + self.tab_state_by_root + .entry(root_conversation_id) + .or_default() + .explicitly_paged = false; TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { sessions.focus_session(session_id, ctx); }); diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 3e6bde89331..b511e22ebd3 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -246,13 +246,11 @@ fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { }); }); app.read(|ctx| { - assert_eq!( - TuiOrchestrationModel::as_ref(ctx) - .tab_snapshot(parent_conversation_id, 32, ctx) - .unwrap() - .page_anchor, - Some(second_child_id) - ); + let snapshot = TuiOrchestrationModel::as_ref(ctx) + .tab_snapshot(parent_conversation_id, 32, ctx) + .unwrap(); + assert_eq!(snapshot.page_anchor, Some(second_child_id)); + assert!(!snapshot.reveal_selected); }); app.update(|ctx| { @@ -262,17 +260,15 @@ fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { assert_eq!(selected, Some(first_session_id)); }); app.read(|ctx| { + let snapshot = TuiOrchestrationModel::as_ref(ctx) + .tab_snapshot(first_child_id, 32, ctx) + .unwrap(); assert_eq!( TuiSessions::as_ref(ctx).focused_session_id(), Some(first_session_id) ); - assert_eq!( - TuiOrchestrationModel::as_ref(ctx) - .tab_snapshot(first_child_id, 32, ctx) - .unwrap() - .page_anchor, - Some(first_child_id) - ); + assert_eq!(snapshot.page_anchor, Some(second_child_id)); + assert!(snapshot.reveal_selected); }); }); } diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index e58f97c8c74..9eb333f8105 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1415,6 +1415,7 @@ impl TuiTerminalSessionView { config.selected_key = Some(snapshot.selected_conversation_id.to_string()); config.focused = self.orchestration_tabs_focused; config.page_anchor = snapshot.page_anchor.map(|id| id.to_string()); + config.reveal_selected = snapshot.reveal_selected; config.maximum_label_columns = Some(ORCHESTRATION_TAB_LABEL_MAX_COLUMNS); config.secondary_gap_columns = 3; config.styles = builder.orchestration_tab_bar_styles(); diff --git a/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md index c5ed342ec25..bbaba786a25 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md +++ b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md @@ -90,7 +90,7 @@ The Warp TUI shows a tab bar for an orchestration tree so users can see and swit - It does not select a tab. - It does not change keyboard focus. In particular, clicking an arrow while the input is focused leaves the input focused. 39. The selected page is shared across all sessions in the same orchestration tree. Switching conversations does not reset it. -40. The active tab is automatically revealed after session selection or dynamic reordering unless the user explicitly paged away with an overflow arrow. +40. The active tab is automatically revealed after session selection or dynamic reordering unless the user explicitly paged away with an overflow arrow. Selecting another tab already on the visible page does not shift or re-anchor that page. 41. After an explicit overflow click, the chosen page remains visible even if the active tab is on another page. The next keyboard selection follows (26). 42. Selecting a tab clears the explicit paged-away state and keeps the selected tab visible. 43. Terminal resizing recomputes which complete or truncated tabs fit, preserves a valid page when possible, and never clips an overflow arrow or writes outside the row. diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md index 98041b30fee..f5dc441fc88 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/TECH.md +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -42,10 +42,10 @@ Extend `TuiOrchestrationModel` from launch coordination to the owner of semantic - The child page-anchor conversation ID. - Whether the user explicitly paged away from the active tab. -Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn-order-derived identity indices, and page anchor. The session view maps identity indices and semantic tab states to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. +Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn-order-derived identity indices, page anchor, and whether the component should reveal an off-page selection. The session view maps identity indices and semantic tab states to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. Model operations: -- `select_conversation` resolves the target's retained session, calls `TuiSessions::focus_session`, clears explicit paging, and anchors the page so the selected child is visible. +- `select_conversation` resolves the target's retained session, calls `TuiSessions::focus_session`, and clears explicit paging without replacing the current page anchor. The component reveals the deterministic page containing an off-page selection. - `select_first_child`/`select_last_child` exclude the orchestrator. - `set_explicit_page` records the page anchor emitted by the tab bar and marks it explicit. It never calls `focus_session`. - A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. From 278e0a36b0b618de81e935c6e6a316daeb593b23 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 19:12:39 -0400 Subject: [PATCH 04/12] address comments Co-Authored-By: Oz --- app/src/ai/blocklist/history_model.rs | 4 + .../ai/blocklist/orchestration_topology.rs | 23 ++ app/src/tui_export.rs | 5 +- crates/warp_tui/src/input/view.rs | 43 +-- crates/warp_tui/src/input/view_tests.rs | 57 +++- crates/warp_tui/src/orchestration_model.rs | 267 ++++++++---------- .../warp_tui/src/orchestration_model_tests.rs | 40 ++- crates/warp_tui/src/session_registry.rs | 17 +- crates/warp_tui/src/session_registry_tests.rs | 6 - crates/warp_tui/src/terminal_session_view.rs | 196 +++++++------ .../src/terminal_session_view_tests.rs | 99 ++++++- .../TECH.md | 43 ++- 12 files changed, 481 insertions(+), 319 deletions(-) diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 5962e94f4a3..2ae881326ec 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -838,6 +838,10 @@ impl BlocklistAIHistoryModel { } conversation.set_pinned(pinned); conversation.write_updated_conversation_state(ctx); + ctx.emit(BlocklistAIHistoryEvent::UpdatedConversationMetadata { + terminal_surface_id: self.terminal_surface_id_for_conversation(&conversation_id), + conversation_id, + }); } /// Sets a live conversation's server token, updates the reverse index, and diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index e1d95f34f01..2eae3ece7a0 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -5,6 +5,7 @@ //! orchestration pill bar so other surfaces (e.g. keyboard navigation and //! the agent-mode usage footer's credit rollup) can walk and order the same //! tree without duplicating the logic. +use std::collections::HashSet; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::blocklist::BlocklistAIHistoryModel; @@ -100,6 +101,28 @@ pub fn resolve_orchestration_participant( } } +/// Returns the topmost loaded conversation in an orchestration tree. +/// +/// Conversations without descendants are not orchestration roots. Malformed +/// parent cycles and missing ancestors fail closed. +pub fn orchestration_root_conversation_id( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> Option { + history.conversation(&conversation_id)?; + let mut current = conversation_id; + let mut visited = HashSet::new(); + while visited.insert(current) { + let conversation = history.conversation(¤t)?; + let Some(parent) = history.resolved_parent_conversation_id_for_conversation(conversation) + else { + return (!history.child_conversation_ids_of(¤t).is_empty()).then_some(current); + }; + current = parent; + } + None +} + const DONE_STATUS_KEY: u8 = 3; fn pill_status_sort_key(status: Option<&ConversationStatus>) -> u8 { diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 6dfebd857f3..46d5864c668 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -69,8 +69,9 @@ pub use crate::ai::blocklist::orchestration_event_streamer::{ }; pub use crate::ai::blocklist::orchestration_topology::{ descendant_conversation_ids_in_pill_order, descendant_conversation_ids_in_spawn_order, - orchestrator_agent_id_for_conversation, resolve_orchestration_participant, - OrchestrationParticipantKind, ResolvedOrchestrationParticipant, + orchestration_root_conversation_id, orchestrator_agent_id_for_conversation, + resolve_orchestration_participant, OrchestrationParticipantKind, + ResolvedOrchestrationParticipant, }; pub use crate::ai::blocklist::view_util::format_credits; #[cfg(not(target_family = "wasm"))] diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index 927e2b20a65..d48cf14ff27 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -22,6 +22,7 @@ //! See `specs/tui-input-view/TECH.md` for the full keybinding table. use std::ops::Range; +use std::rc::Rc; use string_offset::CharOffset; use warp::editor::{CodeEditorModel, CodeEditorModelEvent}; @@ -115,8 +116,8 @@ pub enum TuiInputViewEvent { AcceptedModel(LLMId), /// The user selected an action from the MCP menu. AcceptedMcp(TuiMcpAction), - /// Shift+Up reached an available focus target above the first visual row. - FocusAboveRequested, + /// Shift+Up should move focus from the first visual row to the region above. + MoveFocusUp, } // ───────────────────────────────────────────────────────────────────────────── @@ -172,8 +173,8 @@ pub struct TuiInputView { /// construction always provides this; isolated input tests omit it. transcript: Option>, keyboard_enhancement_supported: bool, - /// Whether Shift+Up may hand focus to an owner-provided region above. - focus_above_available: bool, + /// Consults the owner live before Shift+Up leaves the first visual row. + can_move_focus_up: Rc bool>, } impl Entity for TuiInputView { @@ -200,6 +201,7 @@ impl TuiInputView { suggestions_mode: ModelHandle, inline_menus: Vec, transcript: ViewHandle, + can_move_focus_up: impl Fn(&AppContext) -> bool + 'static, ctx: &mut ViewContext, ) -> Self { Self::new_internal( @@ -208,6 +210,7 @@ impl TuiInputView { suggestions_mode, inline_menus, Some(transcript), + can_move_focus_up, ctx, ) } @@ -218,9 +221,18 @@ impl TuiInputView { input_mode: ModelHandle, suggestions_mode: ModelHandle, inline_menus: Vec, + can_move_focus_up: impl Fn(&AppContext) -> bool + 'static, ctx: &mut ViewContext, ) -> Self { - Self::new_internal(model, input_mode, suggestions_mode, inline_menus, None, ctx) + Self::new_internal( + model, + input_mode, + suggestions_mode, + inline_menus, + None, + can_move_focus_up, + ctx, + ) } fn new_internal( @@ -229,6 +241,7 @@ impl TuiInputView { suggestions_mode: ModelHandle, inline_menus: Vec, transcript: Option>, + can_move_focus_up: impl Fn(&AppContext) -> bool + 'static, ctx: &mut ViewContext, ) -> Self { ctx.subscribe_to_model(&model, |_, _, event, ctx| { @@ -251,19 +264,7 @@ impl TuiInputView { focused: false, transcript, keyboard_enhancement_supported: false, - focus_above_available: false, - } - } - - /// Enables or disables the first-row Shift+Up focus handoff. - pub(crate) fn set_focus_above_available( - &mut self, - available: bool, - ctx: &mut ViewContext, - ) { - if self.focus_above_available != available { - self.focus_above_available = available; - ctx.notify(); + can_move_focus_up: Rc::new(can_move_focus_up), } } @@ -481,7 +482,7 @@ impl TypedActionView for TuiInputView { } TuiInputAction::EditorCommand(command) => { if matches!(*command, TuiEditorCommand::SelectUp) && self.can_focus_above(ctx) { - ctx.emit(TuiInputViewEvent::FocusAboveRequested); + ctx.emit(TuiInputViewEvent::MoveFocusUp); ctx.notify(); return; } @@ -586,14 +587,16 @@ impl TuiInputView { /// Whether Shift+Up should leave the input instead of extending selection. fn can_focus_above(&self, ctx: &AppContext) -> bool { - if !self.focus_above_available || self.selection_range(ctx).is_some() { + if !(self.can_move_focus_up)(ctx) || self.selection_range(ctx).is_some() { return false; } + let model = self.model.as_ref(ctx); let render = model.render_state().as_ref(ctx); let Some(char_cell) = render.char_cell() else { return false; }; + let cursor_offset = CharOffset::from(self.cursor_offset(ctx).as_usize().saturating_sub(1)); let hidden = char_cell.hidden_line_ranges(ctx); char_cell diff --git a/crates/warp_tui/src/input/view_tests.rs b/crates/warp_tui/src/input/view_tests.rs index de97c893d3f..833f49bc443 100644 --- a/crates/warp_tui/src/input/view_tests.rs +++ b/crates/warp_tui/src/input/view_tests.rs @@ -3,7 +3,7 @@ //! These drive a real [`CodeEditorModel`] (TUI char-cell mode) behind a real //! [`TuiInputView`] so they exercise the exact render/layout/cursor path the //! presenter uses, not a reimplementation of it. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::ops::Range; use std::rc::Rc; @@ -314,7 +314,14 @@ fn build_view(ctx: &mut AppContext) -> ViewHandle { }, |ctx| { let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx)); - TuiInputView::new_for_test(model, input_mode, suggestions_mode, Vec::new(), ctx) + TuiInputView::new_for_test( + model, + input_mode, + suggestions_mode, + Vec::new(), + |_| false, + ctx, + ) }, ); view @@ -349,6 +356,7 @@ fn build_view_with_conversation_menu( input_mode, suggestions_mode, vec![inline_menu_for_view], + |_| false, ctx, ) }, @@ -400,6 +408,7 @@ fn build_view_with_inline_menu( input_mode, suggestions_mode, vec![inline_menu], + |_| false, ctx, ) }, @@ -441,6 +450,7 @@ fn build_view_with_model_menu( input_mode, suggestions_mode, vec![inline_menu], + |_| false, ctx, ) }, @@ -627,21 +637,50 @@ fn dispatch(view: &ViewHandle, ctx: &mut AppContext, actions: &[Tu #[test] fn shift_up_requests_focus_above_only_on_first_row_without_selection() { App::test((), |mut app| async move { - let (view, requests) = app.update(|ctx| { - let view = build_view(ctx); - view.update(ctx, |view, ctx| { - view.set_focus_above_available(true, ctx); - }); + let (view, requests, available) = app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + add_test_semantic_selection(ctx); + let input_mode = BlocklistAIInputModel::mock(Rc::new(TestInputModePolicy), ctx); + let suggestions_mode = add_suggestions_mode(ctx, TuiInputSuggestionsMode::Closed); + let available = Rc::new(Cell::new(false)); + let available_for_view = available.clone(); + let (_window_id, view) = ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + move |ctx| { + let model = ctx.add_model(|ctx| CodeEditorModel::new_tui(W, ctx)); + TuiInputView::new_for_test( + model, + input_mode, + suggestions_mode, + Vec::new(), + move |_| available_for_view.get(), + ctx, + ) + }, + ); let requests = Rc::new(RefCell::new(0usize)); let captured = requests.clone(); ctx.subscribe_to_view(&view, move |_, event, _| { - if let TuiInputViewEvent::FocusAboveRequested = event { + if let TuiInputViewEvent::MoveFocusUp = event { *captured.borrow_mut() += 1; } }); - (view, requests) + (view, requests, available) + }); + + app.update(|ctx| { + dispatch( + &view, + ctx, + &[TuiInputAction::EditorCommand(TuiEditorCommand::SelectUp)], + ); }); + assert_eq!(*requests.borrow(), 0); + available.set(true); app.update(|ctx| { dispatch( &view, diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 33c56591ee2..570cd805dd7 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -1,13 +1,14 @@ -//! [`TuiOrchestrationModel`]: the TUI's child-agent coordinator. +//! [`TuiOrchestrationModel`]: TUI orchestration runtime and navigation state. //! //! The shared `StartAgentExecutor` (one per session surface) emits //! `CreateAgent` and waits for a frontend to materialize the child. In the //! GUI that materializer is `TerminalView` → `PaneGroup`'s hidden child //! panes; in the TUI, [`crate::session_registry::TuiSessions`] owns //! materialization. This singleton prepares native Oz children, requests -//! background session lifecycle changes, and tracks the session dimension of -//! the orchestration tree — conversation lineage itself stays in -//! `BlocklistAIHistoryModel`. +//! background session lifecycle changes, tracks the session dimension of the +//! orchestration tree, and projects that tree into the single visible tab bar. +//! Conversation lineage and ordering policy stay in `BlocklistAIHistoryModel` +//! and the shared topology helpers. //! //! Native (Oz) local children run in background TUI sessions. Local //! CLI-harness and remote child requests resolve with an explicit failure. @@ -18,9 +19,10 @@ use std::path::PathBuf; use warp::tui_export::{ apply_child_agent_model_override, descendant_conversation_ids_in_pill_order, descendant_conversation_ids_in_spawn_order, inherit_child_agent_settings, - prepare_local_oz_child_launch, register_agent_event_consumer, unregister_agent_event_consumer, - AIConversationId, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, - Harness, RenderableAIError, StartAgentExecutionMode, StartAgentRequest, + orchestration_root_conversation_id, prepare_local_oz_child_launch, + register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, + BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, Harness, + RenderableAIError, StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; @@ -28,18 +30,17 @@ use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewH use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::terminal_session_view::TuiTerminalSessionView; - /// One navigable child tab in an orchestration snapshot. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct TuiOrchestrationTab { pub(crate) conversation_id: AIConversationId, pub(crate) label: String, - pub(crate) identity_index: usize, + pub(crate) spawn_index: usize, } -/// Plain-data tab state for one complete orchestration tree. +/// Live semantic state for the orchestration tab bar. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TuiOrchestrationTabSnapshot { +pub(crate) struct TuiOrchestrationSnapshot { pub(crate) root_conversation_id: AIConversationId, pub(crate) selected_conversation_id: AIConversationId, pub(crate) tabs: Vec, @@ -48,12 +49,7 @@ pub(crate) struct TuiOrchestrationTabSnapshot { } -#[derive(Default)] -struct TuiOrchestrationTabState { - page_anchor: Option, - explicitly_paged: bool, -} -/// The TUI's child-agent coordinator singleton. See the module docs. +/// The TUI's orchestration singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension /// only — conversation lineage is read from `BlocklistAIHistoryModel` @@ -61,76 +57,10 @@ pub(crate) struct TuiOrchestrationModel { child_session_by_conversation: HashMap, /// Conversations whose event streams are consumed by each live session. event_consumers_by_session: HashMap>, - tab_state_by_root: HashMap, -} - -/// Resolves the topmost loaded conversation in a tree, rejecting cycles. -fn conversation_tree_root( - history: &BlocklistAIHistoryModel, - conversation_id: AIConversationId, -) -> Option { - history.conversation(&conversation_id)?; - let mut current = conversation_id; - let mut visited = HashSet::new(); - while visited.insert(current) { - let conversation = history.conversation(¤t)?; - let Some(parent) = history.resolved_parent_conversation_id_for_conversation(conversation) - else { - return Some(current); - }; - current = parent; - } - None -} -/// Resolves a child-bearing orchestration root for tab navigation. -fn orchestration_root( - history: &BlocklistAIHistoryModel, - conversation_id: AIConversationId, -) -> Option { - let root = conversation_tree_root(history, conversation_id)?; - (!history.child_conversation_ids_of(&root).is_empty()).then_some(root) -} - -/// Resolves a conversation through history ownership to a retained TUI session. -fn navigable_session_id( - history: &BlocklistAIHistoryModel, - sessions: &TuiSessions, - conversation_id: AIConversationId, -) -> Option { - let surface_id = history.terminal_surface_id_for_conversation(&conversation_id)?; - sessions.session_id_for_surface(surface_id) -} - -/// Returns the child-agent label used by orchestration tabs. -fn conversation_label( - history: &BlocklistAIHistoryModel, - conversation_id: AIConversationId, -) -> String { - history - .conversation(&conversation_id) - .and_then(|conversation| conversation.agent_name()) - .filter(|name| !name.is_empty()) - .unwrap_or("Agent") - .to_string() -} - -/// Whether a history event can change tab membership, order, label, or selection. -fn history_event_changes_tab_bar(event: &BlocklistAIHistoryEvent) -> bool { - matches!( - event, - BlocklistAIHistoryEvent::StartedNewConversation { .. } - | BlocklistAIHistoryEvent::UpdatedConversationStatus { .. } - | BlocklistAIHistoryEvent::SetActiveConversation { .. } - | BlocklistAIHistoryEvent::ClearedActiveConversation { .. } - | BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } - | BlocklistAIHistoryEvent::SplitConversation { .. } - | BlocklistAIHistoryEvent::RemoveConversation { .. } - | BlocklistAIHistoryEvent::DeletedConversation { .. } - | BlocklistAIHistoryEvent::RestoredConversations { .. } - | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } - | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } - | BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { .. } - ) + /// Page state for the single orchestration tab bar visible at a time. + page_root_conversation_id: Option, + page_anchor: Option, + explicitly_paged: bool, } pub(crate) enum TuiOrchestrationEvent { CreateLocalOzChildSession { @@ -167,82 +97,121 @@ impl TuiOrchestrationModel { let model = ctx.add_singleton_model(|_| Self { child_session_by_conversation: HashMap::new(), event_consumers_by_session: HashMap::new(), - tab_state_by_root: HashMap::new(), + page_root_conversation_id: None, + page_anchor: None, + explicitly_paged: false, }); let model_for_history = model.clone(); ctx.subscribe_to_model(&history, move |_, event, ctx| { - if !history_event_changes_tab_bar(event) { - return; + let topology_changed = match event { + BlocklistAIHistoryEvent::StartedNewConversation { .. } + | BlocklistAIHistoryEvent::UpdatedConversationStatus { .. } + | BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } + | BlocklistAIHistoryEvent::SplitConversation { .. } + | BlocklistAIHistoryEvent::RemoveConversation { .. } + | BlocklistAIHistoryEvent::DeletedConversation { .. } + | BlocklistAIHistoryEvent::RestoredConversations { .. } + | BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. } + | BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { + .. + } => true, + BlocklistAIHistoryEvent::CreatedSubtask { .. } + | BlocklistAIHistoryEvent::UpgradedTask { .. } + | BlocklistAIHistoryEvent::AppendedExchange { .. } + | BlocklistAIHistoryEvent::ReassignedExchange { .. } + | BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. } + | BlocklistAIHistoryEvent::SetActiveConversation { .. } + | BlocklistAIHistoryEvent::ClearedActiveConversation { .. } + | BlocklistAIHistoryEvent::UpdatedTodoList { .. } + | BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. } + | BlocklistAIHistoryEvent::UpdatedConversationTitle { .. } + | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } + | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } + | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } + | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => false, + }; + + if topology_changed { + model_for_history.update(ctx, |model, ctx| model.topology_changed(ctx)); } - model_for_history.update(ctx, |model, ctx| { - model.prune_tab_state(ctx); - ctx.emit(TuiOrchestrationEvent::TabBarChanged); - ctx.notify(); - }); }); model } /// Builds the current navigable tab tree for a selected conversation. - pub(crate) fn tab_snapshot( + pub(crate) fn snapshot( &self, selected_conversation_id: AIConversationId, - identity_palette_len: usize, ctx: &AppContext, - ) -> Option { + ) -> Option { let history = BlocklistAIHistoryModel::as_ref(ctx); - let root_conversation_id = orchestration_root(history, selected_conversation_id)?; + let root_conversation_id = + orchestration_root_conversation_id(history, selected_conversation_id)?; let sessions = TuiSessions::as_ref(ctx); - navigable_session_id(history, sessions, root_conversation_id)?; + sessions.session_id_for_conversation(history, root_conversation_id)?; let spawn_order = descendant_conversation_ids_in_spawn_order(history, root_conversation_id) .into_iter() .filter(|conversation_id| { - navigable_session_id(history, sessions, *conversation_id).is_some() + sessions + .session_id_for_conversation(history, *conversation_id) + .is_some() }) .collect::>(); if spawn_order.is_empty() { return None; } - let names = spawn_order - .iter() - .map(|conversation_id| conversation_label(history, *conversation_id)); - let identity_indices = assign_agent_identity_indices(names, identity_palette_len); - let identity_by_conversation = spawn_order + let spawn_index_by_conversation = spawn_order .iter() - .copied() - .zip(identity_indices) + .enumerate() + .map(|(index, conversation_id)| (*conversation_id, index)) .collect::>(); let tabs = descendant_conversation_ids_in_pill_order(history, root_conversation_id) .into_iter() .filter_map(|conversation_id| { - navigable_session_id(history, sessions, conversation_id)?; + sessions.session_id_for_conversation(history, conversation_id)?; + let conversation = history.conversation(&conversation_id)?; Some(TuiOrchestrationTab { conversation_id, - label: conversation_label(history, conversation_id), - identity_index: *identity_by_conversation.get(&conversation_id)?, + label: conversation + .agent_name() + .filter(|name| !name.is_empty()) + .unwrap_or("Agent") + .to_owned(), + spawn_index: *spawn_index_by_conversation.get(&conversation_id)?, }) }) .collect::>(); if tabs.is_empty() { return None; } - let state = self.tab_state_by_root.get(&root_conversation_id); - let explicitly_paged = state.is_some_and(|state| state.explicitly_paged); - let stored_anchor = state.and_then(|state| state.page_anchor); - let page_anchor = stored_anchor + + let page_state_applies = self.page_root_conversation_id == Some(root_conversation_id); + let page_anchor = page_state_applies + .then_some(self.page_anchor) + .flatten() .filter(|anchor| tabs.iter().any(|tab| tab.conversation_id == *anchor)) .or_else(|| tabs.first().map(|tab| tab.conversation_id)); - Some(TuiOrchestrationTabSnapshot { + Some(TuiOrchestrationSnapshot { root_conversation_id, selected_conversation_id, tabs, page_anchor, - reveal_selected: !explicitly_paged, + reveal_selected: !page_state_applies || !self.explicitly_paged, }) } + pub(crate) fn has_tabs( + &self, + selected_conversation_id: AIConversationId, + ctx: &AppContext, + ) -> bool { + self.snapshot(selected_conversation_id, ctx).is_some() + } + /// Stores an explicitly selected secondary page without switching sessions. pub(crate) fn set_explicit_page( &mut self, @@ -250,43 +219,54 @@ impl TuiOrchestrationModel { page_anchor: AIConversationId, ctx: &mut ModelContext, ) { - self.tab_state_by_root.insert( - root_conversation_id, - TuiOrchestrationTabState { - page_anchor: Some(page_anchor), - explicitly_paged: true, - }, - ); - ctx.emit(TuiOrchestrationEvent::TabBarChanged); - ctx.notify(); + self.page_root_conversation_id = Some(root_conversation_id); + self.page_anchor = Some(page_anchor); + self.explicitly_paged = true; + self.emit_changed(ctx); } - /// Focuses the retained session for a conversation and clears explicit paging. - pub(crate) fn select_conversation( + /// Focuses the retained session for a conversation and resumes automatic reveal. + pub(crate) fn focus_conversation_session( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) -> Option { + let current_page_anchor = self + .snapshot(conversation_id, ctx) + .and_then(|snapshot| snapshot.page_anchor); let history = BlocklistAIHistoryModel::as_ref(ctx); - let root_conversation_id = orchestration_root(history, conversation_id)?; - let session_id = navigable_session_id(history, TuiSessions::as_ref(ctx), conversation_id)?; - self.tab_state_by_root - .entry(root_conversation_id) - .or_default() - .explicitly_paged = false; + let root_conversation_id = orchestration_root_conversation_id(history, conversation_id)?; + let session_id = + TuiSessions::as_ref(ctx).session_id_for_conversation(history, conversation_id)?; + if self.page_root_conversation_id != Some(root_conversation_id) { + // Capture the page before focus changes any ordering inputs. The + // first switch in a tree must not replace the visible page with a + // new fallback anchor. + self.page_anchor = current_page_anchor; + } + self.page_root_conversation_id = Some(root_conversation_id); + self.explicitly_paged = false; TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { sessions.focus_session(session_id, ctx); }); - ctx.emit(TuiOrchestrationEvent::TabBarChanged); - ctx.notify(); + self.emit_changed(ctx); Some(session_id) } - /// Drops page state for orchestration roots no longer present in history. - fn prune_tab_state(&mut self, ctx: &AppContext) { - let history = BlocklistAIHistoryModel::as_ref(ctx); - self.tab_state_by_root - .retain(|root, _| history.conversation(root).is_some()); + fn topology_changed(&mut self, ctx: &mut ModelContext) { + if self.page_root_conversation_id.is_some_and(|root| { + orchestration_root_conversation_id(BlocklistAIHistoryModel::as_ref(ctx), root).is_none() + }) { + self.page_root_conversation_id = None; + self.page_anchor = None; + self.explicitly_paged = false; + } + self.emit_changed(ctx); + } + + fn emit_changed(&self, ctx: &mut ModelContext) { + ctx.emit(TuiOrchestrationEvent::TabBarChanged); + ctx.notify(); } @@ -414,7 +394,7 @@ impl TuiOrchestrationModel { self.register_event_consumer(parent_session_id, request.parent_conversation_id, ctx); self.register_event_consumer(session_id, conversation_id, ctx); session_view.update(ctx, |view, ctx| { - view.activate_orchestration_child_conversation(conversation_id, ctx); + view.initialize_orchestrated_child_conversation(conversation_id, ctx); }); let prompt = request.prompt; @@ -424,8 +404,7 @@ impl TuiOrchestrationModel { self.child_session_by_conversation .insert(conversation_id, session_id); - ctx.emit(TuiOrchestrationEvent::TabBarChanged); - ctx.notify(); + self.emit_changed(ctx); } /// Tears down the background session of a child that failed at the diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index b511e22ebd3..9dc9c6005e0 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -42,7 +42,7 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { } } -/// Adds a retained child session linked to `parent_conversation_id`. + fn add_child_session( app: &mut App, fixture: &OrchestrationFixture, @@ -57,7 +57,7 @@ fn add_child_session( BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { let conversation_id = history.start_new_child_conversation( session_id.surface_id(), - name.to_string(), + name.to_owned(), parent_conversation_id, Some(Harness::Oz), ctx, @@ -78,7 +78,6 @@ fn add_dispatching_session( let (session, manager) = add_test_terminal_session(app, fixture.window_id); app.update(|ctx| TuiSessions::register_session(&fixture.sessions, session, manager, focus, ctx)) } - /// Creates a standalone executor and relays its frontend materialization /// events into the coordinator. fn add_relayed_executor( @@ -194,25 +193,25 @@ fn local_harness_children_fail_cleanly() { } #[test] -fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { +fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); let parent_session_id = add_dispatching_session(&mut app, &fixture, true); let parent_conversation_id = app.read(|ctx| { BlocklistAIHistoryModel::as_ref(ctx) .active_conversation(parent_session_id.surface_id()) - .unwrap() + .expect("parent conversation") .id() }); let (first_session_id, first_child_id) = add_child_session(&mut app, &fixture, parent_conversation_id, "first-child"); - let (_, second_child_id) = + let (second_session_id, second_child_id) = add_child_session(&mut app, &fixture, parent_conversation_id, "second-child"); app.update(|ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.start_new_child_conversation( warpui::EntityId::new(), - "missing-session".to_string(), + "missing-session".to_owned(), parent_conversation_id, Some(Harness::Oz), ctx, @@ -223,10 +222,10 @@ fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { app.read(|ctx| { let model = TuiOrchestrationModel::as_ref(ctx); let parent = model - .tab_snapshot(parent_conversation_id, 32, ctx) + .snapshot(parent_conversation_id, ctx) .expect("parent has navigable children"); let child = model - .tab_snapshot(first_child_id, 32, ctx) + .snapshot(first_child_id, ctx) .expect("child resolves the same tree"); assert_eq!(parent.root_conversation_id, parent_conversation_id); assert_eq!(child.root_conversation_id, parent_conversation_id); @@ -240,6 +239,19 @@ fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { ); }); + app.update(|ctx| { + let selected = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.focus_conversation_session(second_child_id, ctx) + }); + assert_eq!(selected, Some(second_session_id)); + }); + app.read(|ctx| { + let snapshot = TuiOrchestrationModel::as_ref(ctx) + .snapshot(second_child_id, ctx) + .expect("tab snapshot"); + assert_eq!(snapshot.page_anchor, Some(first_child_id)); + assert!(snapshot.reveal_selected); + }); app.update(|ctx| { TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { model.set_explicit_page(parent_conversation_id, second_child_id, ctx); @@ -247,22 +259,22 @@ fn tab_snapshot_is_shared_across_tree_and_filters_non_sessions() { }); app.read(|ctx| { let snapshot = TuiOrchestrationModel::as_ref(ctx) - .tab_snapshot(parent_conversation_id, 32, ctx) - .unwrap(); + .snapshot(parent_conversation_id, ctx) + .expect("tab snapshot"); assert_eq!(snapshot.page_anchor, Some(second_child_id)); assert!(!snapshot.reveal_selected); }); app.update(|ctx| { let selected = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { - model.select_conversation(first_child_id, ctx) + model.focus_conversation_session(first_child_id, ctx) }); assert_eq!(selected, Some(first_session_id)); }); app.read(|ctx| { let snapshot = TuiOrchestrationModel::as_ref(ctx) - .tab_snapshot(first_child_id, 32, ctx) - .unwrap(); + .snapshot(first_child_id, ctx) + .expect("tab snapshot"); assert_eq!( TuiSessions::as_ref(ctx).focused_session_id(), Some(first_session_id) diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 96a6adfaa20..65547cc86a2 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -9,8 +9,8 @@ use std::path::PathBuf; use pathfinder_geometry::vector::Vector2F; use warp::tui_export::{ - BannerState, IsSharedSessionCreator, LocalTtyTerminalManager, ServerConversationToken, - TerminalManagerTrait, TerminalSurfaceResult, + AIConversationId, BannerState, BlocklistAIHistoryModel, IsSharedSessionCreator, + LocalTtyTerminalManager, ServerConversationToken, TerminalManagerTrait, TerminalSurfaceResult, }; use warpui::SingletonEntity; use warpui_core::runtime::TuiDriverHandle; @@ -325,12 +325,17 @@ impl TuiSessions { pub(crate) fn session(&self, id: TuiSessionId) -> Option<&TuiSession> { self.sessions.iter().find(|session| session.id == id) } - /// Looks up a retained session by its terminal surface id. - pub(crate) fn session_id_for_surface(&self, surface_id: EntityId) -> Option { + + /// Resolves a loaded conversation to the retained session that owns it. + pub(crate) fn session_id_for_conversation( + &self, + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, + ) -> Option { + let surface_id = history.terminal_surface_id_for_conversation(&conversation_id)?; self.sessions .iter() - .find(|session| session.id.surface_id() == surface_id) - .map(|session| session.id) + .find_map(|session| (session.id.surface_id() == surface_id).then_some(session.id)) } /// Whether no session has been registered. diff --git a/crates/warp_tui/src/session_registry_tests.rs b/crates/warp_tui/src/session_registry_tests.rs index 4855dfa6835..49cdb1af86f 100644 --- a/crates/warp_tui/src/session_registry_tests.rs +++ b/crates/warp_tui/src/session_registry_tests.rs @@ -66,12 +66,6 @@ fn focus_drives_events() { TuiSessions::register_session(&sessions, second, second_manager, false, ctx) }); assert_eq!(second_id.surface_id(), second_view_id); - assert_eq!( - app.read_model(&sessions, |sessions, _| { - sessions.session_id_for_surface(second_view_id) - }), - Some(second_id), - ); assert!(std::mem::take(&mut *events.borrow_mut()).is_empty()); assert_eq!( app.read_model(&sessions, |sessions, _| sessions.focused_session_id()), diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 9eb333f8105..44e4c962728 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -70,16 +70,18 @@ use crate::keybindings::{ }; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; +use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::orchestration_block::TuiOrchestrationBlock; use crate::orchestration_model::{ - TuiOrchestrationEvent, TuiOrchestrationModel, TuiOrchestrationTabSnapshot, + TuiOrchestrationEvent, TuiOrchestrationModel, TuiOrchestrationSnapshot, }; use crate::resume::TuiExitSummaryHandle; use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; use crate::slash_commands::TuiSlashCommandModel; use crate::tab_bar::{ - TuiTab, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, TuiTabBarView, + TuiTab, TuiTabBarConfig, TuiTabBarEvent, TuiTabBarNavigationDirection, TuiTabBarSecondaryEdge, + TuiTabBarView, }; use crate::terminal_content_element::TuiTerminalContentElement; use crate::terminal_use::{ @@ -248,11 +250,11 @@ pub(crate) enum TuiTerminalSessionAction { ForwardUserPtyBytes(Vec), /// Toggle the latest exposed inline plan. TogglePlan, - /// Return keyboard focus from tabs to the session interaction. - FocusSessionInteraction, - /// Select the previous tab using the component's settled layout. + /// Return keyboard focus from tabs to the session's default interaction target. + FocusDefaultInteractionTarget, + /// Select the previous tab using the tab view's semantic order. SelectPreviousOrchestrationTab, - /// Select the next tab using the component's settled layout. + /// Select the next tab using the tab view's semantic order. SelectNextOrchestrationTab, /// Select the first child tab, excluding the orchestrator. SelectFirstOrchestrationChild, @@ -364,6 +366,9 @@ pub(crate) fn init(app: &mut AppContext) { .with_group(TUI_BINDING_GROUP) .with_key_binding("ctrl-p"), ]); + + // Tab navigation is user-remappable, unlike the reserved session-control + // bindings above, so the two groups use different registration APIs. let tab_context = id!("TuiTerminalSessionView") & id!(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); app.register_editable_bindings([ EditableBinding::new( @@ -417,7 +422,7 @@ pub(crate) fn init(app: &mut AppContext) { EditableBinding::new( "tui:orchestration_tabs:focus_input", "Return focus to the session input", - TuiTerminalSessionAction::FocusSessionInteraction, + TuiTerminalSessionAction::FocusDefaultInteractionTarget, ) .with_context_predicate(tab_context) .with_group(TUI_BINDING_GROUP) @@ -439,7 +444,10 @@ impl TuiTerminalSessionView { fn focus_current_owner(&mut self, ctx: &mut ViewContext) { match self.input_target() { - TuiInputTarget::Disabled | TuiInputTarget::Pty => ctx.focus_self(), + TuiInputTarget::Disabled | TuiInputTarget::Pty => { + self.orchestration_tabs_focused = false; + ctx.focus_self(); + } TuiInputTarget::AgentEditor => { if let Some(blocker) = self.active_blocking_child(ctx) { self.orchestration_tabs_focused = false; @@ -905,6 +913,7 @@ impl TuiTerminalSessionView { let inline_menus_for_input = inline_menus.clone(); let suggestions_mode_for_input = suggestions_mode.clone(); let transcript_for_input = transcript.clone(); + let conversation_selection_for_input = conversation_selection.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( input_editor_model, @@ -912,6 +921,19 @@ impl TuiTerminalSessionView { suggestions_mode_for_input, inline_menus_for_input, transcript_for_input, + move |ctx| { + if !ctx.has_singleton_model::() + || !ctx.has_singleton_model::() + { + return false; + } + conversation_selection_for_input + .as_ref(ctx) + .selected_conversation_id(ctx) + .is_some_and(|conversation_id| { + TuiOrchestrationModel::as_ref(ctx).has_tabs(conversation_id, ctx) + }) + }, ctx, ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) @@ -950,7 +972,7 @@ impl TuiTerminalSessionView { TuiInputViewEvent::AcceptedMcp(action) => { view.handle_accepted_mcp_action(*action, ctx); } - TuiInputViewEvent::FocusAboveRequested => { + TuiInputViewEvent::MoveFocusUp => { view.focus_orchestration_tabs(ctx); } }); @@ -974,7 +996,7 @@ impl TuiTerminalSessionView { else { return; }; - view.select_orchestration_conversation( + view.switch_to_orchestration_conversation( conversation_id, view.orchestration_tabs_focused, ctx, @@ -984,8 +1006,7 @@ impl TuiTerminalSessionView { let Ok(page_anchor) = AIConversationId::try_from(page_anchor.clone()) else { return; }; - let palette_len = TuiUiBuilder::from_app(ctx).agent_identity_palette().len(); - let Some(snapshot) = view.orchestration_tab_snapshot(palette_len, ctx) else { + let Some(snapshot) = view.orchestration_tab_snapshot(ctx) else { return; }; TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { @@ -1249,8 +1270,8 @@ impl TuiTerminalSessionView { }); } - /// Selects a newly materialized child conversation without changing view focus. - pub(crate) fn activate_orchestration_child_conversation( + /// Initializes a background child session with the conversation it owns. + pub(crate) fn initialize_orchestrated_child_conversation( &mut self, conversation_id: AIConversationId, ctx: &mut ViewContext, @@ -1269,89 +1290,75 @@ impl TuiTerminalSessionView { } /// Resolves the orchestration tabs for this session's selected conversation. - fn orchestration_tab_snapshot( - &self, - identity_palette_len: usize, - ctx: &AppContext, - ) -> Option { + fn orchestration_tab_snapshot(&self, ctx: &AppContext) -> Option { if !ctx.has_singleton_model::() || !ctx.has_singleton_model::() { return None; } let selected_conversation_id = self.selected_conversation_id(ctx)?; - TuiOrchestrationModel::as_ref(ctx).tab_snapshot( - selected_conversation_id, - identity_palette_len, - ctx, - ) + TuiOrchestrationModel::as_ref(ctx).snapshot(selected_conversation_id, ctx) } - /// Reconciles input/tab focus availability after orchestration state changes. + /// Drops tab focus when this session no longer has an orchestration bar. fn handle_orchestration_tab_change(&mut self, ctx: &mut ViewContext) { let builder = TuiUiBuilder::from_app(ctx); - let snapshot = self.orchestration_tab_snapshot(builder.agent_identity_palette().len(), ctx); - let available = snapshot.is_some(); - self.input_view.update(ctx, |input, ctx| { - input.set_focus_above_available(available, ctx); - }); + let snapshot = self.orchestration_tab_snapshot(ctx); if let Some(snapshot) = snapshot.as_ref() { self.sync_orchestration_tab_bar(snapshot, &builder, ctx); } - if !available && self.orchestration_tabs_focused { - self.focus_session_interaction(ctx); + if snapshot.is_none() && self.orchestration_tabs_focused { + self.orchestration_tabs_focused = false; + if self.is_focused_session(ctx) { + self.focus_current_owner(ctx); + } } ctx.notify(); } /// Gives keyboard focus to the orchestration tab bar when it is available. fn focus_orchestration_tabs(&mut self, ctx: &mut ViewContext) { - let builder = TuiUiBuilder::from_app(ctx); - let Some(snapshot) = - self.orchestration_tab_snapshot(builder.agent_identity_palette().len(), ctx) - else { + if self.orchestration_tab_snapshot(ctx).is_none() { return; - }; - self.orchestration_tabs_focused = true; - self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); - ctx.focus_self(); - ctx.notify(); + } + self.set_orchestration_tab_focus(true, ctx); } - /// Focuses the session's blocker or input and leaves tab-navigation mode. - fn focus_session_interaction(&mut self, ctx: &mut ViewContext) { - self.orchestration_tabs_focused = false; + /// Applies tab-focus mode, synchronizes presentation, and resolves the focus owner. + fn set_orchestration_tab_focus(&mut self, focused: bool, ctx: &mut ViewContext) { + self.orchestration_tabs_focused = focused; let builder = TuiUiBuilder::from_app(ctx); - if let Some(snapshot) = - self.orchestration_tab_snapshot(builder.agent_identity_palette().len(), ctx) - { + if let Some(snapshot) = self.orchestration_tab_snapshot(ctx) { self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); } self.focus_current_owner(ctx); ctx.notify(); } - /// Switches retained sessions and applies the requested target focus mode. - fn select_orchestration_conversation( + /// Leaves the tab bar and focuses the session's normal interaction owner. + fn focus_default_interaction_target(&mut self, ctx: &mut ViewContext) { + self.set_orchestration_tab_focus(false, ctx); + } + + /// Switches to the retained session that owns an orchestration conversation. + fn switch_to_orchestration_conversation( &mut self, conversation_id: AIConversationId, keep_tab_focus: bool, ctx: &mut ViewContext, ) { let session_id = TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { - model.select_conversation(conversation_id, ctx) + model.focus_conversation_session(conversation_id, ctx) }); let Some(session_id) = session_id else { return; }; if session_id.surface_id() == self.terminal_surface_id { - if keep_tab_focus { - self.focus_orchestration_tabs(ctx); - } else { - self.focus_session_interaction(ctx); - } + self.set_orchestration_tab_focus(keep_tab_focus, ctx); return; } + self.orchestration_tabs_focused = false; + ctx.notify(); let target_view = TuiSessions::as_ref(ctx) .session(session_id) .map(|session| session.view().clone()); @@ -1359,51 +1366,45 @@ impl TuiTerminalSessionView { return; }; target_view.update(ctx, |target, target_ctx| { - if keep_tab_focus { - target.focus_orchestration_tabs(target_ctx); - } else { - target.focus_session_interaction(target_ctx); - } + target.set_orchestration_tab_focus(keep_tab_focus, target_ctx); target.handle_orchestration_tab_change(target_ctx); }); } - /// Selects the first or last navigable child from the current snapshot. - fn select_orchestration_edge_child(&mut self, first: bool, ctx: &mut ViewContext) { - let palette_len = TuiUiBuilder::from_app(ctx).agent_identity_palette().len(); - let Some(snapshot) = self.orchestration_tab_snapshot(palette_len, ctx) else { - return; - }; - let target = if first { - snapshot.tabs.first() - } else { - snapshot.tabs.last() - }; - if let Some(target) = target { - self.select_orchestration_conversation(target.conversation_id, true, ctx); - } - } - /// Builds the tab child-view configuration for an orchestration snapshot. fn orchestration_tab_bar_config( &self, - snapshot: &TuiOrchestrationTabSnapshot, + snapshot: &TuiOrchestrationSnapshot, builder: &TuiUiBuilder, ) -> TuiTabBarConfig { let palette = builder.agent_identity_palette(); + let mut tabs_in_spawn_order = snapshot.tabs.iter().collect::>(); + tabs_in_spawn_order.sort_by_key(|tab| tab.spawn_index); + let identity_indices = assign_agent_identity_indices( + tabs_in_spawn_order.iter().map(|tab| tab.label.as_str()), + palette.len(), + ); + let identity_by_conversation = tabs_in_spawn_order + .into_iter() + .map(|tab| tab.conversation_id) + .zip(identity_indices) + .collect::>(); let tabs = snapshot .tabs .iter() .map(|tab| { let identity = palette - .get(tab.identity_index) + .get( + identity_by_conversation + .get(&tab.conversation_id) + .copied() + .unwrap_or_default(), + ) .or_else(|| palette.first()) .cloned() .unwrap_or_default(); - let glyph = identity.glyph.to_owned(); - let style = identity.style; TuiTab::new(tab.conversation_id.to_string(), tab.label.clone()) - .with_leading_text(glyph, style) + .with_leading_text(identity.glyph, identity.style) }) .collect(); let mut config = TuiTabBarConfig::new(tabs); @@ -1425,7 +1426,7 @@ impl TuiTerminalSessionView { /// Synchronizes the retained tab child view from current orchestration state. fn sync_orchestration_tab_bar( &self, - snapshot: &TuiOrchestrationTabSnapshot, + snapshot: &TuiOrchestrationSnapshot, builder: &TuiUiBuilder, ctx: &mut ViewContext, ) { @@ -2641,7 +2642,7 @@ impl TuiView for TuiTerminalSessionView { fn keymap_context(&self, ctx: &AppContext) -> keymap::Context { let mut context = Self::default_keymap_context(); - if self.orchestration_tabs_focused { + if self.orchestration_tabs_focused && self.input_target().agent_editor_owns_input() { context.set.insert(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); } if self.is_conversation_restore_loading() { @@ -2704,8 +2705,7 @@ impl TuiView for TuiTerminalSessionView { }) .flatten(); let builder = TuiUiBuilder::from_app(ctx); - let identity_palette_len = builder.agent_identity_palette().len(); - let orchestration_tabs = self.orchestration_tab_snapshot(identity_palette_len, ctx); + let orchestration_tabs = self.orchestration_tab_snapshot(ctx); let orchestration_tabs_available = orchestration_tabs.is_some(); // Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the @@ -2863,8 +2863,8 @@ impl TypedActionView for TuiTerminalSessionView { self.hand_back_terminal_use_control(ctx) } TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), - TuiTerminalSessionAction::FocusSessionInteraction => { - self.focus_session_interaction(ctx) + TuiTerminalSessionAction::FocusDefaultInteractionTarget => { + self.focus_default_interaction_target(ctx) } TuiTerminalSessionAction::SelectPreviousOrchestrationTab => { if let Some(conversation_id) = self @@ -2873,7 +2873,7 @@ impl TypedActionView for TuiTerminalSessionView { .navigation_target(TuiTabBarNavigationDirection::Previous) .and_then(|key| AIConversationId::try_from(key).ok()) { - self.select_orchestration_conversation(conversation_id, true, ctx); + self.switch_to_orchestration_conversation(conversation_id, true, ctx); } } TuiTerminalSessionAction::SelectNextOrchestrationTab => { @@ -2883,14 +2883,28 @@ impl TypedActionView for TuiTerminalSessionView { .navigation_target(TuiTabBarNavigationDirection::Next) .and_then(|key| AIConversationId::try_from(key).ok()) { - self.select_orchestration_conversation(conversation_id, true, ctx); + self.switch_to_orchestration_conversation(conversation_id, true, ctx); } } TuiTerminalSessionAction::SelectFirstOrchestrationChild => { - self.select_orchestration_edge_child(true, ctx) + if let Some(conversation_id) = self + .orchestration_tab_bar + .as_ref(ctx) + .secondary_edge_target(TuiTabBarSecondaryEdge::First) + .and_then(|key| AIConversationId::try_from(key).ok()) + { + self.switch_to_orchestration_conversation(conversation_id, true, ctx); + } } TuiTerminalSessionAction::SelectLastOrchestrationChild => { - self.select_orchestration_edge_child(false, ctx) + if let Some(conversation_id) = self + .orchestration_tab_bar + .as_ref(ctx) + .secondary_edge_target(TuiTabBarSecondaryEdge::Last) + .and_then(|key| AIConversationId::try_from(key).ok()) + { + self.switch_to_orchestration_conversation(conversation_id, true, ctx); + } } TuiTerminalSessionAction::ForwardUserPtyBytes(bytes) => { // Raw passthrough: the bytes are already the app's escape diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index fb72100d4c1..e606fcf9af1 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -1,25 +1,71 @@ use warp::appearance::Appearance; use warp::tui_export::{ - export_conversation_markdown, PtyIntent, PtyIntentEvent, SizeInfo, SizeUpdate, + export_conversation_markdown, register_tui_session_view_test_singletons, PtyIntent, + PtyIntentEvent, SizeInfo, SizeUpdate, }; -use warpui::EntityIdMap; +use warpui::platform::WindowStyle; +use warpui::{AddWindowOptions, EntityIdMap, ModelHandle, ReadModel, UpdateModel, ViewHandle}; use warpui_core::elements::tui::{ TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, }; use warpui_core::keymap::{Context, Keystroke, Trigger}; -use warpui_core::{App, AppContext}; +use warpui_core::{App, AppContext, TuiView}; use super::{ export_file_success_message, raw_prompt_if_not_blank, render_left_footer_hint, - TuiTerminalSessionEvent, + TuiTerminalSessionEvent, ORCHESTRATION_TAB_BAR_FOCUSED_FLAG, }; +use crate::autoupdate::TuiAutoupdater; use crate::keybindings::{ CONTEXTUAL_PLAN_TOGGLE_BINDING_NAME, KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG, PLAN_TOGGLE_AVAILABLE_FLAG, PLAN_TOGGLE_BINDING_NAME, }; +use crate::orchestration_model::TuiOrchestrationModel; +use crate::root_view::RootTuiView; +use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; use crate::tui_builder::TuiUiBuilder; +struct FocusTestFixture { + window_id: warpui_core::WindowId, + sessions: ModelHandle, +} + +fn focus_test_fixture(app: &mut App) -> FocusTestFixture { + register_tui_session_view_test_singletons(app); + add_test_semantic_selection(app); + app.update(TuiAutoupdater::register); + let (window_id, _) = app.update(|ctx| { + ctx.add_tui_window( + AddWindowOptions { + window_style: WindowStyle::NotStealFocus, + ..Default::default() + }, + |_| RootTuiView::new(), + ) + }); + let sessions = app.add_singleton_model(|_| TuiSessions::new_for_test()); + let orchestration = app.update(TuiOrchestrationModel::register); + app.update(|ctx| TuiSessions::wire_orchestration(&sessions, &orchestration, ctx)); + FocusTestFixture { + window_id, + sessions, + } +} + +fn add_focus_test_session( + app: &mut App, + fixture: &FocusTestFixture, + focus: bool, +) -> (ViewHandle, TuiSessionId) { + let (view, manager) = add_test_terminal_session(app, fixture.window_id); + let session_id = app.update(|ctx| { + TuiSessions::register_session(&fixture.sessions, view.clone(), manager, focus, ctx) + }); + (view, session_id) +} + fn render_element(mut element: Box, ctx: &AppContext, width: u16) -> TuiBuffer { let mut rendered_views = EntityIdMap::default(); let mut layout_ctx = TuiLayoutContext { @@ -205,3 +251,48 @@ fn resize_event_maps_to_pty_resize_intent() { assert_eq!(actual_update.new_size().rows(), 8); assert_eq!(actual_update.new_size().columns(), 42); } + +#[test] +fn alternate_screen_clears_orchestration_tab_focus_and_bindings() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (view, _) = add_focus_test_session(&mut app, &fixture, true); + + view.update(&mut app, |view, ctx| { + view.orchestration_tabs_focused = true; + view.terminal_model.lock().process_bytes("\u{1b}[?1049h"); + view.focus_current_owner(ctx); + }); + view.read(&app, |view, ctx| { + assert!(!view.orchestration_tabs_focused); + assert!(!view + .keymap_context(ctx) + .set + .contains(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG)); + }); + }); +} + +#[test] +fn background_tab_refresh_never_moves_responder_focus() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (foreground, foreground_id) = add_focus_test_session(&mut app, &fixture, true); + let (background, _) = add_focus_test_session(&mut app, &fixture, false); + + background.update(&mut app, |view, ctx| { + view.orchestration_tabs_focused = true; + view.handle_orchestration_tab_change(ctx); + }); + + assert_eq!( + app.read_model(&fixture.sessions, |sessions, _| { + sessions.focused_session_id() + }), + Some(foreground_id) + ); + assert!(app + .read(|ctx| { ctx.check_view_or_child_focused(fixture.window_id, &foreground.id()) })); + assert!(!background.read(&app, |view, _| view.orchestration_tabs_focused)); + }); +} diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md index f5dc441fc88..3bf83c9ff58 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/TECH.md +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -35,56 +35,53 @@ Re-export `descendant_conversation_ids_in_pill_order` through `app/src/tui_expor Filtering occurs after canonical ordering so the relative order of navigable descendants remains identical to the GUI. The model does not use `child_session_by_conversation` for rendering or navigation; that map remains narrowly scoped to failed-child cleanup. -Add `TuiSessions::session_id_for_surface(EntityId)` and a narrow session-view lookup so the model can compose `BlocklistAIHistoryModel::terminal_surface_id_for_conversation` with retained TUI ownership. This makes restored conversations self-correcting without another conversation/session index. +Add `TuiSessions::session_id_for_conversation` so the registry composes `BlocklistAIHistoryModel::terminal_surface_id_for_conversation` with retained TUI ownership. This makes restored conversations self-correcting without another conversation/session index. ### 2. Orchestration-owned tab state and actions -Extend `TuiOrchestrationModel` from launch coordination to the owner of semantic orchestration tab state. Add a per-orchestrator entry containing: +`TuiOrchestrationModel` owns TUI-specific orchestration runtime state: child launch materialization and cleanup, semantic tab snapshots, session navigation, and the one page state needed by the single visible tab bar: +- The orchestration root whose page is retained. - The child page-anchor conversation ID. - Whether the user explicitly paged away from the active tab. -Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn-order-derived identity indices, page anchor, and whether the component should reveal an off-page selection. The session view maps identity indices and semantic tab states to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. +Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn indices, page anchor, and whether the component should reveal an off-page selection. The session view assigns theme-specific identities from spawn order and maps semantic tab state to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. Model operations: -- `select_conversation` resolves the target's retained session, calls `TuiSessions::focus_session`, and clears explicit paging without replacing the current page anchor. The component reveals the deterministic page containing an off-page selection. -- `select_first_child`/`select_last_child` exclude the orchestrator. +- `focus_conversation_session` resolves the target's retained session, captures the snapshot's current fallback anchor when initializing page state for a tree, calls `TuiSessions::focus_session`, and clears explicit paging without replacing the current page anchor. The component reveals the deterministic page containing an off-page selection. - `set_explicit_page` records the page anchor emitted by the tab bar and marks it explicit. It never calls `focus_session`. - A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. -- Removed roots prune their per-tree state; removed children clamp invalid anchors through fresh snapshot resolution. +- Removed roots clear the active page state; removed children clamp invalid anchors through fresh snapshot resolution. -Subscribe to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, pin order, or active selection, and to all `TuiSessionsEvent` variants. Emit a narrow `TabBarChanged` event so every retained session view in the affected tree redraws. These subscriptions also make child materialization/removal visible without polling. +Subscribe exhaustively to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, or pin order, and to session addition/removal. Pin mutations emit `UpdatedConversationMetadata`, so ordering and invalidation share the same history boundary. Emit a narrow `Changed` event so retained session views redraw without polling. ### 3. Session switching and focus handoff The model owns tab selection and page state, but actual responder focus remains view-owned because only a `ViewContext` can focus a view. Add orchestration-tab actions and a keymap-context flag to `TuiTerminalSessionView`: -- `FocusInput` +- `FocusDefaultInteractionTarget` - `SelectPrevious` / `SelectNext` - `SelectFirstChild` / `SelectLastChild` -- `SelectConversation(AIConversationId)` -- `SetPage(AIConversationId)` Register `Left`, `Right`, `Tab`, `Shift+Tab`, `Shift+Left`, `Shift+Right`, and `Shift+Down` only under the tab-focused context. Keep the existing session-level `Ctrl+C` binding unchanged; the focused footer omits kill copy in this PR. -The input's `FocusAboveRequested` event directly enters tab focus. `SelectPrevious` and `SelectNext` ask the tab-bar component for a target from its private settled layout; the session view then delegates that semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. +The input's `MoveFocusUp` event directly enters tab focus. `SelectPrevious` and `SelectNext` ask `TuiTabBarView` for a target from its semantic tab order; first/last child navigation resolves through the view's secondary order. The session view delegates semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. Track whether the session view itself currently owns tab-bar focus. A switch performs two coordinated operations: 1. Ask `TuiOrchestrationModel` to select/focus the target retained session. 2. Focus the target `TuiTerminalSessionView` and set its tab-focused mode for keyboard switches or already-focused mouse switches; otherwise invoke a target-session helper that applies existing precedence: alternate screen, active blocker, then input. - -This closes the current `TuiSessionsEvent::FocusChanged` projection/focus gap for tab-driven switches without letting a background session steal focus. Add an explicit test that the projected root child and responder chain move together. +The source session clears its tab-focused mode during cross-session navigation. Process and blocker ownership also clear tab focus, and model-driven refreshes invoke focus APIs only for the focused session. ### 4. Input boundary handoff -Add a generic `FocusAboveRequested` event/availability flag to `TuiInputView`; do not import orchestration types into the input module. +Add a generic `MoveFocusUp` event and owner-supplied live availability predicate to `TuiInputView`; do not import orchestration types into the input module or cache availability state. When handling `SelectUp`, the input requests focus above only if: -- Its owner marked an above-target available. +- Its owner's live predicate reports an above-target available. - The selection is empty. - The cursor's display point is display row zero. -Use the editor's char-cell render state and `display_lattice(...).offset_to_display_point(...)`, the same projection used by `TuiEditorElement`, rather than counting newlines or duplicating soft-wrap math. If any condition is false, call the existing `select_up` path unchanged. `TuiTerminalSessionView` updates availability from the orchestration snapshot and maps `FocusAboveRequested` to `FocusTabs`. +Use the editor's char-cell render state and `display_lattice(...).offset_to_display_point(...)`, the same projection used by `TuiEditorElement`, rather than counting newlines or duplicating soft-wrap math. If any condition is false, call the existing `select_up` path unchanged. The owner predicate reads the selected conversation and `TuiOrchestrationModel` at action time, so render visibility and focus handoff cannot diverge. ### 5. Session rendering and owner callbacks -Render the generalized bar from `TuiTerminalSessionView`, matching the architecture that each rendered terminal session asks `TuiOrchestrationModel` for its current tree. +Own `TuiTabBarView` as a retained child of `TuiTerminalSessionView`. Each session asks `TuiOrchestrationModel` for its current tree, maps the snapshot into `TuiTabBarConfig`, and synchronizes the child view before embedding it with `TuiChildView`. Restructure the normal render tree into: - A full-width optional orchestration tab row. @@ -94,25 +91,25 @@ The alt-screen early return remains unchanged and therefore owns the complete pa When the bar is unfocused, render the normal footer with the conditional `Shift + ↑ sub-agents` hint. When focused, keep the input visible but blurred and replace the normal footer with the PRODUCT (18) navigation footer. -Tab and overflow callbacks dispatch typed actions back to the owning `TuiTerminalSessionView`: +Tab and overflow interactions emit semantic child-view events subscribed by `TuiTerminalSessionView`: - Tab clicks select immediately. The owner preserves tab focus only if it was already active; otherwise it focuses the target session's normal interaction target. - Overflow clicks carry the page anchor computed by the component to the model's `set_explicit_page` operation and never call a focus API, so input focus remains unchanged. ### 6. Styling and identities -Add semantic orchestration-tab style recipes to `TuiUiBuilder` rather than embedding raw colors in the element. Supply those styles to `TuiTabBar`: +Add semantic orchestration-tab style recipes to `TuiUiBuilder` rather than embedding raw colors in the view. Supply those styles through `TuiTabBarConfig`: - Magenta-tinted bar background. - Focused selected tab background and contrasting text. - Unfocused active-tab emphasis. - Muted divider, overflow, and non-selected labels. -Reuse `agent_identity_palette` and `assign_agent_identity_indices`. Assign identities from stable spawn order, then project them into dynamic pill order so a status change never changes a child's glyph or color. The generic tab element receives only the resulting optional leading glyph/style. +Reuse `agent_identity_palette` and `assign_agent_identity_indices`. Assign identities from stable spawn order, then project them into dynamic pill order so a status change never changes a child's glyph or color. Each tab-view entry receives only the resulting optional leading glyph/style. ## Testing and validation ### Integration tests -Extend `orchestration_model_tests.rs`, `sessions_tests.rs`, `terminal_session_view_tests.rs`, and `input/view_tests.rs`: +Extend `orchestration_model_tests.rs`, `session_registry_tests.rs`, `terminal_session_view_tests.rs`, and `input/view_tests.rs`: - Exact reuse of GUI pin/status/error/active/done-recency/spawn ordering after filtering non-navigable sessions — PRODUCT (4, 9-12). - The same root and tabs from orchestrator and child sessions; newly materialized and removed sessions — PRODUCT (1-5, 47-49). -- Shared per-root page state, explicit-page persistence, active reveal, and first/last-visible keyboard origin — PRODUCT (26-27, 39-42). +- Shared active-tree page state, explicit-page persistence, active reveal, and first/last-visible keyboard origin — PRODUCT (26-27, 39-42). - Wrapped-row and selection-aware `Shift+Up` behavior; `Shift+Down` restoration — PRODUCT (13-18). - Wrapped adjacent navigation, child-only first/last jumps, and tab focus preserved across full-session projection — PRODUCT (19-25). - Focused and unfocused mouse switches, no-op active clicks, overflow clicks that neither select nor move focus, and blocker precedence — PRODUCT (28-32, 38, 45-46). @@ -139,7 +136,7 @@ The reusable component lands in the lower `harry/code-1822-tui-tab-bar-component ## Risks and mitigations - **Dynamic order versus explicit paging:** store page anchors by conversation ID, not index, and distinguish automatic reveal from explicit paging. Re-resolve every anchor against the fresh canonical order. -- **Layout/event drift:** the tab bar resolves navigation against the same private settled range and ordered snapshot it rendered, then emits a stable tab ID. The orchestration model resolves that ID against fresh state and no-ops unavailable targets. +- **Layout/event drift:** each size-switch row owns the tabs and overflow callbacks it renders, while keyboard navigation uses the same semantic config held by the retained tab view. The orchestration model resolves emitted stable IDs against fresh state and no-ops unavailable targets. - **Focus without projection:** make every tab selection update `TuiSessions` and the target view's focus mode in one owner action; test both root projection and responder focus. - **Stale child-session cache:** use history's authoritative conversation→surface mapping plus `TuiSessions` lookup for navigation. Keep the existing child map only for launch cleanup. - **Identity changes during reorder:** assign identities in stable spawn order, independent of canonical display order. From 73c96c2b09c54db5eb1d58a20d5ddcf1f9f5bb73 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 12:10:10 -0400 Subject: [PATCH 05/12] update apis --- crates/warp_tui/src/terminal_session_view.rs | 16 ++++++++++++---- crates/warp_tui/src/tui_builder.rs | 2 +- .../PRODUCT.md | 7 ++----- .../code-1822-tui-orchestration-tab-bar/TECH.md | 4 ++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 44e4c962728..30845a49f13 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -40,7 +40,7 @@ use warp::tui_export::{ use warp_core::features::FeatureFlag; use warp_core::settings::Setting; use warp_editor::model::CoreEditorModel; -use warp_errors::report_error; +use warp_errors::{report_error, ReportErrorLogMode}; use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -939,7 +939,7 @@ impl TuiTerminalSessionView { .with_keyboard_enhancement_supported(keyboard_enhancement_supported) }); let orchestration_tab_bar = - ctx.add_typed_action_tui_view(|_| TuiTabBarView::new(TuiTabBarConfig::new(Vec::new()))); + ctx.add_typed_action_tui_view(|_| TuiTabBarView::empty()); ctx.subscribe_to_view(&transcript, |view, _, event, ctx| match event { TuiTranscriptViewEvent::SelectionStarted => { @@ -1040,7 +1040,7 @@ impl TuiTerminalSessionView { ); ctx.subscribe_to_model(&conversation_selection, |view, _, _, ctx| { view.refresh_exit_summary(ctx); - ctx.notify(); + view.handle_orchestration_tab_change(ctx); }); // The zero state's "What's new" section: fetch the changelog once at @@ -1431,8 +1431,16 @@ impl TuiTerminalSessionView { ctx: &mut ViewContext, ) { let config = self.orchestration_tab_bar_config(snapshot, builder); - self.orchestration_tab_bar + let result = self + .orchestration_tab_bar .update(ctx, |tab_bar, ctx| tab_bar.set_config(config, ctx)); + if let Err(error) = result { + report_error!( + anyhow::Error::new(error) + .context("Failed to update orchestration tab bar configuration"), + ReportErrorLogMode::OncePerRun + ); + } } /// Footer shown while orchestration tabs own keyboard focus. diff --git a/crates/warp_tui/src/tui_builder.rs b/crates/warp_tui/src/tui_builder.rs index 1e1d0735882..f11a4e03654 100644 --- a/crates/warp_tui/src/tui_builder.rs +++ b/crates/warp_tui/src/tui_builder.rs @@ -264,7 +264,7 @@ impl TuiUiBuilder { let selected_foreground = cell_color(self.warp_theme.font_color(selected_fill.into_solid())); TuiTabBarStyles { - bar: self.primary_text_style().bg(background), + background: Some(background), leading: self.orchestration_tab_bar_label_style(), chrome: self.orchestration_tab_bar_chrome_style(), tab: self.muted_text_style().bg(background), diff --git a/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md index bbaba786a25..b4d599974eb 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md +++ b/specs/code-1822-tui-orchestration-tab-bar/PRODUCT.md @@ -66,10 +66,7 @@ The Warp TUI shows a tab bar for an orchestration tree so users can see and swit 23. `Shift+Right` switches to the last child-agent conversation. 24. The orchestrator is excluded from the `Shift+Left` and `Shift+Right` destinations. 25. A keyboard-driven session switch keeps the tab bar focused in the target session so repeated navigation continues without another `Shift+Up`. -26. If an explicitly selected overflow page does not contain the active tab: - - `Right` switches to the first visible child tab on that page. - - `Left` switches to the last visible child tab on that page. - - `Tab` behaves like `Right`, and `Shift+Tab` behaves like `Left`. +26. Keyboard navigation always starts from the active conversation in the complete canonical order, even when an explicitly selected overflow page does not contain the active tab. `Tab` behaves like `Right`, and `Shift+Tab` behaves like `Left`. 27. Once keyboard navigation switches conversations, the newly active tab becomes the navigation origin and is kept visible. ### Mouse navigation @@ -91,7 +88,7 @@ The Warp TUI shows a tab bar for an orchestration tree so users can see and swit - It does not change keyboard focus. In particular, clicking an arrow while the input is focused leaves the input focused. 39. The selected page is shared across all sessions in the same orchestration tree. Switching conversations does not reset it. 40. The active tab is automatically revealed after session selection or dynamic reordering unless the user explicitly paged away with an overflow arrow. Selecting another tab already on the visible page does not shift or re-anchor that page. -41. After an explicit overflow click, the chosen page remains visible even if the active tab is on another page. The next keyboard selection follows (26). +41. After an explicit overflow click, the chosen page remains visible even if the active tab is on another page. The next keyboard selection follows the active conversation's canonical neighbor per (26), not the visible page edge. 42. Selecting a tab clears the explicit paged-away state and keeps the selected tab visible. 43. Terminal resizing recomputes which complete or truncated tabs fit, preserves a valid page when possible, and never clips an overflow arrow or writes outside the row. 44. At very narrow widths, the bar prioritizes the `Agents:` label, orchestrator tab, divider, and an applicable overflow control before child labels. It remains a single row. diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md index 3bf83c9ff58..91ef77d7eff 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/TECH.md +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -63,7 +63,7 @@ Add orchestration-tab actions and a keymap-context flag to `TuiTerminalSessionVi Register `Left`, `Right`, `Tab`, `Shift+Tab`, `Shift+Left`, `Shift+Right`, and `Shift+Down` only under the tab-focused context. Keep the existing session-level `Ctrl+C` binding unchanged; the focused footer omits kill copy in this PR. -The input's `MoveFocusUp` event directly enters tab focus. `SelectPrevious` and `SelectNext` ask `TuiTabBarView` for a target from its semantic tab order; first/last child navigation resolves through the view's secondary order. The session view delegates semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. +The input's `MoveFocusUp` event directly enters tab focus. `SelectPrevious` and `SelectNext` ask `TuiTabBarView` for a target from its semantic tab order; navigation remains relative to the active conversation even when the user has explicitly paged the active tab out of view. First/last child navigation resolves through the view's secondary order. The session view delegates semantic selection to `TuiOrchestrationModel`. The session and model never read a visible range. Track whether the session view itself currently owns tab-bar focus. A switch performs two coordinated operations: 1. Ask `TuiOrchestrationModel` to select/focus the target retained session. @@ -109,7 +109,7 @@ Reuse `agent_identity_palette` and `assign_agent_identity_indices`. Assign ident Extend `orchestration_model_tests.rs`, `session_registry_tests.rs`, `terminal_session_view_tests.rs`, and `input/view_tests.rs`: - Exact reuse of GUI pin/status/error/active/done-recency/spawn ordering after filtering non-navigable sessions — PRODUCT (4, 9-12). - The same root and tabs from orchestrator and child sessions; newly materialized and removed sessions — PRODUCT (1-5, 47-49). -- Shared active-tree page state, explicit-page persistence, active reveal, and first/last-visible keyboard origin — PRODUCT (26-27, 39-42). +- Shared active-tree page state, explicit-page persistence, active reveal, and semantic keyboard navigation while the active tab is off-page — PRODUCT (26-27, 39-42). - Wrapped-row and selection-aware `Shift+Up` behavior; `Shift+Down` restoration — PRODUCT (13-18). - Wrapped adjacent navigation, child-only first/last jumps, and tab focus preserved across full-session projection — PRODUCT (19-25). - Focused and unfocused mouse switches, no-op active clicks, overflow clicks that neither select nor move focus, and blocker precedence — PRODUCT (28-32, 38, 45-46). From 5106342f470890574b75b78ca9a98ef5d3627e19 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 12:29:10 -0400 Subject: [PATCH 06/12] fmt --- crates/warp_tui/src/terminal_session_view.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 30845a49f13..f7ffa2d3e99 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -938,8 +938,7 @@ impl TuiTerminalSessionView { ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) }); - let orchestration_tab_bar = - ctx.add_typed_action_tui_view(|_| TuiTabBarView::empty()); + let orchestration_tab_bar = ctx.add_typed_action_tui_view(|_| TuiTabBarView::empty()); ctx.subscribe_to_view(&transcript, |view, _, event, ctx| match event { TuiTranscriptViewEvent::SelectionStarted => { From 1006f904e19ba9bf4c2e5f35a41dfd725590dd5d Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 14:17:22 -0400 Subject: [PATCH 07/12] clean up weird event structure --- crates/warp_tui/src/input/view.rs | 1 - crates/warp_tui/src/orchestration_model.rs | 26 +--- .../warp_tui/src/orchestration_model_tests.rs | 1 - crates/warp_tui/src/session_registry.rs | 1 - crates/warp_tui/src/terminal_session_view.rs | 128 ++++++++---------- .../src/terminal_session_view_tests.rs | 12 +- .../TECH.md | 2 +- 7 files changed, 72 insertions(+), 99 deletions(-) diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index d48cf14ff27..61a52cfaaea 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -483,7 +483,6 @@ impl TypedActionView for TuiInputView { TuiInputAction::EditorCommand(command) => { if matches!(*command, TuiEditorCommand::SelectUp) && self.can_focus_above(ctx) { ctx.emit(TuiInputViewEvent::MoveFocusUp); - ctx.notify(); return; } // Only open the conversation list from normal agent input; in diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index 570cd805dd7..d20612da77b 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -27,9 +27,9 @@ use warp::tui_export::{ use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; -use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::terminal_session_view::TuiTerminalSessionView; + /// One navigable child tab in an orchestration snapshot. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct TuiOrchestrationTab { @@ -48,7 +48,6 @@ pub(crate) struct TuiOrchestrationSnapshot { pub(crate) reveal_selected: bool, } - /// The TUI's orchestration singleton. See the module docs. pub(crate) struct TuiOrchestrationModel { /// Session hosting each live child conversation. The session dimension @@ -72,8 +71,8 @@ pub(crate) enum TuiOrchestrationEvent { conversation_name: String, }, RemoveChildSession(TuiSessionId), - TabBarChanged, } + pub(crate) struct MaterializedLocalOzChildSession { pub(crate) parent_session_id: TuiSessionId, pub(crate) session_id: TuiSessionId, @@ -204,14 +203,6 @@ impl TuiOrchestrationModel { }) } - pub(crate) fn has_tabs( - &self, - selected_conversation_id: AIConversationId, - ctx: &AppContext, - ) -> bool { - self.snapshot(selected_conversation_id, ctx).is_some() - } - /// Stores an explicitly selected secondary page without switching sessions. pub(crate) fn set_explicit_page( &mut self, @@ -222,7 +213,7 @@ impl TuiOrchestrationModel { self.page_root_conversation_id = Some(root_conversation_id); self.page_anchor = Some(page_anchor); self.explicitly_paged = true; - self.emit_changed(ctx); + ctx.notify(); } /// Focuses the retained session for a conversation and resumes automatic reveal. @@ -249,7 +240,7 @@ impl TuiOrchestrationModel { TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { sessions.focus_session(session_id, ctx); }); - self.emit_changed(ctx); + ctx.notify(); Some(session_id) } @@ -261,15 +252,9 @@ impl TuiOrchestrationModel { self.page_anchor = None; self.explicitly_paged = false; } - self.emit_changed(ctx); - } - - fn emit_changed(&self, ctx: &mut ModelContext) { - ctx.emit(TuiOrchestrationEvent::TabBarChanged); ctx.notify(); } - /// Routes a `CreateAgent` request the same two ways as the GUI's /// per-mode dispatch, with unsupported modes resolving as clean per-child /// failures. @@ -404,7 +389,7 @@ impl TuiOrchestrationModel { self.child_session_by_conversation .insert(conversation_id, session_id); - self.emit_changed(ctx); + ctx.notify(); } /// Tears down the background session of a child that failed at the @@ -479,7 +464,6 @@ impl TuiOrchestrationModel { } self.child_session_by_conversation .retain(|_, child_session_id| *child_session_id != session_id); - ctx.emit(TuiOrchestrationEvent::TabBarChanged); ctx.notify(); } } diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 9dc9c6005e0..87eaa191fd2 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -42,7 +42,6 @@ fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { } } - fn add_child_session( app: &mut App, fixture: &OrchestrationFixture, diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 65547cc86a2..0a8c82883ad 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -238,7 +238,6 @@ impl TuiSessions { sessions.remove_session(*session_id, ctx); }); } - TuiOrchestrationEvent::TabBarChanged => {} }); } diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index f7ffa2d3e99..7d446a67143 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -40,7 +40,7 @@ use warp::tui_export::{ use warp_core::features::FeatureFlag; use warp_core::settings::Setting; use warp_editor::model::CoreEditorModel; -use warp_errors::{report_error, ReportErrorLogMode}; +use warp_errors::report_error; use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::SingletonEntity; use warpui_core::elements::tui::{ @@ -72,9 +72,7 @@ use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; use crate::orchestration_block::TuiOrchestrationBlock; -use crate::orchestration_model::{ - TuiOrchestrationEvent, TuiOrchestrationModel, TuiOrchestrationSnapshot, -}; +use crate::orchestration_model::{TuiOrchestrationModel, TuiOrchestrationSnapshot}; use crate::resume::TuiExitSummaryHandle; use crate::session_registry::TuiSessions; use crate::skills_menu::{TuiSkillMenuEvent, TuiSkillMenuModel}; @@ -369,7 +367,8 @@ pub(crate) fn init(app: &mut AppContext) { // Tab navigation is user-remappable, unlike the reserved session-control // bindings above, so the two groups use different registration APIs. - let tab_context = id!("TuiTerminalSessionView") & id!(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); + let tab_context = + id!(TuiTerminalSessionView::ui_name()) & id!(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); app.register_editable_bindings([ EditableBinding::new( "tui:orchestration_tabs:previous", @@ -463,7 +462,12 @@ impl TuiTerminalSessionView { fn focus_current_owner_if_active(&mut self, ctx: &mut ViewContext) { if self.is_focused_session(ctx) { + let tabs_were_focused = self.orchestration_tabs_focused; self.focus_current_owner(ctx); + if tabs_were_focused && !self.orchestration_tabs_focused { + self.refresh_orchestration_tab_bar(ctx); + ctx.notify(); + } } } @@ -618,7 +622,7 @@ impl TuiTerminalSessionView { fn hand_back_terminal_use_control(&mut self, ctx: &mut ViewContext) { if self.active_user_controlled_target(ctx).is_none() { return; - }; + } self.cli_subagent_controller.update(ctx, |controller, ctx| { controller.handoff_active_command_control_to_agent(ctx); }); @@ -931,7 +935,9 @@ impl TuiTerminalSessionView { .as_ref(ctx) .selected_conversation_id(ctx) .is_some_and(|conversation_id| { - TuiOrchestrationModel::as_ref(ctx).has_tabs(conversation_id, ctx) + TuiOrchestrationModel::as_ref(ctx) + .snapshot(conversation_id, ctx) + .is_some() }) }, ctx, @@ -991,12 +997,8 @@ impl TuiTerminalSessionView { }); ctx.subscribe_to_view(&orchestration_tab_bar, |view, _, event, ctx| match event { TuiTabBarEvent::SelectTab(conversation_id) => { - let Ok(conversation_id) = AIConversationId::try_from(conversation_id.clone()) - else { - return; - }; - view.switch_to_orchestration_conversation( - conversation_id, + view.switch_to_orchestration_tab( + Some(conversation_id.clone()), view.orchestration_tabs_focused, ctx, ); @@ -1014,16 +1016,9 @@ impl TuiTerminalSessionView { } }); if ctx.has_singleton_model::() { - ctx.subscribe_to_model( - &TuiOrchestrationModel::handle(ctx), - |view, _, event, ctx| match event { - TuiOrchestrationEvent::TabBarChanged => { - view.handle_orchestration_tab_change(ctx); - } - TuiOrchestrationEvent::CreateLocalOzChildSession { .. } - | TuiOrchestrationEvent::RemoveChildSession(_) => {} - }, - ); + ctx.observe(&TuiOrchestrationModel::handle(ctx), |view, _, ctx| { + view.handle_orchestration_tab_change(ctx) + }); } // The input box border color and the footer's shell-mode hint depend // on the input mode. @@ -1278,14 +1273,6 @@ impl TuiTerminalSessionView { self.conversation_selection.update(ctx, |selection, ctx| { selection.select_existing_conversation(conversation_id, AgentViewEntryOrigin::Tui, ctx); }); - ctx.notify(); - } - - /// The conversation selected for prompts and tab highlighting. - fn selected_conversation_id(&self, ctx: &AppContext) -> Option { - self.conversation_selection - .as_ref(ctx) - .selected_conversation_id(ctx) } /// Resolves the orchestration tabs for this session's selected conversation. @@ -1295,15 +1282,18 @@ impl TuiTerminalSessionView { { return None; } - let selected_conversation_id = self.selected_conversation_id(ctx)?; + let selected_conversation_id = self + .conversation_selection + .as_ref(ctx) + .selected_conversation_id(ctx)?; TuiOrchestrationModel::as_ref(ctx).snapshot(selected_conversation_id, ctx) } /// Drops tab focus when this session no longer has an orchestration bar. fn handle_orchestration_tab_change(&mut self, ctx: &mut ViewContext) { - let builder = TuiUiBuilder::from_app(ctx); let snapshot = self.orchestration_tab_snapshot(ctx); if let Some(snapshot) = snapshot.as_ref() { + let builder = TuiUiBuilder::from_app(ctx); self.sync_orchestration_tab_bar(snapshot, &builder, ctx); } if snapshot.is_none() && self.orchestration_tabs_focused { @@ -1326,17 +1316,29 @@ impl TuiTerminalSessionView { /// Applies tab-focus mode, synchronizes presentation, and resolves the focus owner. fn set_orchestration_tab_focus(&mut self, focused: bool, ctx: &mut ViewContext) { self.orchestration_tabs_focused = focused; - let builder = TuiUiBuilder::from_app(ctx); - if let Some(snapshot) = self.orchestration_tab_snapshot(ctx) { - self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); - } self.focus_current_owner(ctx); + self.refresh_orchestration_tab_bar(ctx); ctx.notify(); } - /// Leaves the tab bar and focuses the session's normal interaction owner. - fn focus_default_interaction_target(&mut self, ctx: &mut ViewContext) { - self.set_orchestration_tab_focus(false, ctx); + fn refresh_orchestration_tab_bar(&self, ctx: &mut ViewContext) { + let Some(snapshot) = self.orchestration_tab_snapshot(ctx) else { + return; + }; + let builder = TuiUiBuilder::from_app(ctx); + self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); + } + + fn switch_to_orchestration_tab( + &mut self, + key: Option, + keep_tab_focus: bool, + ctx: &mut ViewContext, + ) { + let Some(conversation_id) = key.and_then(|key| AIConversationId::try_from(key).ok()) else { + return; + }; + self.switch_to_orchestration_conversation(conversation_id, keep_tab_focus, ctx); } /// Switches to the retained session that owns an orchestration conversation. @@ -1366,7 +1368,6 @@ impl TuiTerminalSessionView { }; target_view.update(ctx, |target, target_ctx| { target.set_orchestration_tab_focus(keep_tab_focus, target_ctx); - target.handle_orchestration_tab_change(target_ctx); }); } @@ -1437,7 +1438,7 @@ impl TuiTerminalSessionView { report_error!( anyhow::Error::new(error) .context("Failed to update orchestration tab bar configuration"), - ReportErrorLogMode::OncePerRun + warp_errors::ReportErrorLogMode::OncePerRun ); } } @@ -2712,8 +2713,7 @@ impl TuiView for TuiTerminalSessionView { }) .flatten(); let builder = TuiUiBuilder::from_app(ctx); - let orchestration_tabs = self.orchestration_tab_snapshot(ctx); - let orchestration_tabs_available = orchestration_tabs.is_some(); + let orchestration_tabs_available = self.orchestration_tab_snapshot(ctx).is_some(); // Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the // fixed binding registered in [`Self::init`], so no element-level key @@ -2846,7 +2846,7 @@ impl TuiView for TuiTerminalSessionView { .with_padding_top(2) .with_padding_bottom(1) .finish(); - if orchestration_tabs.is_some() { + if orchestration_tabs_available { TuiFlex::column() .child(TuiChildView::new(&self.orchestration_tab_bar).finish()) .flex_child(session) @@ -2871,47 +2871,35 @@ impl TypedActionView for TuiTerminalSessionView { } TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), TuiTerminalSessionAction::FocusDefaultInteractionTarget => { - self.focus_default_interaction_target(ctx) + self.set_orchestration_tab_focus(false, ctx) } TuiTerminalSessionAction::SelectPreviousOrchestrationTab => { - if let Some(conversation_id) = self + let key = self .orchestration_tab_bar .as_ref(ctx) - .navigation_target(TuiTabBarNavigationDirection::Previous) - .and_then(|key| AIConversationId::try_from(key).ok()) - { - self.switch_to_orchestration_conversation(conversation_id, true, ctx); - } + .navigation_target(TuiTabBarNavigationDirection::Previous); + self.switch_to_orchestration_tab(key, true, ctx); } TuiTerminalSessionAction::SelectNextOrchestrationTab => { - if let Some(conversation_id) = self + let key = self .orchestration_tab_bar .as_ref(ctx) - .navigation_target(TuiTabBarNavigationDirection::Next) - .and_then(|key| AIConversationId::try_from(key).ok()) - { - self.switch_to_orchestration_conversation(conversation_id, true, ctx); - } + .navigation_target(TuiTabBarNavigationDirection::Next); + self.switch_to_orchestration_tab(key, true, ctx); } TuiTerminalSessionAction::SelectFirstOrchestrationChild => { - if let Some(conversation_id) = self + let key = self .orchestration_tab_bar .as_ref(ctx) - .secondary_edge_target(TuiTabBarSecondaryEdge::First) - .and_then(|key| AIConversationId::try_from(key).ok()) - { - self.switch_to_orchestration_conversation(conversation_id, true, ctx); - } + .secondary_edge_target(TuiTabBarSecondaryEdge::First); + self.switch_to_orchestration_tab(key, true, ctx); } TuiTerminalSessionAction::SelectLastOrchestrationChild => { - if let Some(conversation_id) = self + let key = self .orchestration_tab_bar .as_ref(ctx) - .secondary_edge_target(TuiTabBarSecondaryEdge::Last) - .and_then(|key| AIConversationId::try_from(key).ok()) - { - self.switch_to_orchestration_conversation(conversation_id, true, ctx); - } + .secondary_edge_target(TuiTabBarSecondaryEdge::Last); + self.switch_to_orchestration_tab(key, true, ctx); } TuiTerminalSessionAction::ForwardUserPtyBytes(bytes) => { // Raw passthrough: the bytes are already the app's escape diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index e606fcf9af1..47253050bd6 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -4,7 +4,7 @@ use warp::tui_export::{ PtyIntentEvent, SizeInfo, SizeUpdate, }; use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, EntityIdMap, ModelHandle, ReadModel, UpdateModel, ViewHandle}; +use warpui::{AddWindowOptions, EntityIdMap, ModelHandle, ReadModel, SingletonEntity, ViewHandle}; use warpui_core::elements::tui::{ TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, @@ -274,15 +274,19 @@ fn alternate_screen_clears_orchestration_tab_focus_and_bindings() { } #[test] -fn background_tab_refresh_never_moves_responder_focus() { +fn background_model_notification_never_moves_responder_focus() { App::test((), |mut app| async move { let fixture = focus_test_fixture(&mut app); let (foreground, foreground_id) = add_focus_test_session(&mut app, &fixture, true); let (background, _) = add_focus_test_session(&mut app, &fixture, false); - background.update(&mut app, |view, ctx| { + background.update(&mut app, |view, _| { view.orchestration_tabs_focused = true; - view.handle_orchestration_tab_change(ctx); + }); + app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |_, ctx| { + ctx.notify(); + }); }); assert_eq!( diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md index 91ef77d7eff..a6a70673347 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/TECH.md +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -51,7 +51,7 @@ Model operations: - A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. - Removed roots clear the active page state; removed children clamp invalid anchors through fresh snapshot resolution. -Subscribe exhaustively to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, or pin order, and to session addition/removal. Pin mutations emit `UpdatedConversationMetadata`, so ordering and invalidation share the same history boundary. Emit a narrow `Changed` event so retained session views redraw without polling. +Subscribe exhaustively to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, or pin order, and react to session removal through the registry lifecycle. Pin mutations emit `UpdatedConversationMetadata`, so ordering and invalidation share the same history boundary. Notify model observers so retained session views redraw without adding a synthetic event variant or polling. ### 3. Session switching and focus handoff The model owns tab selection and page state, but actual responder focus remains view-owned because only a `ViewContext` can focus a view. From 2053c108b2069ca8dee807caf37301849950cca9 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 14:42:29 -0400 Subject: [PATCH 08/12] fix tab bar ordering issues --- crates/warp_tui/src/orchestration_model.rs | 12 ++---------- crates/warp_tui/src/orchestration_model_tests.rs | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index d20612da77b..b77129ea04f 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -189,7 +189,7 @@ impl TuiOrchestrationModel { } let page_state_applies = self.page_root_conversation_id == Some(root_conversation_id); - let page_anchor = page_state_applies + let page_anchor = (page_state_applies && self.explicitly_paged) .then_some(self.page_anchor) .flatten() .filter(|anchor| tabs.iter().any(|tab| tab.conversation_id == *anchor)) @@ -222,20 +222,12 @@ impl TuiOrchestrationModel { conversation_id: AIConversationId, ctx: &mut ModelContext, ) -> Option { - let current_page_anchor = self - .snapshot(conversation_id, ctx) - .and_then(|snapshot| snapshot.page_anchor); let history = BlocklistAIHistoryModel::as_ref(ctx); let root_conversation_id = orchestration_root_conversation_id(history, conversation_id)?; let session_id = TuiSessions::as_ref(ctx).session_id_for_conversation(history, conversation_id)?; - if self.page_root_conversation_id != Some(root_conversation_id) { - // Capture the page before focus changes any ordering inputs. The - // first switch in a tree must not replace the visible page with a - // new fallback anchor. - self.page_anchor = current_page_anchor; - } self.page_root_conversation_id = Some(root_conversation_id); + self.page_anchor = None; self.explicitly_paged = false; TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { sessions.focus_session(session_id, ctx); diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 87eaa191fd2..9a1c23f112b 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -278,7 +278,7 @@ fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { TuiSessions::as_ref(ctx).focused_session_id(), Some(first_session_id) ); - assert_eq!(snapshot.page_anchor, Some(second_child_id)); + assert_eq!(snapshot.page_anchor, Some(first_child_id)); assert!(snapshot.reveal_selected); }); }); From f5d0891fe1d935878eb6026456bfaa7ffed66f75 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 15:35:33 -0400 Subject: [PATCH 09/12] make tui pagination more efficient --- .../agent_view/orchestration_pill_bar.rs | 8 +- .../ai/blocklist/orchestration_topology.rs | 28 ++++- app/src/tui_export.rs | 4 +- crates/warp_tui/src/orchestration_model.rs | 46 +++---- .../warp_tui/src/orchestration_model_tests.rs | 8 ++ crates/warp_tui/src/session_registry.rs | 43 ++++++- crates/warp_tui/src/tab_bar.rs | 11 +- crates/warp_tui/src/terminal_session_view.rs | 114 +++++++++--------- .../src/terminal_session_view_tests.rs | 25 +++- .../TECH.md | 19 +-- 10 files changed, 187 insertions(+), 119 deletions(-) diff --git a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs index 4ba2d05f40a..61a700eb462 100644 --- a/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs +++ b/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs @@ -49,8 +49,8 @@ use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::{ }; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::orchestration_topology::{ - aggregated_orchestrator_status, descendant_conversation_ids_in_pill_order, - descendant_conversation_ids_in_spawn_order, + aggregated_orchestrator_status, descendant_conversation_ids_in_spawn_order, + descendant_conversations_in_pill_order, }; use crate::ai::blocklist::telemetry::{ BlocklistOrchestrationTelemetryEvent, PillBarActionKind, PillBarInteractionEvent, @@ -613,9 +613,9 @@ impl OrchestrationPillBar { // Use the shared canonical pill ordering so the visible row and // keyboard navigation cannot drift. - let children: Vec<_> = descendant_conversation_ids_in_pill_order(history, orchestrator_id) + let children: Vec<_> = descendant_conversations_in_pill_order(history, orchestrator_id) .into_iter() - .filter_map(|id| history.conversation(&id)) + .filter_map(|descendant| history.conversation(&descendant.conversation_id)) .collect(); // Nothing to show if the orchestrator has no children yet. diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index 2eae3ece7a0..715d4a2996f 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -204,6 +204,13 @@ pub fn has_local_orchestrated_children( }) } +/// One descendant in canonical orchestration pill order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OrderedOrchestrationDescendant { + pub conversation_id: AIConversationId, + pub spawn_index: usize, +} + /// Returns descendants in the canonical orchestration pill order: /// 1) pinned children /// 2) unpinned children @@ -212,10 +219,10 @@ pub fn has_local_orchestrated_children( /// This is the single ordering source used by both the pill bar and keyboard /// navigation. Callers should preserve the returned order rather than sorting /// the conversations again. -pub fn descendant_conversation_ids_in_pill_order( +pub fn descendant_conversations_in_pill_order( history: &BlocklistAIHistoryModel, parent_id: AIConversationId, -) -> Vec { +) -> Vec { let mut descendants = descendant_conversation_ids_in_spawn_order(history, parent_id) .into_iter() .enumerate() @@ -246,7 +253,12 @@ pub fn descendant_conversation_ids_in_pill_order( ); descendants .into_iter() - .map(|(_, _, _, _, conversation_id)| conversation_id) + .map( + |(_, _, _, spawn_index, conversation_id)| OrderedOrchestrationDescendant { + conversation_id, + spawn_index, + }, + ) .collect() } @@ -266,12 +278,16 @@ pub fn adjacent_orchestration_child_conversation_id( let orchestration_root_id = history .resolved_parent_conversation_id_for_conversation(active_conversation) .unwrap_or(active_conversation_id); - let descendant_ids = descendant_conversation_ids_in_pill_order(history, orchestration_root_id); - if descendant_ids.is_empty() { + let descendants = descendant_conversations_in_pill_order(history, orchestration_root_id); + if descendants.is_empty() { return None; } let conversation_ids = std::iter::once(orchestration_root_id) - .chain(descendant_ids) + .chain( + descendants + .into_iter() + .map(|descendant| descendant.conversation_id), + ) .collect::>(); let active_index = conversation_ids diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 46d5864c668..7b5011843b9 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -68,10 +68,10 @@ pub use crate::ai::blocklist::orchestration_event_streamer::{ register_agent_event_consumer, unregister_agent_event_consumer, }; pub use crate::ai::blocklist::orchestration_topology::{ - descendant_conversation_ids_in_pill_order, descendant_conversation_ids_in_spawn_order, + descendant_conversation_ids_in_spawn_order, descendant_conversations_in_pill_order, orchestration_root_conversation_id, orchestrator_agent_id_for_conversation, resolve_orchestration_participant, OrchestrationParticipantKind, - ResolvedOrchestrationParticipant, + OrderedOrchestrationDescendant, ResolvedOrchestrationParticipant, }; pub use crate::ai::blocklist::view_util::format_credits; #[cfg(not(target_family = "wasm"))] diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index b77129ea04f..e8df4b5f455 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -17,12 +17,11 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use warp::tui_export::{ - apply_child_agent_model_override, descendant_conversation_ids_in_pill_order, - descendant_conversation_ids_in_spawn_order, inherit_child_agent_settings, - orchestration_root_conversation_id, prepare_local_oz_child_launch, - register_agent_event_consumer, unregister_agent_event_consumer, AIConversationId, - BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, Harness, - RenderableAIError, StartAgentExecutionMode, StartAgentRequest, + apply_child_agent_model_override, descendant_conversations_in_pill_order, + inherit_child_agent_settings, orchestration_root_conversation_id, + prepare_local_oz_child_launch, register_agent_event_consumer, unregister_agent_event_consumer, + AIConversationId, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, ConversationStatus, + Harness, RenderableAIError, StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; @@ -149,29 +148,14 @@ impl TuiOrchestrationModel { let root_conversation_id = orchestration_root_conversation_id(history, selected_conversation_id)?; let sessions = TuiSessions::as_ref(ctx); - sessions.session_id_for_conversation(history, root_conversation_id)?; + let session_ids_by_conversation = sessions.session_ids_by_conversation(history); + session_ids_by_conversation.get(&root_conversation_id)?; - let spawn_order = descendant_conversation_ids_in_spawn_order(history, root_conversation_id) + let tabs = descendant_conversations_in_pill_order(history, root_conversation_id) .into_iter() - .filter(|conversation_id| { - sessions - .session_id_for_conversation(history, *conversation_id) - .is_some() - }) - .collect::>(); - if spawn_order.is_empty() { - return None; - } - let spawn_index_by_conversation = spawn_order - .iter() - .enumerate() - .map(|(index, conversation_id)| (*conversation_id, index)) - .collect::>(); - - let tabs = descendant_conversation_ids_in_pill_order(history, root_conversation_id) - .into_iter() - .filter_map(|conversation_id| { - sessions.session_id_for_conversation(history, conversation_id)?; + .filter_map(|descendant| { + let conversation_id = descendant.conversation_id; + session_ids_by_conversation.get(&conversation_id)?; let conversation = history.conversation(&conversation_id)?; Some(TuiOrchestrationTab { conversation_id, @@ -180,7 +164,7 @@ impl TuiOrchestrationModel { .filter(|name| !name.is_empty()) .unwrap_or("Agent") .to_owned(), - spawn_index: *spawn_index_by_conversation.get(&conversation_id)?, + spawn_index: descendant.spawn_index, }) }) .collect::>(); @@ -224,15 +208,15 @@ impl TuiOrchestrationModel { ) -> Option { let history = BlocklistAIHistoryModel::as_ref(ctx); let root_conversation_id = orchestration_root_conversation_id(history, conversation_id)?; - let session_id = - TuiSessions::as_ref(ctx).session_id_for_conversation(history, conversation_id)?; + let session_id = *TuiSessions::as_ref(ctx) + .session_ids_by_conversation(history) + .get(&conversation_id)?; self.page_root_conversation_id = Some(root_conversation_id); self.page_anchor = None; self.explicitly_paged = false; TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { sessions.focus_session(session_id, ctx); }); - ctx.notify(); Some(session_id) } diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 9a1c23f112b..79da2020394 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -236,6 +236,14 @@ fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { .collect::>(), vec![first_child_id, second_child_id] ); + assert_eq!( + parent + .tabs + .iter() + .map(|tab| tab.spawn_index) + .collect::>(), + vec![0, 1] + ); }); app.update(|ctx| { diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 0a8c82883ad..9babe6bf1d5 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -194,6 +194,34 @@ impl TuiSessions { orchestration: &ModelHandle, ctx: &mut AppContext, ) { + let sessions_for_model_updates = sessions.clone(); + ctx.observe_model(orchestration, move |_, ctx| { + let focused_view = sessions_for_model_updates + .as_ref(ctx) + .focused_session() + .map(|session| session.view().clone()); + if let Some(focused_view) = focused_view { + focused_view.update(ctx, |view, ctx| { + view.refresh_orchestration_tab_state(ctx); + }); + } + }); + + let sessions_for_focus_updates = sessions.clone(); + ctx.subscribe_to_model(sessions, move |_, event, ctx| { + let TuiSessionsEvent::FocusChanged(session_id) = event else { + return; + }; + let focused_view = sessions_for_focus_updates + .as_ref(ctx) + .session(*session_id) + .map(|session| session.view().clone()); + if let Some(focused_view) = focused_view { + focused_view.update(ctx, |view, ctx| { + view.refresh_orchestration_tab_state(ctx); + }); + } + }); let sessions = sessions.clone(); let orchestration_for_events = orchestration.clone(); ctx.subscribe_to_model(orchestration, move |_, event, ctx| match event { @@ -325,16 +353,19 @@ impl TuiSessions { self.sessions.iter().find(|session| session.id == id) } - /// Resolves a loaded conversation to the retained session that owns it. - pub(crate) fn session_id_for_conversation( + /// Builds the loaded conversation-to-session index used by one topology snapshot. + pub(crate) fn session_ids_by_conversation( &self, history: &BlocklistAIHistoryModel, - conversation_id: AIConversationId, - ) -> Option { - let surface_id = history.terminal_surface_id_for_conversation(&conversation_id)?; + ) -> HashMap { self.sessions .iter() - .find_map(|session| (session.id.surface_id() == surface_id).then_some(session.id)) + .flat_map(|session| { + history + .all_live_conversations_for_terminal_surface(session.id.surface_id()) + .map(move |conversation| (conversation.id(), session.id)) + }) + .collect() } /// Whether no session has been registered. diff --git a/crates/warp_tui/src/tab_bar.rs b/crates/warp_tui/src/tab_bar.rs index 16bead73c37..4a97d5c59cc 100644 --- a/crates/warp_tui/src/tab_bar.rs +++ b/crates/warp_tui/src/tab_bar.rs @@ -24,7 +24,7 @@ const DIVIDER_PADDING_RIGHT: u16 = 2; const ELLIPSIS: &str = "..."; /// Stable tab data rendered by [`TuiTabBarView`]. -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct TuiTab { pub key: String, pub label: String, @@ -32,7 +32,7 @@ pub struct TuiTab { } /// Styled text rendered before a tab label. -#[derive(Clone)] +#[derive(Clone, PartialEq)] struct TuiTabLeading { text: String, style: TuiStyle, @@ -59,7 +59,7 @@ impl TuiTab { } /// Caller-supplied styles for the tab bar and its semantic states. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TuiTabBarStyles { pub background: Option, pub leading: TuiStyle, @@ -70,7 +70,7 @@ pub struct TuiTabBarStyles { } /// Caller-owned semantic state and presentation options for a tab bar. -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub struct TuiTabBarConfig { pub leading: Option, pub main_tab: Option, @@ -210,6 +210,9 @@ impl TuiTabBarView { config: TuiTabBarConfig, ctx: &mut ViewContext, ) -> Result<(), TuiTabBarConfigError> { + if self.config == config { + return Ok(()); + } let live_keys = validated_live_keys(&config)?; self.config = config; self.reconcile_mouse_states(live_keys); diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 7d446a67143..786f819626b 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,5 +1,6 @@ //! Authenticated terminal-session TUI surface. use std::borrow::Cow; +use std::cell::Cell; use std::collections::HashMap; use std::path::PathBuf; use std::rc::Rc; @@ -315,6 +316,8 @@ pub(crate) struct TuiTerminalSessionView { /// visibility itself is derived at render time, never stored. active_blocker_view_id: Option, orchestration_tab_bar: ViewHandle, + orchestration_snapshot: Option, + orchestration_tabs_available: Rc>, orchestration_tabs_focused: bool, } @@ -917,7 +920,8 @@ impl TuiTerminalSessionView { let inline_menus_for_input = inline_menus.clone(); let suggestions_mode_for_input = suggestions_mode.clone(); let transcript_for_input = transcript.clone(); - let conversation_selection_for_input = conversation_selection.clone(); + let orchestration_tabs_available = Rc::new(Cell::new(false)); + let orchestration_tabs_available_for_input = orchestration_tabs_available.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( input_editor_model, @@ -925,21 +929,7 @@ impl TuiTerminalSessionView { suggestions_mode_for_input, inline_menus_for_input, transcript_for_input, - move |ctx| { - if !ctx.has_singleton_model::() - || !ctx.has_singleton_model::() - { - return false; - } - conversation_selection_for_input - .as_ref(ctx) - .selected_conversation_id(ctx) - .is_some_and(|conversation_id| { - TuiOrchestrationModel::as_ref(ctx) - .snapshot(conversation_id, ctx) - .is_some() - }) - }, + move |_| orchestration_tabs_available_for_input.get(), ctx, ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) @@ -1007,7 +997,7 @@ impl TuiTerminalSessionView { let Ok(page_anchor) = AIConversationId::try_from(page_anchor.clone()) else { return; }; - let Some(snapshot) = view.orchestration_tab_snapshot(ctx) else { + let Some(snapshot) = view.orchestration_snapshot.as_ref() else { return; }; TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { @@ -1015,11 +1005,6 @@ impl TuiTerminalSessionView { }); } }); - if ctx.has_singleton_model::() { - ctx.observe(&TuiOrchestrationModel::handle(ctx), |view, _, ctx| { - view.handle_orchestration_tab_change(ctx) - }); - } // The input box border color and the footer's shell-mode hint depend // on the input mode. ctx.subscribe_to_model(&ai_input_model, |_, _, _, ctx| ctx.notify()); @@ -1034,7 +1019,9 @@ impl TuiTerminalSessionView { ); ctx.subscribe_to_model(&conversation_selection, |view, _, _, ctx| { view.refresh_exit_summary(ctx); - view.handle_orchestration_tab_change(ctx); + if view.is_focused_session(ctx) { + view.refresh_orchestration_tab_state(ctx); + } }); // The zero state's "What's new" section: fetch the changelog once at @@ -1197,16 +1184,7 @@ impl TuiTerminalSessionView { ctx.spawn_stream_local( throttle(WAKEUP_THROTTLE_PERIOD, wakeups_rx), |view, _, ctx| { - { - let mut model = view.terminal_model.lock(); - if !model.is_alt_screen_active() { - model.block_list_mut().update_background_block_height(); - model.block_list_mut().update_active_block_height(); - } - } - view.update_process_input_focus(ctx); - - ctx.notify(); + view.handle_terminal_wakeup(ctx); }, |_, _| {}, ); @@ -1245,6 +1223,8 @@ impl TuiTerminalSessionView { exit_summary, active_blocker_view_id: None, orchestration_tab_bar, + orchestration_snapshot: None, + orchestration_tabs_available, orchestration_tabs_focused: false, } } @@ -1275,8 +1255,11 @@ impl TuiTerminalSessionView { }); } - /// Resolves the orchestration tabs for this session's selected conversation. - fn orchestration_tab_snapshot(&self, ctx: &AppContext) -> Option { + /// Resolves live semantic orchestration state for this session. + fn compute_orchestration_tab_snapshot( + &self, + ctx: &AppContext, + ) -> Option { if !ctx.has_singleton_model::() || !ctx.has_singleton_model::() { @@ -1288,26 +1271,32 @@ impl TuiTerminalSessionView { .selected_conversation_id(ctx)?; TuiOrchestrationModel::as_ref(ctx).snapshot(selected_conversation_id, ctx) } - - /// Drops tab focus when this session no longer has an orchestration bar. - fn handle_orchestration_tab_change(&mut self, ctx: &mut ViewContext) { - let snapshot = self.orchestration_tab_snapshot(ctx); - if let Some(snapshot) = snapshot.as_ref() { - let builder = TuiUiBuilder::from_app(ctx); - self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + /// Refreshes this session's retained bar from live semantic state. + pub(crate) fn refresh_orchestration_tab_state(&mut self, ctx: &mut ViewContext) { + let snapshot = self.compute_orchestration_tab_snapshot(ctx); + let snapshot_changed = self.orchestration_snapshot != snapshot; + self.orchestration_tabs_available.set(snapshot.is_some()); + self.orchestration_snapshot = snapshot; + if snapshot_changed { + if let Some(snapshot) = self.orchestration_snapshot.as_ref() { + let builder = TuiUiBuilder::from_app(ctx); + self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + } } - if snapshot.is_none() && self.orchestration_tabs_focused { + let mut focus_changed = false; + if self.orchestration_snapshot.is_none() && self.orchestration_tabs_focused { self.orchestration_tabs_focused = false; - if self.is_focused_session(ctx) { - self.focus_current_owner(ctx); - } + focus_changed = true; + self.focus_current_owner(ctx); + } + if snapshot_changed || focus_changed { + ctx.notify(); } - ctx.notify(); } /// Gives keyboard focus to the orchestration tab bar when it is available. fn focus_orchestration_tabs(&mut self, ctx: &mut ViewContext) { - if self.orchestration_tab_snapshot(ctx).is_none() { + if self.orchestration_snapshot.is_none() { return; } self.set_orchestration_tab_focus(true, ctx); @@ -1322,11 +1311,10 @@ impl TuiTerminalSessionView { } fn refresh_orchestration_tab_bar(&self, ctx: &mut ViewContext) { - let Some(snapshot) = self.orchestration_tab_snapshot(ctx) else { - return; - }; - let builder = TuiUiBuilder::from_app(ctx); - self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); + if let Some(snapshot) = self.orchestration_snapshot.as_ref() { + let builder = TuiUiBuilder::from_app(ctx); + self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + } } fn switch_to_orchestration_tab( @@ -1355,6 +1343,7 @@ impl TuiTerminalSessionView { return; }; if session_id.surface_id() == self.terminal_surface_id { + self.refresh_orchestration_tab_state(ctx); self.set_orchestration_tab_focus(keep_tab_focus, ctx); return; } @@ -1468,6 +1457,7 @@ impl TuiTerminalSessionView { pub(crate) fn activate(&mut self, ctx: &mut ViewContext) { self.focus_current_owner(ctx); self.write_exit_summary(ctx); + ctx.notify(); } /// Whether this view projects the focused session. @@ -1761,6 +1751,22 @@ impl TuiTerminalSessionView { ctx.emit(TuiTerminalSessionEvent::Resize(size_update)); ctx.notify(); } + /// Refreshes terminal model geometry and redraws only when this session is visible. + fn handle_terminal_wakeup(&mut self, ctx: &mut ViewContext) -> bool { + { + let mut model = self.terminal_model.lock(); + if !model.is_alt_screen_active() { + model.block_list_mut().update_background_block_height(); + model.block_list_mut().update_active_block_height(); + } + } + let is_focused = self.is_focused_session(ctx); + if is_focused { + self.update_process_input_focus(ctx); + ctx.notify(); + } + is_focused + } /// Re-renders on history events that can change the warping indicator: /// the selected conversation's status changing, or an exchange starting @@ -2713,7 +2719,7 @@ impl TuiView for TuiTerminalSessionView { }) .flatten(); let builder = TuiUiBuilder::from_app(ctx); - let orchestration_tabs_available = self.orchestration_tab_snapshot(ctx).is_some(); + let orchestration_tabs_available = self.orchestration_tabs_available.get(); // Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the // fixed binding registered in [`Self::init`], so no element-level key diff --git a/crates/warp_tui/src/terminal_session_view_tests.rs b/crates/warp_tui/src/terminal_session_view_tests.rs index 47253050bd6..e2edef825d5 100644 --- a/crates/warp_tui/src/terminal_session_view_tests.rs +++ b/crates/warp_tui/src/terminal_session_view_tests.rs @@ -4,7 +4,9 @@ use warp::tui_export::{ PtyIntentEvent, SizeInfo, SizeUpdate, }; use warpui::platform::WindowStyle; -use warpui::{AddWindowOptions, EntityIdMap, ModelHandle, ReadModel, SingletonEntity, ViewHandle}; +use warpui::{ + AddWindowOptions, EntityIdMap, ModelHandle, ReadModel, SingletonEntity, UpdateModel, ViewHandle, +}; use warpui_core::elements::tui::{ TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, @@ -274,11 +276,11 @@ fn alternate_screen_clears_orchestration_tab_focus_and_bindings() { } #[test] -fn background_model_notification_never_moves_responder_focus() { +fn orchestration_updates_refresh_only_the_focused_session() { App::test((), |mut app| async move { let fixture = focus_test_fixture(&mut app); let (foreground, foreground_id) = add_focus_test_session(&mut app, &fixture, true); - let (background, _) = add_focus_test_session(&mut app, &fixture, false); + let (background, background_id) = add_focus_test_session(&mut app, &fixture, false); background.update(&mut app, |view, _| { view.orchestration_tabs_focused = true; @@ -297,6 +299,23 @@ fn background_model_notification_never_moves_responder_focus() { ); assert!(app .read(|ctx| { ctx.check_view_or_child_focused(fixture.window_id, &foreground.id()) })); + assert!(background.read(&app, |view, _| view.orchestration_tabs_focused)); + + app.update_model(&fixture.sessions, |sessions, ctx| { + assert!(sessions.focus_session(background_id, ctx)); + }); assert!(!background.read(&app, |view, _| view.orchestration_tabs_focused)); }); } + +#[test] +fn terminal_wakeup_redraws_only_the_focused_session() { + App::test((), |mut app| async move { + let fixture = focus_test_fixture(&mut app); + let (foreground, _) = add_focus_test_session(&mut app, &fixture, true); + let (background, _) = add_focus_test_session(&mut app, &fixture, false); + + assert!(foreground.update(&mut app, |view, ctx| { view.handle_terminal_wakeup(ctx) })); + assert!(!background.update(&mut app, |view, ctx| { view.handle_terminal_wakeup(ctx) })); + }); +} diff --git a/specs/code-1822-tui-orchestration-tab-bar/TECH.md b/specs/code-1822-tui-orchestration-tab-bar/TECH.md index a6a70673347..2f3598e836a 100644 --- a/specs/code-1822-tui-orchestration-tab-bar/TECH.md +++ b/specs/code-1822-tui-orchestration-tab-bar/TECH.md @@ -13,7 +13,7 @@ The preceding stack gives every native local child a retained full TUI session, - [`app/src/ai/blocklist/history_model.rs (963-1018) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/history_model.rs#L963-L1018) — `terminal_surface_id_for_conversation` and `active_conversation` provide the authoritative conversation↔surface mapping. This mapping remains correct when a session restores a different conversation, unlike `TuiOrchestrationModel::child_session_by_conversation`. The GUI already owns the canonical orchestration ordering and tree traversal: -- [`app/src/ai/blocklist/orchestration_topology.rs (107-180) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/orchestration_topology.rs#L107-L180) — `descendant_conversation_ids_in_pill_order` implements pin/status/recency/spawn ordering, and adjacent navigation establishes wraparound semantics. +- [`app/src/ai/blocklist/orchestration_topology.rs (107-180) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/orchestration_topology.rs#L107-L180) — `descendant_conversations_in_pill_order` implements pin/status/recency/spawn ordering, and adjacent navigation establishes wraparound semantics. - [`app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs (600-670) @ 0bfc7889`](https://github.com/warpdotdev/warp/blob/0bfc788907e2b27c3488c581fee92e2f67a18ef1/app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs#L600-L670) — the GUI renders an orchestrator first, then descendants in the shared canonical order. Its current active-child lookup climbs one parent; PRODUCT (2) requires the TUI to climb to the top orchestration root so every member session shows the same complete tree. The focused session already owns the relevant input and focus transitions: @@ -24,18 +24,18 @@ The focused session already owns the relevant input and focus transitions: ## Proposed changes ### 1. Export and reuse the GUI topology policy -Re-export `descendant_conversation_ids_in_pill_order` through `app/src/tui_export.rs`; do not duplicate its sort keys in `warp_tui`. +Re-export `descendant_conversations_in_pill_order` through `app/src/tui_export.rs`; do not duplicate its sort keys in `warp_tui`. `TuiOrchestrationModel` reuses the GUI's descendant ordering while resolving the complete tree required by PRODUCT (2): 1. Receive the focused session's selected conversation ID from `TuiTerminalSessionView`; do not substitute the history model's “most recently streamed” active pointer for the input selection. 2. Follow resolved parent conversation IDs until reaching the top orchestration root, with a visited set to make malformed cycles fail closed. -3. Read all descendants from `descendant_conversation_ids_in_pill_order`. +3. Read all descendants and their stable spawn indices from `descendant_conversations_in_pill_order`. 4. Filter the orchestrator and descendants through the authoritative history surface mapping and a retained-session lookup. 5. Omit the bar unless the orchestrator and at least one child are immediately navigable. Filtering occurs after canonical ordering so the relative order of navigable descendants remains identical to the GUI. The model does not use `child_session_by_conversation` for rendering or navigation; that map remains narrowly scoped to failed-child cleanup. -Add `TuiSessions::session_id_for_conversation` so the registry composes `BlocklistAIHistoryModel::terminal_surface_id_for_conversation` with retained TUI ownership. This makes restored conversations self-correcting without another conversation/session index. +Add `TuiSessions::session_ids_by_conversation` to build one authoritative loaded conversation→session index from the history model's live conversations and retained TUI sessions per snapshot. Snapshot filtering and navigation then use constant-time lookups without maintaining another persistent index. Restored and transferred conversations remain self-correcting because the temporary index is rebuilt from live history state. ### 2. Orchestration-owned tab state and actions `TuiOrchestrationModel` owns TUI-specific orchestration runtime state: child launch materialization and cleanup, semantic tab snapshots, session navigation, and the one page state needed by the single visible tab bar: @@ -43,7 +43,7 @@ Add `TuiSessions::session_id_for_conversation` so the registry composes `Blockli - The child page-anchor conversation ID. - Whether the user explicitly paged away from the active tab. -Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn indices, page anchor, and whether the component should reveal an off-page selection. The session view assigns theme-specific identities from spawn order and maps semantic tab state to current-theme styles before updating the generalized component. Snapshot construction reads live history and session state each time; cached UI state never becomes the source of truth for membership, status, ordering, or selection. +Expose a plain-data snapshot for `TuiTerminalSessionView` containing the root, ordered child tabs, active conversation ID, spawn indices, page anchor, and whether the component should reveal an off-page selection. Each retained session caches its latest snapshot only as presentation state. The focused session refreshes that cache from live history/session state on topology changes, selection changes, explicit paging, and activation; hidden sessions refresh when activated. Render reads the cache and never reconstructs topology. The session view assigns theme-specific identities from spawn order and maps semantic tab state to current-theme styles before updating its generalized component. Model operations: - `focus_conversation_session` resolves the target's retained session, captures the snapshot's current fallback anchor when initializing page state for a tree, calls `TuiSessions::focus_session`, and clears explicit paging without replacing the current page anchor. The component reveals the deterministic page containing an off-page selection. @@ -51,7 +51,7 @@ Model operations: - A dynamic ordering update automatically re-anchors to the active child only when explicit paging is false. - Removed roots clear the active page state; removed children clamp invalid anchors through fresh snapshot resolution. -Subscribe exhaustively to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, or pin order, and react to session removal through the registry lifecycle. Pin mutations emit `UpdatedConversationMetadata`, so ordering and invalidation share the same history boundary. Notify model observers so retained session views redraw without adding a synthetic event variant or polling. +Subscribe exhaustively to `BlocklistAIHistoryModel` events that can change membership, parent linkage, labels, status, recency, or pin order, and react to session removal through the registry lifecycle. Pin mutations emit `UpdatedConversationMetadata`, so ordering and invalidation share the same history boundary. A single `TuiSessions` observer forwards orchestration-model invalidation only to the focused session; retained hidden sessions do no tab work until activation. ### 3. Session switching and focus handoff The model owns tab selection and page state, but actual responder focus remains view-owned because only a `ViewContext` can focus a view. @@ -71,17 +71,17 @@ Track whether the session view itself currently owns tab-bar focus. A switch per The source session clears its tab-focused mode during cross-session navigation. Process and blocker ownership also clear tab focus, and model-driven refreshes invoke focus APIs only for the focused session. ### 4. Input boundary handoff -Add a generic `MoveFocusUp` event and owner-supplied live availability predicate to `TuiInputView`; do not import orchestration types into the input module or cache availability state. +Add a generic `MoveFocusUp` event and owner-supplied availability predicate to `TuiInputView`; do not import orchestration types into the input module. The owner updates a shared boolean together with its cached orchestration snapshot, so the input's action-time predicate remains constant-time and cannot diverge from rendered bar visibility. When handling `SelectUp`, the input requests focus above only if: - Its owner's live predicate reports an above-target available. - The selection is empty. - The cursor's display point is display row zero. -Use the editor's char-cell render state and `display_lattice(...).offset_to_display_point(...)`, the same projection used by `TuiEditorElement`, rather than counting newlines or duplicating soft-wrap math. If any condition is false, call the existing `select_up` path unchanged. The owner predicate reads the selected conversation and `TuiOrchestrationModel` at action time, so render visibility and focus handoff cannot diverge. +Use the editor's char-cell render state and `display_lattice(...).offset_to_display_point(...)`, the same projection used by `TuiEditorElement`, rather than counting newlines or duplicating soft-wrap math. If any condition is false, call the existing `select_up` path unchanged. The owner predicate reads the availability value synchronized with the retained presentation snapshot. ### 5. Session rendering and owner callbacks -Own `TuiTabBarView` as a retained child of `TuiTerminalSessionView`. Each session asks `TuiOrchestrationModel` for its current tree, maps the snapshot into `TuiTabBarConfig`, and synchronizes the child view before embedding it with `TuiChildView`. +Own `TuiTabBarView` as a retained child of `TuiTerminalSessionView`. Each session retains its own bar and cached semantic snapshot. Only the focused session maps fresh model state into `TuiTabBarConfig`; hidden sessions continue processing agent and terminal state without synchronizing or rendering their bars. Activation refreshes the target session before it is projected, and `TuiTabBarView::set_config` no-ops identical configurations. Restructure the normal render tree into: - A full-width optional orchestration tab row. @@ -136,6 +136,7 @@ The reusable component lands in the lower `harry/code-1822-tui-tab-bar-component ## Risks and mitigations - **Dynamic order versus explicit paging:** store page anchors by conversation ID, not index, and distinguish automatic reveal from explicit paging. Re-resolve every anchor against the fresh canonical order. +- **Hidden-session presentation drift:** hidden sessions intentionally retain stale presentation caches while their semantic models continue updating. Refresh the complete snapshot and bar configuration on `TuiSessionsEvent::FocusChanged` before relying on the target session's cached availability or navigation order. - **Layout/event drift:** each size-switch row owns the tabs and overflow callbacks it renders, while keyboard navigation uses the same semantic config held by the retained tab view. The orchestration model resolves emitted stable IDs against fresh state and no-ops unavailable targets. - **Focus without projection:** make every tab selection update `TuiSessions` and the target view's focus mode in one owner action; test both root projection and responder focus. - **Stale child-session cache:** use history's authoritative conversation→surface mapping plus `TuiSessions` lookup for navigation. Keep the existing child map only for launch cleanup. From d972caa06fa1de0bf1a6e0edd51ff344d2864187 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 17:43:52 -0400 Subject: [PATCH 10/12] fix model preference override --- .../warp_tui/src/orchestration_block/configuration.rs | 1 + crates/warp_tui/src/orchestration_block_tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/warp_tui/src/orchestration_block/configuration.rs b/crates/warp_tui/src/orchestration_block/configuration.rs index b2a3b36a09c..d70ad108f97 100644 --- a/crates/warp_tui/src/orchestration_block/configuration.rs +++ b/crates/warp_tui/src/orchestration_block/configuration.rs @@ -16,6 +16,7 @@ const LOCATION_CLOUD_ID: &str = "cloud"; /// therefore always uses Oz. pub(super) fn normalize_tui_local_harness(state: &mut OrchestrationConfigState) { if matches!(state.execution_mode, RunAgentsExecutionMode::Local) + && !state.harness_type.trim().is_empty() && !state.harness_type.eq_ignore_ascii_case("oz") { state.harness_type = "oz".to_string(); diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 4ed26bf5360..aab1043de06 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -167,6 +167,16 @@ fn unapproved_local_request_forces_oz_harness() { assert_eq!(state.harness_type, "oz"); assert_eq!(state.model_id, ""); } +#[test] +fn local_request_with_implicit_oz_harness_preserves_explicit_model() { + let mut incoming = request("", RunAgentsExecutionMode::Local); + incoming.model_id = "claude-3-5-haiku".to_string(); + + let state = TuiOrchestrationBlock::config_state_from_request(&incoming, None); + + assert_eq!(state.harness_type, ""); + assert_eq!(state.model_id, "claude-3-5-haiku"); +} #[test] fn build_request_carries_card_fields_and_edited_run_wide_state() { From 7fb2722697aa97b8d3f9599ddccf70ccd1c69c93 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 20:58:48 -0400 Subject: [PATCH 11/12] address comments --- crates/warp_tui/src/orchestration_model.rs | 65 +++++++--------- .../warp_tui/src/orchestration_model_tests.rs | 10 +-- crates/warp_tui/src/tab_bar.rs | 52 +++++++++++++ crates/warp_tui/src/tab_bar_tests.rs | 37 ++++++++- crates/warp_tui/src/terminal_session_view.rs | 77 ++++++++++--------- 5 files changed, 161 insertions(+), 80 deletions(-) diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index e8df4b5f455..de981edb6c2 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -27,11 +27,12 @@ use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; use crate::session_registry::{TuiSessionId, TuiSessions}; +use crate::tab_bar::TuiTabBarPagingState; use crate::terminal_session_view::TuiTerminalSessionView; -/// One navigable child tab in an orchestration snapshot. +/// One navigable child conversation in an orchestration snapshot. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct TuiOrchestrationTab { +pub(crate) struct TuiOrchestrationChild { pub(crate) conversation_id: AIConversationId, pub(crate) label: String, pub(crate) spawn_index: usize, @@ -42,8 +43,10 @@ pub(crate) struct TuiOrchestrationTab { pub(crate) struct TuiOrchestrationSnapshot { pub(crate) root_conversation_id: AIConversationId, pub(crate) selected_conversation_id: AIConversationId, - pub(crate) tabs: Vec, + pub(crate) children: Vec, + /// Stable child ID used to resolve the page start at the current width. pub(crate) page_anchor: Option, + /// Whether the tab bar may override the anchor to reveal the selection. pub(crate) reveal_selected: bool, } @@ -55,10 +58,8 @@ pub(crate) struct TuiOrchestrationModel { child_session_by_conversation: HashMap, /// Conversations whose event streams are consumed by each live session. event_consumers_by_session: HashMap>, - /// Page state for the single orchestration tab bar visible at a time. - page_root_conversation_id: Option, - page_anchor: Option, - explicitly_paged: bool, + /// Paging intent shared by the per-session tab-bar views. + tab_bar_paging: TuiTabBarPagingState, } pub(crate) enum TuiOrchestrationEvent { CreateLocalOzChildSession { @@ -95,14 +96,13 @@ impl TuiOrchestrationModel { let model = ctx.add_singleton_model(|_| Self { child_session_by_conversation: HashMap::new(), event_consumers_by_session: HashMap::new(), - page_root_conversation_id: None, - page_anchor: None, - explicitly_paged: false, + tab_bar_paging: TuiTabBarPagingState::default(), }); let model_for_history = model.clone(); ctx.subscribe_to_model(&history, move |_, event, ctx| { let topology_changed = match event { BlocklistAIHistoryEvent::StartedNewConversation { .. } + | BlocklistAIHistoryEvent::AppendedExchange { .. } | BlocklistAIHistoryEvent::UpdatedConversationStatus { .. } | BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } | BlocklistAIHistoryEvent::SplitConversation { .. } @@ -115,7 +115,6 @@ impl TuiOrchestrationModel { } => true, BlocklistAIHistoryEvent::CreatedSubtask { .. } | BlocklistAIHistoryEvent::UpgradedTask { .. } - | BlocklistAIHistoryEvent::AppendedExchange { .. } | BlocklistAIHistoryEvent::ReassignedExchange { .. } | BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. } | BlocklistAIHistoryEvent::SetActiveConversation { .. } @@ -151,13 +150,13 @@ impl TuiOrchestrationModel { let session_ids_by_conversation = sessions.session_ids_by_conversation(history); session_ids_by_conversation.get(&root_conversation_id)?; - let tabs = descendant_conversations_in_pill_order(history, root_conversation_id) + let children = descendant_conversations_in_pill_order(history, root_conversation_id) .into_iter() .filter_map(|descendant| { let conversation_id = descendant.conversation_id; session_ids_by_conversation.get(&conversation_id)?; let conversation = history.conversation(&conversation_id)?; - Some(TuiOrchestrationTab { + Some(TuiOrchestrationChild { conversation_id, label: conversation .agent_name() @@ -168,35 +167,34 @@ impl TuiOrchestrationModel { }) }) .collect::>(); - if tabs.is_empty() { + if children.is_empty() { return None; } - let page_state_applies = self.page_root_conversation_id == Some(root_conversation_id); - let page_anchor = (page_state_applies && self.explicitly_paged) - .then_some(self.page_anchor) - .flatten() - .filter(|anchor| tabs.iter().any(|tab| tab.conversation_id == *anchor)) - .or_else(|| tabs.first().map(|tab| tab.conversation_id)); + let resolved_page = self.tab_bar_paging.resolve( + children.first().map(|child| child.conversation_id), + |anchor| { + children + .iter() + .any(|child| child.conversation_id == *anchor) + }, + ); Some(TuiOrchestrationSnapshot { root_conversation_id, selected_conversation_id, - tabs, - page_anchor, - reveal_selected: !page_state_applies || !self.explicitly_paged, + children, + page_anchor: resolved_page.page_anchor, + reveal_selected: resolved_page.reveal_selected, }) } /// Stores an explicitly selected secondary page without switching sessions. pub(crate) fn set_explicit_page( &mut self, - root_conversation_id: AIConversationId, page_anchor: AIConversationId, ctx: &mut ModelContext, ) { - self.page_root_conversation_id = Some(root_conversation_id); - self.page_anchor = Some(page_anchor); - self.explicitly_paged = true; + self.tab_bar_paging.set_explicit_anchor(page_anchor); ctx.notify(); } @@ -207,13 +205,11 @@ impl TuiOrchestrationModel { ctx: &mut ModelContext, ) -> Option { let history = BlocklistAIHistoryModel::as_ref(ctx); - let root_conversation_id = orchestration_root_conversation_id(history, conversation_id)?; + orchestration_root_conversation_id(history, conversation_id)?; let session_id = *TuiSessions::as_ref(ctx) .session_ids_by_conversation(history) .get(&conversation_id)?; - self.page_root_conversation_id = Some(root_conversation_id); - self.page_anchor = None; - self.explicitly_paged = false; + self.tab_bar_paging.clear_explicit_anchor(); TuiSessions::handle(ctx).update(ctx, |sessions, ctx| { sessions.focus_session(session_id, ctx); }); @@ -221,13 +217,6 @@ impl TuiOrchestrationModel { } fn topology_changed(&mut self, ctx: &mut ModelContext) { - if self.page_root_conversation_id.is_some_and(|root| { - orchestration_root_conversation_id(BlocklistAIHistoryModel::as_ref(ctx), root).is_none() - }) { - self.page_root_conversation_id = None; - self.page_anchor = None; - self.explicitly_paged = false; - } ctx.notify(); } diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 79da2020394..62806f8e84a 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -230,17 +230,17 @@ fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { assert_eq!(child.root_conversation_id, parent_conversation_id); assert_eq!( parent - .tabs + .children .iter() - .map(|tab| tab.conversation_id) + .map(|child| child.conversation_id) .collect::>(), vec![first_child_id, second_child_id] ); assert_eq!( parent - .tabs + .children .iter() - .map(|tab| tab.spawn_index) + .map(|child| child.spawn_index) .collect::>(), vec![0, 1] ); @@ -261,7 +261,7 @@ fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { }); app.update(|ctx| { TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { - model.set_explicit_page(parent_conversation_id, second_child_id, ctx); + model.set_explicit_page(second_child_id, ctx); }); }); app.read(|ctx| { diff --git a/crates/warp_tui/src/tab_bar.rs b/crates/warp_tui/src/tab_bar.rs index 4a97d5c59cc..29a1664fbfa 100644 --- a/crates/warp_tui/src/tab_bar.rs +++ b/crates/warp_tui/src/tab_bar.rs @@ -104,6 +104,54 @@ impl TuiTabBarConfig { } } +/// Caller-owned responsive paging intent for a tab bar. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TuiTabBarPagingState { + explicit_anchor: Option, +} +impl Default for TuiTabBarPagingState { + fn default() -> Self { + Self { + explicit_anchor: None, + } + } +} + +impl TuiTabBarPagingState { + /// Preserves the page beginning at `anchor` instead of revealing selection. + pub(crate) fn set_explicit_anchor(&mut self, anchor: K) { + self.explicit_anchor = Some(anchor); + } + + /// Resumes automatic selected-tab reveal. + pub(crate) fn clear_explicit_anchor(&mut self) { + self.explicit_anchor = None; + } +} + +impl TuiTabBarPagingState { + /// Resolves paging intent against the owner's current ordered keys. + pub(crate) fn resolve( + &self, + default_anchor: Option, + explicit_anchor_is_valid: impl FnOnce(&K) -> bool, + ) -> TuiTabBarResolvedPage { + let explicit_anchor = self + .explicit_anchor + .as_ref() + .and_then(|anchor| explicit_anchor_is_valid(anchor).then_some(anchor)); + TuiTabBarResolvedPage { + page_anchor: explicit_anchor.cloned().or(default_anchor), + reveal_selected: explicit_anchor.is_none(), + } + } +} + +/// Effective paging inputs resolved from [`TuiTabBarPagingState`]. +pub(crate) struct TuiTabBarResolvedPage { + pub(crate) page_anchor: Option, + pub(crate) reveal_selected: bool, +} /// Invalid caller-supplied tab-bar configuration. #[derive(Clone, Debug, Eq, PartialEq)] pub enum TuiTabBarConfigError { @@ -227,6 +275,10 @@ impl TuiTabBarView { self.mouse_states.entry(key).or_default(); } } + /// Whether the current configuration contains any main or secondary tabs. + pub(crate) fn has_tabs(&self) -> bool { + self.config.main_tab.is_some() || !self.config.tabs.is_empty() + } /// Resolves the adjacent tab in semantic order, wrapping at either end. pub fn navigation_target(&self, direction: TuiTabBarNavigationDirection) -> Option { diff --git a/crates/warp_tui/src/tab_bar_tests.rs b/crates/warp_tui/src/tab_bar_tests.rs index 224a1a80798..9823d8d0cb1 100644 --- a/crates/warp_tui/src/tab_bar_tests.rs +++ b/crates/warp_tui/src/tab_bar_tests.rs @@ -7,7 +7,8 @@ use warpui_core::{App, AppContext, TuiView}; use super::{ deterministic_pages_at_width, minimum_label_width, minimum_row_width, page_variant_at_width, validated_live_keys, TuiTab, TuiTabBarConfig, TuiTabBarConfigError, - TuiTabBarNavigationDirection, TuiTabBarSecondaryEdge, TuiTabBarStyles, TuiTabBarView, + TuiTabBarNavigationDirection, TuiTabBarPagingState, TuiTabBarSecondaryEdge, TuiTabBarStyles, + TuiTabBarView, }; fn key(key: u8) -> String { @@ -38,6 +39,40 @@ fn view(config: TuiTabBarConfig) -> TuiTabBarView { TuiTabBarView::new(config).unwrap() } +#[test] +fn tab_availability_is_derived_from_the_retained_config() { + let empty = view(config(Vec::new())); + assert!(!empty.has_tabs()); + + let secondary = view(config(vec![tab(1, "one")])); + assert!(secondary.has_tabs()); + + let mut main_only = config(Vec::new()); + main_only.main_tab = Some(tab(1, "main")); + assert!(view(main_only).has_tabs()); +} +#[test] +fn paging_state_preserves_only_a_valid_explicit_anchor() { + let mut state = TuiTabBarPagingState::default(); + let automatic = state.resolve(Some(key(1)), |_| false); + assert_eq!(automatic.page_anchor, Some(key(1))); + assert!(automatic.reveal_selected); + + state.set_explicit_anchor(key(2)); + let explicit = state.resolve(Some(key(1)), |anchor| anchor == "2"); + assert_eq!(explicit.page_anchor, Some(key(2))); + assert!(!explicit.reveal_selected); + + let invalid = state.resolve(Some(key(1)), |_| false); + assert_eq!(invalid.page_anchor, Some(key(1))); + assert!(invalid.reveal_selected); + + state.clear_explicit_anchor(); + let cleared = state.resolve(Some(key(1)), |_| true); + assert_eq!(cleared.page_anchor, Some(key(1))); + assert!(cleared.reveal_selected); +} + fn render( view: &TuiTabBarView, width: u16, diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 786f819626b..c02159fd981 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -1,6 +1,5 @@ //! Authenticated terminal-session TUI surface. use std::borrow::Cow; -use std::cell::Cell; use std::collections::HashMap; use std::path::PathBuf; use std::rc::Rc; @@ -316,8 +315,6 @@ pub(crate) struct TuiTerminalSessionView { /// visibility itself is derived at render time, never stored. active_blocker_view_id: Option, orchestration_tab_bar: ViewHandle, - orchestration_snapshot: Option, - orchestration_tabs_available: Rc>, orchestration_tabs_focused: bool, } @@ -920,8 +917,8 @@ impl TuiTerminalSessionView { let inline_menus_for_input = inline_menus.clone(); let suggestions_mode_for_input = suggestions_mode.clone(); let transcript_for_input = transcript.clone(); - let orchestration_tabs_available = Rc::new(Cell::new(false)); - let orchestration_tabs_available_for_input = orchestration_tabs_available.clone(); + let orchestration_tab_bar = ctx.add_typed_action_tui_view(|_| TuiTabBarView::empty()); + let orchestration_tab_bar_for_input = orchestration_tab_bar.clone(); let input_view = ctx.add_typed_action_tui_view(move |ctx| { TuiInputView::new( input_editor_model, @@ -929,12 +926,11 @@ impl TuiTerminalSessionView { suggestions_mode_for_input, inline_menus_for_input, transcript_for_input, - move |_| orchestration_tabs_available_for_input.get(), + move |ctx| orchestration_tab_bar_for_input.as_ref(ctx).has_tabs(), ctx, ) .with_keyboard_enhancement_supported(keyboard_enhancement_supported) }); - let orchestration_tab_bar = ctx.add_typed_action_tui_view(|_| TuiTabBarView::empty()); ctx.subscribe_to_view(&transcript, |view, _, event, ctx| match event { TuiTranscriptViewEvent::SelectionStarted => { @@ -997,11 +993,8 @@ impl TuiTerminalSessionView { let Ok(page_anchor) = AIConversationId::try_from(page_anchor.clone()) else { return; }; - let Some(snapshot) = view.orchestration_snapshot.as_ref() else { - return; - }; TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { - model.set_explicit_page(snapshot.root_conversation_id, page_anchor, ctx); + model.set_explicit_page(page_anchor, ctx); }); } }); @@ -1223,8 +1216,6 @@ impl TuiTerminalSessionView { exit_summary, active_blocker_view_id: None, orchestration_tab_bar, - orchestration_snapshot: None, - orchestration_tabs_available, orchestration_tabs_focused: false, } } @@ -1274,29 +1265,29 @@ impl TuiTerminalSessionView { /// Refreshes this session's retained bar from live semantic state. pub(crate) fn refresh_orchestration_tab_state(&mut self, ctx: &mut ViewContext) { let snapshot = self.compute_orchestration_tab_snapshot(ctx); - let snapshot_changed = self.orchestration_snapshot != snapshot; - self.orchestration_tabs_available.set(snapshot.is_some()); - self.orchestration_snapshot = snapshot; - if snapshot_changed { - if let Some(snapshot) = self.orchestration_snapshot.as_ref() { - let builder = TuiUiBuilder::from_app(ctx); - self.sync_orchestration_tab_bar(snapshot, &builder, ctx); - } + let tabs_were_available = self.orchestration_tab_bar.as_ref(ctx).has_tabs(); + if let Some(snapshot) = snapshot.as_ref() { + let builder = TuiUiBuilder::from_app(ctx); + self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + } else { + self.clear_orchestration_tab_bar(ctx); } + let tabs_are_available = self.orchestration_tab_bar.as_ref(ctx).has_tabs(); + let availability_changed = tabs_were_available != tabs_are_available; let mut focus_changed = false; - if self.orchestration_snapshot.is_none() && self.orchestration_tabs_focused { + if !tabs_are_available && self.orchestration_tabs_focused { self.orchestration_tabs_focused = false; focus_changed = true; self.focus_current_owner(ctx); } - if snapshot_changed || focus_changed { + if availability_changed || focus_changed { ctx.notify(); } } /// Gives keyboard focus to the orchestration tab bar when it is available. fn focus_orchestration_tabs(&mut self, ctx: &mut ViewContext) { - if self.orchestration_snapshot.is_none() { + if !self.orchestration_tab_bar.as_ref(ctx).has_tabs() { return; } self.set_orchestration_tab_focus(true, ctx); @@ -1311,9 +1302,9 @@ impl TuiTerminalSessionView { } fn refresh_orchestration_tab_bar(&self, ctx: &mut ViewContext) { - if let Some(snapshot) = self.orchestration_snapshot.as_ref() { + if let Some(snapshot) = self.compute_orchestration_tab_snapshot(ctx) { let builder = TuiUiBuilder::from_app(ctx); - self.sync_orchestration_tab_bar(snapshot, &builder, ctx); + self.sync_orchestration_tab_bar(&snapshot, &builder, ctx); } } @@ -1367,32 +1358,34 @@ impl TuiTerminalSessionView { builder: &TuiUiBuilder, ) -> TuiTabBarConfig { let palette = builder.agent_identity_palette(); - let mut tabs_in_spawn_order = snapshot.tabs.iter().collect::>(); - tabs_in_spawn_order.sort_by_key(|tab| tab.spawn_index); + let mut children_in_spawn_order = snapshot.children.iter().collect::>(); + children_in_spawn_order.sort_by_key(|child| child.spawn_index); let identity_indices = assign_agent_identity_indices( - tabs_in_spawn_order.iter().map(|tab| tab.label.as_str()), + children_in_spawn_order + .iter() + .map(|child| child.label.as_str()), palette.len(), ); - let identity_by_conversation = tabs_in_spawn_order + let identity_by_conversation = children_in_spawn_order .into_iter() - .map(|tab| tab.conversation_id) + .map(|child| child.conversation_id) .zip(identity_indices) .collect::>(); let tabs = snapshot - .tabs + .children .iter() - .map(|tab| { + .map(|child| { let identity = palette .get( identity_by_conversation - .get(&tab.conversation_id) + .get(&child.conversation_id) .copied() .unwrap_or_default(), ) .or_else(|| palette.first()) .cloned() .unwrap_or_default(); - TuiTab::new(tab.conversation_id.to_string(), tab.label.clone()) + TuiTab::new(child.conversation_id.to_string(), child.label.clone()) .with_leading_text(identity.glyph, identity.style) }) .collect(); @@ -1420,6 +1413,18 @@ impl TuiTerminalSessionView { ctx: &mut ViewContext, ) { let config = self.orchestration_tab_bar_config(snapshot, builder); + self.set_orchestration_tab_bar_config(config, ctx); + } + + fn clear_orchestration_tab_bar(&self, ctx: &mut ViewContext) { + self.set_orchestration_tab_bar_config(TuiTabBarConfig::new(Vec::new()), ctx); + } + + fn set_orchestration_tab_bar_config( + &self, + config: TuiTabBarConfig, + ctx: &mut ViewContext, + ) { let result = self .orchestration_tab_bar .update(ctx, |tab_bar, ctx| tab_bar.set_config(config, ctx)); @@ -2719,7 +2724,7 @@ impl TuiView for TuiTerminalSessionView { }) .flatten(); let builder = TuiUiBuilder::from_app(ctx); - let orchestration_tabs_available = self.orchestration_tabs_available.get(); + let orchestration_tabs_available = self.orchestration_tab_bar.as_ref(ctx).has_tabs(); // Ctrl-c (cancel/clear/exit) is handled by the keymap pass via the // fixed binding registered in [`Self::init`], so no element-level key From b8d1b0d0736402e60c31b0de6c4f0bd3a6e4260f Mon Sep 17 00:00:00 2001 From: harryalbert Date: Fri, 17 Jul 2026 21:42:42 -0400 Subject: [PATCH 12/12] clippy --- app/src/ai/blocklist/orchestration_topology.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index 715d4a2996f..dfe3783f7c4 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -5,6 +5,7 @@ //! orchestration pill bar so other surfaces (e.g. keyboard navigation and //! the agent-mode usage footer's credit rollup) can walk and order the same //! tree without duplicating the logic. +#[cfg(feature = "tui")] use std::collections::HashSet; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; @@ -105,6 +106,7 @@ pub fn resolve_orchestration_participant( /// /// Conversations without descendants are not orchestration roots. Malformed /// parent cycles and missing ancestors fail closed. +#[cfg(feature = "tui")] pub fn orchestration_root_conversation_id( history: &BlocklistAIHistoryModel, conversation_id: AIConversationId,