From 56950b96ea0de7f53d820df82ae5fd64eff179c7 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 14:29:27 -0400 Subject: [PATCH 1/5] add product and tech spec --- specs/code-1822-tui-cloud-children/PRODUCT.md | 91 ++++++++ specs/code-1822-tui-cloud-children/TECH.md | 199 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 specs/code-1822-tui-cloud-children/PRODUCT.md create mode 100644 specs/code-1822-tui-cloud-children/TECH.md diff --git a/specs/code-1822-tui-cloud-children/PRODUCT.md b/specs/code-1822-tui-cloud-children/PRODUCT.md new file mode 100644 index 00000000000..059ea720b2e --- /dev/null +++ b/specs/code-1822-tui-cloud-children/PRODUCT.md @@ -0,0 +1,91 @@ +# PRODUCT: TUI Cloud Orchestration Children +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Depends on: [specs/code-1822-tui-local-children/TECH.md](../code-1822-tui-local-children/TECH.md) + +## Summary +The Warp TUI can launch cloud child agents from an accepted orchestration request and represent each child as a retained, navigable cloud session. Before cloud session viewing is available, the child surface is read-only and shows the cloud run's status plus an actionable link to view the run in Oz. + +## Figma +Figma: none provided. + +## Goals +- Make remote `run_agents` requests complete successfully in the TUI instead of returning the current unsupported-mode failure. +- Give each cloud child a stable TUI session and orchestration tab from launch through its terminal lifecycle state. +- Reuse the same cloud launch configuration, startup-error meaning, and lifecycle status meaning as the GUI without changing existing GUI behavior. +- Keep the v0 surface intentionally read-only while establishing the stable session identity needed for future cloud session viewing. + +## Non-goals +- Viewing or interacting with the cloud child's shared terminal session in the TUI. +- Rendering the cloud child's transcript in the placeholder. +- Sending follow-up prompts or steering the cloud child from the placeholder. +- Stopping, killing, deleting, or restarting a cloud child from the placeholder. +- Automatically retrying an orchestrated child after GitHub authentication completes. +- Changing `StartAgentExecutor`, `RunAgentsExecutor`, their result types, or their existing timeout and blocked-launch semantics. +- Changing the existing GUI cloud-pane launch, retry, status, or session-attachment behavior. +- Adding support for local CLI-harness child agents. + +## Behavior +### Launch and navigation +1. When the user accepts a valid remote `run_agents` request in the TUI, Warp starts each requested cloud child using the approved run-wide execution configuration and that child's name, title, and prompt. +2. Each cloud child gets a retained TUI session as soon as its launch is dispatched. The session is created before the server assigns a cloud run ID, allowing startup progress and actionable startup blockers to remain visible. +3. A newly created cloud child appears in the existing orchestration tab bar as soon as it is a navigable retained session. Creating it does not steal focus from the orchestrator or another selected child. +4. Selecting the cloud child's orchestration tab focuses the same retained session for that child's lifetime. Status updates never replace the session, change its identity, reset orchestration paging, or steal focus. +5. Multiple cloud children may launch concurrently. Each child displays and updates only its own name, status, startup issue, and run link. +6. Local and cloud children can coexist in the same orchestration tree and use the same existing ordering and navigation behavior. + +### Placeholder layout +7. Before cloud session viewing is supported, a focused cloud child renders a read-only placeholder instead of a terminal transcript, prompt input, inline menus, normal footer, zero state, or shell content. +8. The placeholder's primary callout is centered horizontally and vertically within the content area available beneath the orchestration tab bar. +9. The placeholder displays: + - A status glyph using the same semantic status treatment as other TUI orchestration surfaces. + - A concise status or startup message. + - The relevant actionable link when one exists. +10. The child's orchestration tab/header also displays the same current status. The header and callout never disagree about the child's state. +11. The placeholder adapts to light, dark, and custom terminal themes using semantic styles rather than fixed colors. +12. On narrow terminal widths, callout text and visible URLs wrap without overflowing or dropping URL content. Resizing recenters and reflows the placeholder without changing its state. + +### Starting and successful launch +13. From session creation until a run ID or startup issue is received, the placeholder shows a running/attention glyph and a message equivalent to `Starting cloud run…`. +14. A successful server response associates the returned task ID and run ID with the existing child session and resolves that child as launched in the parent `run_agents` result. +15. Once the run ID is available, the placeholder displays `View cloud run here:` followed by the run's Oz URL. +16. The Oz URL targets the current Warp channel's Oz web application and the assigned run ID. The link never waits for a shared terminal session to become available. +17. The visible URL is selectable and copyable as text. +18. Clicking the link opens it in the user's configured browser. +19. Pressing Enter while the read-only cloud child is focused opens its current primary link. For a launched child this is the Oz run URL; for an authentication-blocked child this is the authentication URL. +20. Opening a link does not change TUI session selection, orchestration tab focus, child status, or launch state. + +### Ongoing lifecycle status +21. After launch, the placeholder and orchestration tab reflect the child's server lifecycle using the existing TUI status meanings: + - Queued, started, restarted, and in-progress states display as in progress. + - Blocked displays as blocked. + - Succeeded and idle display as succeeded. + - Failed and errored display as failed. + - Cancelled displays as cancelled. +22. Lifecycle status continues updating while the cloud child session is focused or in the background. +23. A terminal lifecycle state does not remove the cloud child session. The Oz run link remains available after success, failure, error, or cancellation. +24. Duplicate, replayed, delayed, or out-of-order lifecycle notifications must not create duplicate sessions or associate one child's status with another child. +25. If a lifecycle state is unknown, the placeholder fails closed to an error state rather than presenting the run as successful. + +### GitHub authentication required +26. If cloud startup fails because GitHub authentication is required before a server-side run is created, the existing child session is retained and displays a blocked status. +27. The blocked placeholder shows: + - The server-provided or shared fallback explanation. + - The actionable GitHub authentication URL. + - Clear text that the orchestration request must be run again after authentication. +28. Clicking the authentication link or pressing Enter opens the authentication URL. +29. Matching the current GUI behavior for an orchestrated remote child, the original `run_agents` child outcome is reported as failed. Completing authentication does not retroactively change that outcome. +30. The TUI does not automatically retry the failed child after authentication. This preserves the GUI's observable orchestrated-child result contract, but intentionally does not copy the GUI cloud pane model's independent post-failure auto-retry. A later orchestration request creates a new launch attempt using normal duplicate-agent and session behavior. +31. An authentication-blocked child is not removed by terminal failed-launch cleanup, matching the existing blocked-child behavior. + +### Other startup failures +32. A non-recoverable startup failure that occurs before a run ID is assigned is reported in the parent `run_agents` result with its meaningful shared error message. +33. Terminal pre-run failures use the existing failed-child cleanup behavior: the optimistic child session and conversation are removed so the tab bar does not retain a dead child with no cloud run. +34. Cleanup of one failed child does not remove, refocus, or alter successfully launched or blocked siblings. +35. If request preparation fails before dispatch—for example, a missing parent run ID or unresolved required skill—the child reports that preparation failure rather than timing out. + +### Interaction boundaries +36. The cloud placeholder accepts no prompt or shell input. Printable keys are never forwarded to a local PTY or remote run. +37. The placeholder does not expose stop, kill, delete, retry, or follow-up actions. +38. Existing orchestration-tab keyboard and mouse navigation remains available. +39. Existing application exit behavior remains available; focusing a cloud placeholder must not make terminal-control keybindings act on a nonexistent local process. +40. The reusable link interaction behaves consistently for the Oz run URL and GitHub authentication URL, including hover treatment, click target, Enter activation, selection, and copying. diff --git a/specs/code-1822-tui-cloud-children/TECH.md b/specs/code-1822-tui-cloud-children/TECH.md new file mode 100644 index 00000000000..84f767dbb37 --- /dev/null +++ b/specs/code-1822-tui-cloud-children/TECH.md @@ -0,0 +1,199 @@ +# TECH: TUI Cloud Orchestration Children +Linear: [CODE-1822 — Orchestration](https://linear.app/warpdotdev/issue/CODE-1822/orchestration) +Product: [specs/code-1822-tui-cloud-children/PRODUCT.md](./PRODUCT.md) +Inspected commit: `8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76` + +## Context +The downstack TUI already configures and accepts remote `run_agents` requests, retains multiple full terminal sessions, materializes native local children, renders child messages/status identities, and navigates an orchestration tree. The remaining remote arm deliberately resolves as unsupported: +- [`crates/warp_tui/src/orchestration_model.rs (371-659) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/crates/warp_tui/src/orchestration_model.rs#L371-L659) — `TuiOrchestrationModel` subscribes to each retained session's `StartAgentExecutor`, registers the parent event consumer, materializes native children, and fails Remote requests. It also owns the narrow child-conversation/session map used by failed-launch cleanup. +- [`crates/warp_tui/src/session_registry.rs (20-175) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/crates/warp_tui/src/session_registry.rs#L20-L175) — every `TuiSession` retains one `TuiTerminalSessionView` and a type-erased `TerminalManagerTrait`; the view ID is also the terminal surface ID used by shared AI models. +- [`crates/warp_tui/src/session.rs (175-215) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/crates/warp_tui/src/session.rs#L175-L215) — `create_local_terminal_session` is the single local PTY materializer and the pattern for registering focused/background sessions. +- [`crates/warp_tui/src/terminal_session_view.rs (220-420) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/crates/warp_tui/src/terminal_session_view.rs#L220-L420) and [`terminal_session_view.rs (2450-2660) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/crates/warp_tui/src/terminal_session_view.rs#L2450-L2660) — the full session view owns input, transcript, terminal routing, orchestration tabs, focus, and the normal render tree. Cloud placeholder mode must short-circuit these responsibilities rather than thread cloud checks through each child component. + +The shared executor contract already supports frontend-owned materialization: +- [`app/src/ai/blocklist/action_model/execute/run_agents.rs (211-409) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/ai/blocklist/action_model/execute/run_agents.rs#L211-L409) — `RunAgentsExecutor` translates approved remote configuration into per-child `StartAgentRequest`s, fans them out, and applies the existing 30-second terminal spawn timeout. +- [`app/src/ai/blocklist/action_model/execute/start_agent.rs (108-306) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/ai/blocklist/action_model/execute/start_agent.rs#L108-L306) — `StartAgentExecutor` links the frontend-created child conversation, resolves launch after run-ID assignment, and reports startup errors from conversation status. +- [`app/src/ai/blocklist/action_model/execute/start_agent.rs (496-644) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/ai/blocklist/action_model/execute/start_agent.rs#L496-L644) — blocked startup is reported as a failed child outcome but retained, while terminal pre-run errors emit `CleanupFailedChildLaunch`. This behavior remains unchanged. + +The GUI proves the deferred-terminal architecture but mixes reusable launch preparation with GUI-owned pane behavior: +- [`app/src/pane_group/pane/terminal_pane.rs (1910-2149) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/pane_group/pane/terminal_pane.rs#L1910-L2149) — `launch_remote_child` creates a hidden PTY-less cloud pane and child conversation, normalizes remote configuration, resolves runtime skills, builds `SpawnAgentRequest`, and delegates launch to `AmbientAgentViewModel`. +- [`app/src/terminal/view/ambient_agent/model.rs (156-354) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/terminal/view/ambient_agent/model.rs#L156-L354) and [`model.rs (1253-1687) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/terminal/view/ambient_agent/model.rs#L1253-L1687) — GUI-only startup/session progress, error classification, GitHub-auth handling, task polling, and shared-session readiness. The TUI must not reuse this pane model or change its behavior. +- [`app/src/terminal/terminal_manager.rs (24-42) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/terminal/terminal_manager.rs#L24-L42) — `TerminalManagerTrait` requires a terminal model and downcasting, not a local PTY. +- [`app/src/terminal/model/terminal_model.rs (1148-1181) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/terminal/model/terminal_model.rs#L1148-L1181) — `new_for_cloud_mode_shared_session_viewer` creates the GUI's deferred PTY-less cloud terminal model. The TUI uses the same model state so a future viewer can attach without replacing the session or changing its surface ID. +- [`app/src/terminal/shared_session/viewer/terminal_manager.rs (202-405) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/terminal/shared_session/viewer/terminal_manager.rs#L202-L405) — the GUI viewer manager's `new_internal` builds event channels, an inactive PTY-read receiver, the PTY-less model, `Sessions`, `ModelEventDispatcher`, and a normal `TerminalView` before a shared session exists; `new_deferred` exposes that state for cloud panes. +- [`app/src/terminal/view/ambient_agent/mod.rs (54-159) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/terminal/view/ambient_agent/mod.rs#L54-L159) — `create_cloud_mode_view` retains the concrete deferred viewer behind `Box`, then `wire_ambient_agent_session_events` downcasts it and connects the same manager/view in place when `SessionReady` arrives. +- [`app/src/pane_group/mod.rs (4105-4155) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/pane_group/mod.rs#L4105-L4155) — remote children use this same deferred manager/view as an off-tree terminal pane, preserving pane and surface identity until the user reveals it or a shared session attaches. + +The parent event stream already receives child lifecycle events, but its owner-side path queues them for the parent rather than mutating remote child history: +- [`app/src/ai/blocklist/orchestration_event_streamer.rs (196-395) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/ai/blocklist/orchestration_event_streamer.rs#L196-L395) — owner-side per-conversation stream state and existing viewer-mode `ChildStatusChanged` event. +- [`app/src/ai/blocklist/orchestration_event_streamer.rs (911-1110) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/ai/blocklist/orchestration_event_streamer.rs#L911-L1110) — viewer-mode ancestor events emit child status broadcasts; this path must not be registered by the TUI because it would duplicate the parent's active SSE connection. +- [`app/src/ai/blocklist/orchestration_event_streamer.rs (2141-2340) @ 8f73e01b`](https://github.com/warpdotdev/warp/blob/8f73e01bd1f0638e24ff09f4d24e76d91e2e5b76/app/src/ai/blocklist/orchestration_event_streamer.rs#L2141-L2340) — canonical lifecycle-wire mapping into `ConversationStatus`, reused by the new owner-side notification. + +## Proposed changes +### 1. Extract shared remote-child launch preparation, not a launch coordinator +Add a frontend-neutral module under `app/src/ai/orchestration`, re-exported through `app/src/tui_export.rs`, containing: +- `RemoteChildLaunchConfig`: the remote fields currently destructured into GUI-local `RemoteLaunchFields`. +- `PreparedRemoteChildLaunch`: normalized child/display name, parsed orchestration harness, and final `SpawnAgentRequest`. +- `PrepareRemoteChildLaunchError`: missing parent run ID and unresolved required skills. +- `prepare_remote_child_launch`: resolves runtime skills and builds the wire request with the GUI's current semantics for empty environments, unknown harness overrides, model/worker host, Oz-only computer use, Claude/Codex managed auth secrets, title, parent run ID, interactive mode, and snapshot policy. + +Move the configuration mapping from `terminal_pane.rs::launch_remote_child` into this helper and make the GUI call it without changing pane creation, conversation ordering, `AmbientAgentViewModel`, spawn polling, error handling, retry, run-ID assignment, or session attachment. Keep the helper limited to launch preparation; it must not accept frontend callbacks or own mutable launch state. + +Extract the launch-error interpretation needed by both frontends into shared data: +- `CloudAgentStartupBlocker::GitHubAuthRequired { message, auth_url }`. +- `CloudAgentStartupFailure::{Capacity, OutOfCredits, ServerOverloaded, Other}`. +- A classifier that preserves the GUI's existing downcasts, fallback messages, and cloud-setup auth URL construction. + +The GUI maps these shared values into its existing `Status` and events without changing control flow. The TUI uses the same values in its placeholder presentation. Do not introduce a shared launch coordinator or duplicate `StartAgentExecutor` outcome state. + +### 2. Add a minimal deferred TUI cloud terminal manager +Add `crates/warp_tui/src/cloud_terminal_manager.rs` with `TuiCloudTerminalManager`, implementing `TerminalManagerTrait`. It owns: +- A PTY-less `TerminalModel` constructed with `TerminalModel::new_for_cloud_mode_shared_session_viewer`. +- Wakeup/model-event channels, `Sessions`, `ModelEventDispatcher`, terminal colors/size, and the inactive PTY-read receiver required to construct `TerminalSurfaceInit`. +- No local PTY, network transport, session-sharing connection API, polling loop, or speculative connected-state enum. + +**GUI prior art:** This deliberately mirrors the GUI's `shared_session::viewer::TerminalManager::new_deferred` construction and lifetime model: create the real terminal model, event plumbing, view, and concrete type-erased manager before a remote session exists, then retain their identities for an eventual in-place attachment. It does not reuse the GUI manager implementation because that implementation constructs `TerminalView` and owns GUI-specific network, orchestration polling, `ActiveAgentViewsModel`, permission, and session-attachment behavior. + +Add `create_cloud_terminal_session` beside `create_local_terminal_session` in `crates/warp_tui/src/session.rs`. It creates `TuiCloudTerminalManager`, constructs an unfocused `TuiTerminalSessionView` from its `TerminalSurfaceInit`, and registers the view plus type-erased manager with `TuiSessions`. The resulting view ID remains the `TuiSessionId` and terminal surface ID for the session's lifetime. + +The manager is intentionally the valid deferred initial state of a future cloud viewer, not a fake local terminal. Future session-sharing work may add transport to the same concrete manager without replacing the view, manager, conversation, or session identity. + +### 3. Add cloud placeholder state and centralize session-mode branching +Add a per-session `TuiCloudRunState` model containing: +- Child conversation ID. +- Shared startup presentation (`Dispatching`, blocker, or terminal startup failure). +- Optional task ID, run ID, and deterministic Oz run URL. + +Do not duplicate ongoing `ConversationStatus`; after run-ID assignment the view reads the authoritative status from `BlocklistAIHistoryModel`. + +Add `TuiTerminalSessionMode::{Local, CloudPlaceholder(ModelHandle)}` to `TuiTerminalSessionView` and a `new_cloud` constructor. Keep all cloud branching behind narrow helpers: +- `cloud_placeholder_presentation` derives the current callout from startup state plus history status. +- `render` returns the placeholder before constructing normal transcript/input/footer content. +- `child_view_ids`, activation/focus, keymap context, and PTY event routing treat an unattached cloud placeholder as self-focused and non-interactive. + +Do not add cloud checks throughout transcript, input, menus, footer, or shell-command handlers. No printable input or PTY intent is emitted from placeholder mode. + +### 4. Materialize and launch remote children in `TuiOrchestrationModel` +Replace the Remote failure arm in `dispatch_create_agent` with a TUI-owned launch flow: +1. Call the shared preparer. A preparation failure is surfaced through the existing failed-child contract rather than timing out. +2. Eagerly create an unfocused deferred cloud terminal session and `TuiCloudRunState::Dispatching`. +3. Create the child conversation on that session's surface, mark it remote, set it active for the surface, and call `record_new_conversation_request_complete` using the existing GUI ordering. +4. Record the conversation/session mapping before dispatch so `CleanupFailedChildLaunch` can remove terminal pre-run failures. +5. Call `AIClient::spawn_agent` directly. Do not use `spawn_task`: v0 needs the returned task/run IDs and deterministic Oz URL, not a joinable shared-session signal. +6. On success, update `TuiCloudRunState`, assign the task/run IDs through `BlocklistAIHistoryModel::assign_run_id_for_conversation`, and leave `StartAgentExecutor` to resolve the child and register the run under the parent. +7. On `GitHubAuthRequired`, set the shared blocked presentation and update the child conversation to `ConversationStatus::Blocked` with the shared message. Existing executor behavior reports a failed child outcome but emits no cleanup, leaving the actionable pane visible. Do not subscribe to `GitHubAuthNotifier` or automatically retry. +8. On a terminal startup failure, set the shared failure/error in history. Existing `CleanupFailedChildLaunch` removes the optimistic session and conversation. + +Keep GUI launch ownership unchanged. `TuiOrchestrationModel` remains the only TUI materializer; no new singleton coordinates both frontends. + +### 5. Project owner-side lifecycle events into TUI child status +Extend `OrchestrationEventStreamerEvent` with an owner-side watched-run lifecycle notification carrying: +- Owner/parent conversation ID. +- Child run ID. +- Canonically mapped `ConversationStatus`. + +Emit it while draining lifecycle events already received by the parent's existing owner-side SSE connection. Preserve current message hydration, event queueing, cursor advancement, eligibility, and viewer-mode behavior. + +Subscribe `TuiOrchestrationModel` to this event. For each notification: +1. Resolve the run ID through `BlocklistAIHistoryModel`'s authoritative agent-ID index. +2. Require the resolved conversation to be a remote child of the indicated parent. +3. Resolve its retained TUI surface and update that conversation's status in history. +4. No-op when the run, conversation, parent relationship, or retained session no longer matches. + +Only the TUI subscriber performs the history mutation. GUI code does not subscribe, so the change cannot clobber GUI-owned ambient status. Do not register a viewer-mode consumer and do not poll `get_ambient_agent_task`. + +### 6. Add a reusable TUI link element +Add `crates/warp_tui/src/link.rs` with a reusable `TuiLink` presentation helper: +- Owns a persistent `MouseStateHandle` outside the render pass. +- Renders caller-provided label/URL text with semantic normal and hover styles from `TuiUiBuilder`. +- Uses `TuiHoverable` for footprint-correct click handling. +- Accepts an `on_open` callback; it contains no cloud-specific URL construction or copy. + +The cloud session view uses a typed `OpenCloudRunUrl(String)` action for both link clicks and Enter while the read-only placeholder is focused. The view action calls `ctx.open_url`; the element itself does not mutate application state. The same element renders the Oz run URL and GitHub authentication URL. + +Keep keyboard ownership in `TuiTerminalSessionView`, not the element: Enter is active only for a focused cloud placeholder with a primary link. Visible URL text remains available to the host terminal's normal selection/copy behavior. + +### 7. Exports and cleanup +Re-export only the least-visible shared APIs needed by `warp_tui` through `app/src/tui_export.rs`: the prepared launch types/helper, startup blocker/failure values, spawn response/request types if not already exported, and the owner-side lifecycle event. + +Continue using `TuiOrchestrationModel::child_session_by_conversation` only for failed-launch cleanup. Conversation lineage, navigation, run-ID lookup, and lifecycle routing remain authoritative in shared history and existing topology helpers. + +## End-to-end flow +```mermaid +flowchart LR + A["RunAgentsExecutor
StartAgentRequest::Remote"] --> B["TuiOrchestrationModel"] + B --> C["prepare_remote_child_launch"] + B --> D["Deferred TUI cloud session
PTY-less TerminalModel"] + D --> E["Remote child conversation
marked remote"] + C --> F["AIClient::spawn_agent"] + F -->|success| G["Assign task/run IDs
build Oz URL"] + G --> H["StartAgentOutcome::Started"] + H --> I["Parent owner-side SSE
watches child run"] + I --> J["TUI lifecycle projection
updates child history"] + J --> K["Tab + centered callout
rerender"] + F -->|GitHub auth| L["Blocked pane
auth link; child outcome failed"] + F -->|terminal error| M["Existing failed-launch cleanup"] +``` + +## Testing and validation +### Shared launch preparation and error classification +- Table-driven tests cover PRODUCT (1, 14, 26-35): + - Empty and explicit environment IDs. + - Oz/default, Claude, Codex, Gemini, OpenCode, and unknown harness input. + - Oz-only computer-use propagation. + - Claude/Codex auth-secret mapping and ignored unsupported secret combinations. + - Empty and explicit model, worker host, and title. + - Parent run-ID validation, runtime-skill resolution, and snapshot policy. + - GitHub auth, capacity, quota, overload, and fallback error classification. +- Existing GUI tests continue passing against the extracted preparer/classifier, proving the refactor did not change GUI launch behavior. + +### Deferred manager and placeholder view +- Unit tests construct `TuiCloudTerminalManager` and assert that it exposes a PTY-less cloud-viewer terminal model and a usable `TerminalSurfaceInit` without starting a local shell. +- Render-to-lines tests cover PRODUCT (7-13, 15-20, 26-28, 36-40): + - Centered dispatching, launched, blocked-auth, failed, and terminal-status callouts. + - Status glyph/text parity between the tab snapshot and callout. + - URL wrapping at narrow widths and recentering after resize. + - Dark/light theme semantic styles. + - Click and Enter dispatch the same open-URL action. + - No transcript, input, zero state, footer, or PTY event routing in placeholder mode. +- `TuiLink` tests cover persistent hover styling, footprint-correct click dispatch, label/URL preservation, and reuse with both cloud and authentication URLs. + +### Orchestration launch and lifecycle +- Extend `orchestration_model_tests.rs` for PRODUCT (2-6, 14, 21-35): + - Remote dispatch eagerly creates one unfocused retained session and child conversation. + - Successful spawn assigns task/run IDs, resolves `run_agents` as launched, produces the channel-correct Oz URL, and leaves the session navigable. + - GitHub auth produces a retained blocked pane and failed child result with no automatic retry. + - Terminal pre-run errors trigger existing cleanup and do not affect siblings. + - Mixed local/remote and concurrent remote children remain isolated. + - Owner-side lifecycle events map to the correct remote child, update background sessions, ignore stale/mismatched events, and never mutate another tree. + - Success/error/cancelled lifecycle states retain the pane and URL. +- Streamer tests prove the new owner-side notification reuses the existing connection/cursor path and leaves viewer-mode events and GUI subscribers unchanged. + +### Live verification +Use `tui-verify-change` with `./script/run-tui`: +1. Launch at least two remote children and one local child from one orchestration. +2. Verify immediate background tabs without focus theft. +3. Switch among children while remote statuses transition. +4. Open the Oz URL by mouse and Enter. +5. Exercise an authentication-required launch and verify the retained blocked pane, auth link, failed child result, and no automatic retry. +6. Resize across narrow and wide terminal widths and verify centering, wrapping, tab stability, and input suppression. + +Run focused validation: +- `cargo nextest run -p warp_tui orchestration` +- `cargo nextest run -p warp_tui terminal_session` +- Focused app tests for the shared preparer, startup classifier, and event streamer. + +Before submission, run `./script/format`, the repository-prescribed Clippy command, and `./script/presubmit`. + +## Parallelization +Do not split implementation across child agents. Shared launch preparation must preserve the GUI contract while the TUI materializer, deferred manager, history linkage, lifecycle projection, and placeholder rendering share ordering and identity invariants. The reusable link element is independent but small; integrating it sequentially avoids worktree/stack coordination overhead. Long-running focused test groups may run concurrently after implementation. + +## Risks and mitigations +- **GUI behavior drift during extraction:** keep `AmbientAgentViewModel` and `launch_remote_child` ordering intact; move only normalized request construction and error interpretation, with parity tests over every mapped field and error kind. +- **Placeholder masquerades as a local terminal:** use a PTY-less cloud-viewer `TerminalModel`, centralize the render/focus/child-view early return, and assert no PTY intent can originate before a future viewer attaches. +- **Lifecycle status clobbers GUI state:** emit an owner-side event but perform history projection only in `TuiOrchestrationModel`, after validating remote-child identity and parent linkage. +- **Duplicate status sources:** startup presentation ends at run-ID assignment; ongoing status comes only from history. Do not poll task state or store a second ongoing status in `TuiCloudRunState`. +- **Failed-launch cleanup removes a blocker:** preserve existing executor semantics—`Blocked` produces a failed outcome without cleanup; only terminal `Error`/`Cancelled` pre-run states remove the optimistic session. +- **Stale run events target a reused surface:** resolve through history's run-ID index and revalidate parent, remote-child flag, and retained surface before updating. +- **Terminal-model deadlock:** keep lock scopes short, never acquire another terminal-model lock from within a locked call path, and use existing constructor/access patterns. +- **Future session viewing requires replacement:** retain the same cloud manager, terminal model, view, session ID, and conversation surface. Future network transport is additive to `TuiCloudTerminalManager`. From fdb13fb7b53fa25a660d0b65acbd93bf236c0e11 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Thu, 16 Jul 2026 16:34:32 -0400 Subject: [PATCH 2/5] add cloud conversatoins --- Cargo.lock | 1 + app/src/ai/blocklist/controller.rs | 3 +- .../blocklist/orchestration_event_streamer.rs | 22 ++ app/src/ai/orchestration/mod.rs | 7 + app/src/ai/orchestration/remote_child.rs | 304 ++++++++++++++++ .../ai/orchestration/remote_child_tests.rs | 149 ++++++++ app/src/pane_group/pane/terminal_pane.rs | 175 +-------- app/src/settings/privacy.rs | 16 + .../terminal/local_tty/terminal_manager.rs | 50 ++- app/src/terminal/view/ambient_agent/mod.rs | 1 - app/src/terminal/view/ambient_agent/model.rs | 75 ++-- app/src/tui_export.rs | 19 +- app/src/tui_test_support.rs | 7 +- crates/warp_tui/Cargo.toml | 1 + crates/warp_tui/src/agent_message.rs | 4 +- crates/warp_tui/src/cloud_run.rs | 96 +++++ crates/warp_tui/src/cloud_terminal_manager.rs | 44 +++ crates/warp_tui/src/lib.rs | 3 + crates/warp_tui/src/link.rs | 47 +++ crates/warp_tui/src/link_tests.rs | 24 ++ crates/warp_tui/src/orchestration_model.rs | 339 +++++++++++++++++- .../warp_tui/src/orchestration_model_tests.rs | 228 ++++++++++-- crates/warp_tui/src/session_registry.rs | 74 +++- crates/warp_tui/src/tab_bar.rs | 21 +- crates/warp_tui/src/terminal_session_view.rs | 249 ++++++++++++- crates/warp_tui/src/ui.rs | 2 +- specs/code-1822-tui-cloud-children/PRODUCT.md | 2 +- specs/code-1822-tui-cloud-children/TECH.md | 10 +- 28 files changed, 1685 insertions(+), 288 deletions(-) create mode 100644 app/src/ai/orchestration/remote_child.rs create mode 100644 app/src/ai/orchestration/remote_child_tests.rs create mode 100644 crates/warp_tui/src/cloud_run.rs create mode 100644 crates/warp_tui/src/cloud_terminal_manager.rs create mode 100644 crates/warp_tui/src/link.rs create mode 100644 crates/warp_tui/src/link_tests.rs diff --git a/Cargo.lock b/Cargo.lock index cb508c5dc83..7b81a2b7657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15930,6 +15930,7 @@ version = "0.0.0" dependencies = [ "ai", "anyhow", + "async-broadcast", "async-channel", "async-fs", "base64 0.22.1", diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 4d0e28f425f..ef5982c79eb 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -615,7 +615,8 @@ impl BlocklistAIController { } // Viewer-mode events are handled by `OrchestrationViewerModel`. OrchestrationEventStreamerEvent::ChildSpawned { .. } - | OrchestrationEventStreamerEvent::ChildStatusChanged { .. } => {} + | OrchestrationEventStreamerEvent::ChildStatusChanged { .. } + | OrchestrationEventStreamerEvent::WatchedRunStatusChanged { .. } => {} }); Self { input_model, diff --git a/app/src/ai/blocklist/orchestration_event_streamer.rs b/app/src/ai/blocklist/orchestration_event_streamer.rs index ea76bff2d7b..e14eb6d0ba2 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer.rs @@ -297,6 +297,7 @@ pub struct OrchestrationEventStreamer { killed_run_id_order: VecDeque, } +#[allow(private_interfaces)] pub enum OrchestrationEventStreamerEvent { DormantClaudeWakeReady { conversation_id: AIConversationId, @@ -314,6 +315,12 @@ pub enum OrchestrationEventStreamerEvent { run_id: String, status: ConversationStatus, }, + /// Lifecycle transition observed on an owner-side stream for a watched run. + WatchedRunStatusChanged { + owner_conversation_id: AIConversationId, + run_id: String, + status: ConversationStatus, + }, } /// Outcome of selecting the SSE wire filter for an owner-side conversation. @@ -2102,6 +2109,21 @@ impl OrchestrationEventStreamer { .extend(message_ids); } + for event in &events { + if event.run_id == self_run_id { + continue; + } + let Some(lifecycle_type) = lifecycle_event_type_from_wire(event.event_type.as_str()) + else { + continue; + }; + ctx.emit(OrchestrationEventStreamerEvent::WatchedRunStatusChanged { + owner_conversation_id: conversation_id, + run_id: event.run_id.clone(), + status: conversation_status_from_lifecycle_event_type(lifecycle_type), + }); + } + let lifecycle_events = convert_lifecycle_events(&events, self_run_id); if messages.is_empty() && lifecycle_events.is_empty() { return; diff --git a/app/src/ai/orchestration/mod.rs b/app/src/ai/orchestration/mod.rs index d61bf5dd6f2..e1dfdcdf26d 100644 --- a/app/src/ai/orchestration/mod.rs +++ b/app/src/ai/orchestration/mod.rs @@ -9,6 +9,7 @@ mod config_state; mod edit_state; mod providers; +mod remote_child; mod snapshots; mod validation; @@ -25,6 +26,12 @@ pub use providers::{ resolve_auth_secret_selection_for_harness, resolve_default_environment_id, resolve_default_host_slug, ORCHESTRATION_WARP_WORKER_HOST, }; +pub(crate) use remote_child::should_disable_snapshot; +pub use remote_child::{ + classify_cloud_agent_startup_error, oz_run_url, prepare_remote_child_launch, + CloudAgentStartupBlocker, CloudAgentStartupFailure, CloudAgentStartupIssue, + PrepareRemoteChildLaunchError, PreparedRemoteChildLaunch, RemoteChildLaunchConfig, +}; #[cfg_attr(not(feature = "tui"), allow(unused_imports))] pub use snapshots::location_snapshot; pub(crate) use snapshots::AUTH_SECRET_INHERIT_LABEL; diff --git a/app/src/ai/orchestration/remote_child.rs b/app/src/ai/orchestration/remote_child.rs new file mode 100644 index 00000000000..44f8dd868d4 --- /dev/null +++ b/app/src/ai/orchestration/remote_child.rs @@ -0,0 +1,304 @@ +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +use prost::Message as _; +use warp_cli::agent::Harness; +use warp_multi_agent_api as multi_agent_api; +use warpui::{AppContext, SingletonEntity as _}; + +use crate::ai::agent::UserQueryMode; +use crate::ai::ambient_agents::task::{ + normalize_orchestrator_agent_name, HarnessAuthSecretsConfig, HarnessConfig, +}; +use crate::ai::ambient_agents::{ + github_auth_url, OUT_OF_CREDITS_TASK_FAILURE_MESSAGE, SERVER_OVERLOADED_TASK_FAILURE_MESSAGE, +}; +use crate::ai::blocklist::StartAgentRequest; +use crate::ai::skills::{SkillManager, SkillReference}; +use crate::server::server_api::ai::{AgentConfigSnapshot, SpawnAgentRequest}; +use crate::server::server_api::{AIApiError, ClientError, CloudAgentCapacityError}; +use crate::settings::PrivacySettings; +use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::workspace::AdminEnablementSetting; +use crate::ChannelState; + +/// Remote execution fields carried by [`crate::ai::agent::StartAgentExecutionMode`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteChildLaunchConfig { + pub environment_id: String, + pub skill_references: Vec, + pub model_id: String, + pub computer_use_enabled: bool, + pub worker_host: String, + pub harness_type: String, + pub title: String, + pub auth_secret_name: Option, + pub agent_identity_uid: Option, +} + +impl RemoteChildLaunchConfig { + pub fn orchestration_harness(&self) -> Harness { + if self.harness_type.trim().is_empty() { + Harness::Oz + } else { + Harness::parse_orchestration_harness(&self.harness_type).unwrap_or(Harness::Unknown) + } + } +} + +/// Frontend-neutral output used to launch one remote child. +#[derive(Clone, Debug)] +pub struct PreparedRemoteChildLaunch { + pub display_name: String, + pub orchestration_harness: Harness, + pub spawn_request: SpawnAgentRequest, +} + +/// Failure while constructing the remote child request, before calling the server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PrepareRemoteChildLaunchError { + MissingParentRunId, + UnresolvedSkills { references: Vec }, +} + +impl PrepareRemoteChildLaunchError { + pub fn user_message(&self) -> String { + match self { + Self::MissingParentRunId => { + "Remote child agents require the parent run_id to be available.".to_string() + } + Self::UnresolvedSkills { references } => { + format!( + "Failed to resolve child agent skills: {}", + references.join(", ") + ) + } + } + } +} + +/// A recoverable reason a cloud agent could not start. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CloudAgentStartupBlocker { + GitHubAuthRequired { message: String, auth_url: String }, +} + +impl CloudAgentStartupBlocker { + pub fn message(&self) -> &str { + match self { + Self::GitHubAuthRequired { message, .. } => message, + } + } + + pub fn primary_url(&self) -> &str { + match self { + Self::GitHubAuthRequired { auth_url, .. } => auth_url, + } + } +} + +/// A terminal cloud-agent startup failure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CloudAgentStartupFailure { + Capacity { message: String }, + OutOfCredits { message: String }, + ServerOverloaded { message: String }, + Other { message: String }, +} + +impl CloudAgentStartupFailure { + pub fn message(&self) -> &str { + match self { + Self::Capacity { message } + | Self::OutOfCredits { message } + | Self::ServerOverloaded { message } + | Self::Other { message } => message, + } + } +} + +/// Shared interpretation of an error returned while starting a cloud agent. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CloudAgentStartupIssue { + Blocked(CloudAgentStartupBlocker), + Failed(CloudAgentStartupFailure), +} + +/// Builds the public API request for one remote child without owning frontend lifecycle state. +pub fn prepare_remote_child_launch( + request: &StartAgentRequest, + config: RemoteChildLaunchConfig, + ctx: &AppContext, +) -> Result { + let orchestration_harness = config.orchestration_harness(); + let RemoteChildLaunchConfig { + environment_id, + skill_references, + model_id, + computer_use_enabled, + worker_host, + harness_type, + title, + auth_secret_name, + agent_identity_uid, + } = config; + let Some(parent_run_id) = request.parent_run_id.clone() else { + return Err(PrepareRemoteChildLaunchError::MissingParentRunId); + }; + let runtime_skills = resolve_runtime_skills(&skill_references, ctx)?; + let agent_name = normalize_orchestrator_agent_name(&request.name); + let display_name = agent_name.clone().unwrap_or_default(); + let environment_id = Some(environment_id).filter(|id| !id.trim().is_empty()); + let harness_override = if harness_type.is_empty() { + None + } else { + match ::from_str(&harness_type, true) { + Ok(harness) => Some(HarnessConfig::from_harness_type(harness)), + Err(_) => { + log::warn!( + "Unknown harness type from StartAgentV2 proto: {harness_type:?}; omitting harness override so the server picks its default" + ); + None + } + } + }; + let computer_use_enabled = + (orchestration_harness == Harness::Oz).then_some(computer_use_enabled); + let harness_auth_secrets = auth_secret_name + .filter(|name| !name.trim().is_empty()) + .and_then(|name| match orchestration_harness { + Harness::Claude => Some(HarnessAuthSecretsConfig { + claude_auth_secret_name: Some(name), + codex_auth_secret_name: None, + }), + Harness::Codex => Some(HarnessAuthSecretsConfig { + claude_auth_secret_name: None, + codex_auth_secret_name: Some(name), + }), + Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Unknown => None, + }); + let spawn_request = SpawnAgentRequest { + prompt: Some(request.prompt.clone()), + mode: UserQueryMode::Normal, + config: Some(AgentConfigSnapshot { + name: agent_name, + environment_id, + model_id: (!model_id.is_empty()).then_some(model_id), + worker_host: (!worker_host.is_empty()).then_some(worker_host), + computer_use_enabled, + harness: harness_override, + harness_auth_secrets, + ..Default::default() + }), + title: (!title.is_empty()).then_some(title), + team: None, + skill: None, + attachments: Vec::new(), + interactive: Some(true), + parent_run_id: Some(parent_run_id), + runtime_skills, + referenced_attachments: Vec::new(), + conversation_id: None, + initial_snapshot_token: None, + agent_identity_uid: agent_identity_uid.filter(|uid| !uid.trim().is_empty()), + snapshot_disabled: should_disable_snapshot(ctx).then_some(true), + orchestration_handoff: None, + }; + Ok(PreparedRemoteChildLaunch { + display_name, + orchestration_harness, + spawn_request, + }) +} + +/// Maps server/client launch failures into shared startup presentation. +pub fn classify_cloud_agent_startup_error(error: &anyhow::Error) -> CloudAgentStartupIssue { + if let Some(client_error) = error.downcast_ref::() { + if let Some(auth_url) = &client_error.auth_url { + return CloudAgentStartupIssue::Blocked(CloudAgentStartupBlocker::GitHubAuthRequired { + message: client_error.error.clone(), + auth_url: github_auth_url::cloud_setup_auth_url_with_next(auth_url), + }); + } + } + if let Some(capacity_error) = error.downcast_ref::() { + return CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::Capacity { + message: capacity_error.error.clone(), + }); + } + if let Some(ai_api_error) = error.downcast_ref::() { + match ai_api_error { + AIApiError::QuotaLimit { + user_display_message, + } => { + return CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::OutOfCredits { + message: user_display_message + .clone() + .unwrap_or_else(|| OUT_OF_CREDITS_TASK_FAILURE_MESSAGE.to_string()), + }); + } + AIApiError::ServerOverloaded => { + return CloudAgentStartupIssue::Failed( + CloudAgentStartupFailure::ServerOverloaded { + message: SERVER_OVERLOADED_TASK_FAILURE_MESSAGE.to_string(), + }, + ); + } + AIApiError::Transport(_) + | AIApiError::Deserialization(_) + | AIApiError::NoContextFound + | AIApiError::ErrorStatus(_, _) + | AIApiError::Other(_) + | AIApiError::Stream { .. } + | AIApiError::UnexpectedEof + | AIApiError::GrokSubscriptionTokenRefreshFailed => {} + } + } + CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::Other { + message: error.to_string(), + }) +} + +pub(crate) fn should_disable_snapshot(ctx: &AppContext) -> bool { + let privacy = PrivacySettings::as_ref(ctx); + if !privacy.is_cloud_conversation_storage_enabled { + return true; + } + matches!( + UserWorkspaces::as_ref(ctx).get_cloud_conversation_storage_enablement_setting(), + AdminEnablementSetting::Disable + ) +} + +/// Builds the Oz web URL for a server-assigned agent run ID. +pub fn oz_run_url(run_id: &str) -> String { + format!("{}/runs/{run_id}", ChannelState::oz_root_url()) +} + +fn resolve_runtime_skills( + skill_references: &[SkillReference], + ctx: &AppContext, +) -> Result, PrepareRemoteChildLaunchError> { + let skill_manager = SkillManager::as_ref(ctx); + let mut runtime_skills = Vec::with_capacity(skill_references.len()); + let mut unresolved_references = Vec::new(); + for reference in skill_references { + let Some(skill) = skill_manager.active_skill_by_reference(reference, ctx) else { + unresolved_references.push(reference.to_string()); + continue; + }; + runtime_skills.push( + BASE64_STANDARD.encode(multi_agent_api::Skill::from(skill.clone()).encode_to_vec()), + ); + } + if unresolved_references.is_empty() { + Ok(runtime_skills) + } else { + Err(PrepareRemoteChildLaunchError::UnresolvedSkills { + references: unresolved_references, + }) + } +} + +#[cfg(test)] +#[path = "remote_child_tests.rs"] +mod tests; diff --git a/app/src/ai/orchestration/remote_child_tests.rs b/app/src/ai/orchestration/remote_child_tests.rs new file mode 100644 index 00000000000..79fa1400552 --- /dev/null +++ b/app/src/ai/orchestration/remote_child_tests.rs @@ -0,0 +1,149 @@ +use anyhow::anyhow; +use warpui::App; + +use super::{ + classify_cloud_agent_startup_error, prepare_remote_child_launch, CloudAgentStartupBlocker, + CloudAgentStartupFailure, CloudAgentStartupIssue, RemoteChildLaunchConfig, +}; +use crate::ai::agent::{StartAgentExecutionMode, UserQueryMode}; +use crate::ai::blocklist::StartAgentRequest; +use crate::server::server_api::{AIApiError, ClientError, CloudAgentCapacityError}; + +fn config(harness_type: &str) -> RemoteChildLaunchConfig { + RemoteChildLaunchConfig { + environment_id: String::new(), + skill_references: Vec::new(), + model_id: String::new(), + computer_use_enabled: false, + worker_host: String::new(), + harness_type: harness_type.to_string(), + title: String::new(), + auth_secret_name: None, + agent_identity_uid: None, + } +} + +#[test] +fn orchestration_harness_defaults_to_oz_and_parses_known_harnesses() { + assert_eq!( + config("").orchestration_harness(), + warp_cli::agent::Harness::Oz + ); + assert_eq!( + config("claude").orchestration_harness(), + warp_cli::agent::Harness::Claude + ); +} + +#[test] +fn prepared_remote_request_matches_gui_wire_semantics() { + App::test((), |mut app| async move { + crate::tui_test_support::register_tui_session_view_test_singletons(&mut app); + let request = StartAgentRequest { + id: Default::default(), + name: " researcher ".to_string(), + prompt: "Inspect the code".to_string(), + execution_mode: StartAgentExecutionMode::Remote { + environment_id: "env-1".to_string(), + skill_references: Vec::new(), + model_id: "auto".to_string(), + computer_use_enabled: true, + worker_host: "warp".to_string(), + harness_type: "oz".to_string(), + title: "Research".to_string(), + auth_secret_name: None, + agent_identity_uid: None, + }, + lifecycle_subscription: None, + parent_conversation_id: crate::ai::agent::conversation::AIConversationId::new(), + parent_run_id: Some("parent-run".to_string()), + }; + app.read(|ctx| { + let prepared = prepare_remote_child_launch( + &request, + RemoteChildLaunchConfig { + environment_id: "env-1".to_string(), + skill_references: Vec::new(), + model_id: "auto".to_string(), + computer_use_enabled: true, + worker_host: "warp".to_string(), + harness_type: "oz".to_string(), + title: "Research".to_string(), + auth_secret_name: None, + agent_identity_uid: Some("researcher-agent".to_string()), + }, + ctx, + ) + .unwrap(); + assert_eq!(prepared.display_name, "researcher"); + assert_eq!( + prepared.spawn_request.prompt.as_deref(), + Some("Inspect the code") + ); + assert_eq!(prepared.spawn_request.mode, UserQueryMode::Normal); + assert_eq!( + prepared.spawn_request.parent_run_id.as_deref(), + Some("parent-run") + ); + assert_eq!( + prepared.spawn_request.agent_identity_uid.as_deref(), + Some("researcher-agent") + ); + let config = prepared.spawn_request.config.unwrap(); + assert_eq!(config.environment_id.as_deref(), Some("env-1")); + assert_eq!(config.model_id.as_deref(), Some("auto")); + assert_eq!(config.worker_host.as_deref(), Some("warp")); + assert_eq!(config.computer_use_enabled, Some(true)); + }); + }); +} + +#[test] +fn github_auth_error_is_a_shared_blocker_with_cloud_callback_url() { + let error = anyhow::Error::new(ClientError { + error: "GitHub authentication required".to_string(), + auth_url: Some("https://example.com/auth?scheme=warpdev".to_string()), + }); + let CloudAgentStartupIssue::Blocked(CloudAgentStartupBlocker::GitHubAuthRequired { + message, + auth_url, + }) = classify_cloud_agent_startup_error(&error) + else { + panic!("expected GitHub auth blocker"); + }; + assert_eq!(message, "GitHub authentication required"); + assert!(auth_url.starts_with("https://example.com/auth?")); + assert!(auth_url.contains("next=")); +} + +#[test] +fn capacity_quota_and_fallback_errors_keep_their_semantics() { + let capacity = anyhow::Error::new(CloudAgentCapacityError { + error: "Too many agents".to_string(), + running_agents: 4, + }); + assert_eq!( + classify_cloud_agent_startup_error(&capacity), + CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::Capacity { + message: "Too many agents".to_string(), + }) + ); + + let quota = anyhow::Error::new(AIApiError::QuotaLimit { + user_display_message: Some("Buy more credits".to_string()), + }); + assert_eq!( + classify_cloud_agent_startup_error("a), + CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::OutOfCredits { + message: "Buy more credits".to_string(), + }) + ); + + let fallback = anyhow!("network unavailable"); + assert_eq!( + classify_cloud_agent_startup_error(&fallback), + CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::Other { + message: "network unavailable".to_string(), + }) + ); +} diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 963b276c050..0dcbca8f999 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -3,15 +3,12 @@ use std::collections::HashMap; use std::sync::mpsc::SyncSender; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use base64::Engine as _; #[cfg(not(target_family = "wasm"))] use session_sharing_protocol::sharer::SessionSourceType; use url::Url; use warp_cli::agent::Harness; use warp_core::execution_mode::AppExecutionMode; use warp_errors::report_error; -use warp_multi_agent_api as multi_agent_api; use warpui::{ AppContext, EntityId, ModelHandle, SingletonEntity, ViewContext, ViewHandle, WindowId, }; @@ -25,8 +22,8 @@ use super::{ use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::{RenderableAIError, StartAgentExecutionMode}; -use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, HarnessConfig}; -use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}; +use crate::ai::ambient_agents::task::normalize_orchestrator_agent_name; +use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; #[cfg(feature = "local_fs")] @@ -36,7 +33,7 @@ use crate::ai::blocklist::{apply_child_agent_model_override, prepare_local_oz_ch use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; use crate::ai::conversation_utils; use crate::ai::llms::LLMPreferences; -use crate::ai::skills::SkillManager; +use crate::ai::orchestration::{prepare_remote_child_launch, RemoteChildLaunchConfig}; use crate::app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}; use crate::code::buffer_location::LocalOrRemotePath; use crate::pane_group::child_agent::{ @@ -47,7 +44,6 @@ use crate::pane_group::CodeSource; use crate::pane_group::Event::OpenConversationHistory; use crate::pane_group::{self, Direction, PaneGroup}; use crate::persistence::{BlockCompleted, ModelEvent}; -use crate::server::server_api::ai::{SpawnAgentRequest, UserQueryMode}; #[cfg(not(target_family = "wasm"))] use crate::server::server_api::ServerApiProvider; use crate::session_management::SessionNavigationData; @@ -58,7 +54,6 @@ use crate::terminal::shared_session::role_change_modal::RoleChangeOpenSource; #[cfg(not(target_family = "wasm"))] use crate::terminal::shared_session::SharedSessionSource; use crate::terminal::shared_session::{join_link, SharedSessionStatus}; -use crate::terminal::view::ambient_agent::should_disable_snapshot; use crate::terminal::view::Event; use crate::terminal::{TerminalManager, TerminalView}; use crate::view_components::ToastFlavor; @@ -95,35 +90,6 @@ pub struct TerminalPane { view: ViewHandle, } -fn resolve_runtime_skills( - skill_references: &[ai::skills::SkillReference], - ctx: &AppContext, -) -> Result, Vec> { - let skill_manager = SkillManager::as_ref(ctx); - let mut runtime_skills = Vec::with_capacity(skill_references.len()); - let mut unresolved_references = Vec::new(); - - for reference in skill_references { - let Some(skill) = skill_manager.active_skill_by_reference(reference, ctx) else { - unresolved_references.push(reference.to_string()); - continue; - }; - runtime_skills.push(serialize_proto_to_base64(&multi_agent_api::Skill::from( - skill.clone(), - ))); - } - - if unresolved_references.is_empty() { - Ok(runtime_skills) - } else { - Err(unresolved_references) - } -} - -fn serialize_proto_to_base64(message: &M) -> String { - BASE64_STANDARD.encode(message.encode_to_vec()) -} - /// Returns the host terminal's `SharedSessionSource`, or `None` if it is /// not currently a shared-session creator. Reads the underlying /// `TerminalModel` directly via the host's `TerminalView`. @@ -1586,7 +1552,7 @@ fn dispatch_start_agent_conversation( group, parent_pane_id, request, - RemoteLaunchFields { + RemoteChildLaunchConfig { environment_id, skill_references, model_id, @@ -1880,25 +1846,6 @@ fn launch_local_harness_child( ); } -/// Fields destructured from `StartAgentExecutionMode::Remote` so they can be -/// passed through to [`launch_remote_child`] as a single argument cluster. -struct RemoteLaunchFields { - environment_id: String, - skill_references: Vec, - model_id: String, - computer_use_enabled: bool, - worker_host: String, - harness_type: String, - title: String, - /// Managed-secret name forwarded from the orchestration UI for non-Oz - /// harness credentials. Resolved to `AgentConfigSnapshot.harness_auth_secrets` - /// when applicable. - auth_secret_name: Option, - /// UID of the named agent (service account) the remote child should - /// execute as; forwarded to `SpawnAgentRequest.agent_identity_uid`. - agent_identity_uid: Option, -} - /// Sets up a hidden ambient-agent pane for a Remote child agent: creates the /// child conversation, marks it as remote, resolves runtime skills (silently /// bailing with a status update on resolution failure), constructs the @@ -1916,37 +1863,21 @@ fn launch_remote_child( group: &mut PaneGroup, parent_pane_id: PaneId, request: StartAgentRequest, - fields: RemoteLaunchFields, + config: RemoteChildLaunchConfig, ctx: &mut ViewContext, ) -> Option { - let RemoteLaunchFields { - environment_id, - skill_references, - model_id, - computer_use_enabled, - worker_host, - harness_type, - title, - auth_secret_name, - agent_identity_uid, - } = fields; - let request_id = request.id; - let orchestration_harness = if harness_type.trim().is_empty() { - Harness::Oz - } else { - Harness::parse_orchestration_harness(&harness_type).unwrap_or(Harness::Unknown) - }; - let Some(parent_run_id) = request.parent_run_id.clone() else { + if request.parent_run_id.is_none() { report_error!( "Remote StartAgent request missing parent_run_id", extra: { "parent_conversation_id" => ?request.parent_conversation_id } ); return None; - }; + } let agent_name = normalize_orchestrator_agent_name(&request.name); let request_name = agent_name.clone().unwrap_or_default(); + let orchestration_harness = config.orchestration_harness(); let new_pane_id = group.insert_ambient_agent_pane_hidden_for_child_agent(parent_pane_id, ctx); @@ -1975,18 +1906,15 @@ fn launch_remote_child( model.record_new_conversation_request_complete(request_id, conversation_id, ctx); }); - let runtime_skills = match resolve_runtime_skills(&skill_references, ctx) { - Ok(runtime_skills) => runtime_skills, - Err(unresolved_references) => { - let error_message = format!( - "Failed to resolve child agent skills: {}", - unresolved_references.join(", ") - ); + let prepared = match prepare_remote_child_launch(&request, config, ctx) { + Ok(prepared) => prepared, + Err(error) => { + let error_message = error.user_message(); report_error!( - "Failed to resolve StartAgentV2 skill references for remote child", + "Failed to prepare StartAgentV2 remote child launch", extra: { "conversation_id" => ?conversation_id, - "unresolved_references" => %unresolved_references.join(", ") + "error" => %error_message } ); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { @@ -2002,79 +1930,6 @@ fn launch_remote_child( } }; - // Treat an empty environment_id as "no environment specified" so the - // spawn request leaves the config.environment_id field unset. The - // server's StartAgent producer defaults to the parent's environment - // when available, so an empty value here means the caller explicitly - // opted into running with an empty environment. - let environment_id = Some(environment_id).filter(|s| !s.trim().is_empty()); - // Unrecognized harness types collapse to None so the server picks - // its default, matching the behavior of an empty `harness_type`. - // We deliberately do NOT round-trip `Harness::Unknown` to the server; - // that variant is for representing server-originated unknowns to the - // user, not for writes. - let harness_override = if harness_type.is_empty() { - None - } else { - match ::from_str(&harness_type, true) { - Ok(harness) => Some(HarnessConfig::from_harness_type(harness)), - Err(_) => { - log::warn!( - "Unknown harness type from StartAgentV2 proto: {harness_type:?}; omitting harness override so the server picks its default" - ); - None - } - } - }; - let computer_use_enabled = - (orchestration_harness == Harness::Oz).then_some(computer_use_enabled); - // Map the run-wide auth secret name into the harness-specific - // config variant. For unsupported harnesses (Oz, OpenCode, Gemini, - // Unknown), the secret is silently ignored — those harnesses either - // use Warp's built-in auth (Oz) or don't currently support managed - // secrets via this flow. - let harness_auth_secrets = auth_secret_name - .as_ref() - .filter(|name| !name.trim().is_empty()) - .and_then(|name| match orchestration_harness { - Harness::Claude => Some(crate::ai::ambient_agents::task::HarnessAuthSecretsConfig { - claude_auth_secret_name: Some(name.clone()), - codex_auth_secret_name: None, - }), - Harness::Codex => Some(crate::ai::ambient_agents::task::HarnessAuthSecretsConfig { - claude_auth_secret_name: None, - codex_auth_secret_name: Some(name.clone()), - }), - Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Unknown => None, - }); - let spawn_request = SpawnAgentRequest { - prompt: Some(request.prompt), - mode: UserQueryMode::Normal, - config: Some(AgentConfigSnapshot { - name: agent_name, - environment_id, - model_id: (!model_id.is_empty()).then_some(model_id), - worker_host: (!worker_host.is_empty()).then_some(worker_host), - computer_use_enabled, - harness: harness_override, - harness_auth_secrets, - ..Default::default() - }), - title: (!title.is_empty()).then_some(title), - team: None, - skill: None, - attachments: vec![], - interactive: Some(true), - parent_run_id: Some(parent_run_id), - runtime_skills, - referenced_attachments: vec![], - conversation_id: None, - initial_snapshot_token: None, - agent_identity_uid: agent_identity_uid.filter(|uid| !uid.trim().is_empty()), - snapshot_disabled: should_disable_snapshot(ctx).then_some(true), - orchestration_handoff: None, - }; - new_terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.enter_agent_view( None, @@ -2085,7 +1940,7 @@ fn launch_remote_child( if let Some(ambient_agent_view_model) = terminal_view.ambient_agent_view_model() { ambient_agent_view_model.update(ctx, |model, ctx| { model.set_conversation_id(Some(conversation_id)); - model.spawn_agent_with_request(spawn_request, ctx); + model.spawn_agent_with_request(prepared.spawn_request, ctx); }); } else { report_error!("Remote StartAgent child pane missing ambient agent view model"); diff --git a/app/src/settings/privacy.rs b/app/src/settings/privacy.rs index 20f60a15d6a..81e7b7dc781 100644 --- a/app/src/settings/privacy.rs +++ b/app/src/settings/privacy.rs @@ -307,6 +307,22 @@ impl PrivacySettings { } } + #[cfg(any(test, feature = "test-util"))] + pub fn new_for_test(ctx: &mut ModelContext) -> Self { + Self { + auth_state: AuthStateProvider::as_ref(ctx).get().clone(), + auth_client: ServerApiProvider::as_ref(ctx).get_auth_client(), + is_crash_reporting_enabled: true, + is_telemetry_enabled: true, + is_cloud_conversation_storage_enabled: true, + user_secret_regex_list: CustomSecretRegexList::new(None), + has_initialized_default_secret_regexes: HasInitializedDefaultSecretRegexes::new(None), + is_telemetry_force_enabled: false, + is_enterprise_secret_redaction_enabled: false, + enterprise_secret_regex_list: Vec::new(), + } + } + pub fn is_telemetry_force_enabled(&self) -> bool { self.is_telemetry_force_enabled } diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 9746e0292f8..36e386e32f7 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -36,7 +36,7 @@ use crate::features::FeatureFlag; use crate::persistence::ModelEvent; use crate::send_telemetry_on_executor; use crate::server::telemetry::TelemetryEvent; -use crate::settings::{DebugSettings, PrivacySettings, SshSettings}; +use crate::settings::{DebugSettings, InputModeSettings, PrivacySettings, SshSettings}; use crate::terminal::available_shells::{AvailableShell, AvailableShells}; use crate::terminal::color::List as ColorList; use crate::terminal::event_listener::ChannelEventListener; @@ -47,6 +47,8 @@ use crate::terminal::model::session::Sessions; #[cfg(unix)] use crate::terminal::model::terminal_model::BlockIndex; use crate::terminal::model::terminal_model::ExitReason; +#[cfg(feature = "tui")] +use crate::terminal::model::ObfuscateSecrets; #[cfg(unix)] use crate::terminal::model_events::ModelEvent as TerminalModelEvent; use crate::terminal::model_events::ModelEventDispatcher; @@ -123,6 +125,52 @@ pub struct TerminalSurfaceInit { pub inactive_pty_reads_rx: InactiveReceiver>>, } +#[cfg(feature = "tui")] +impl TerminalSurfaceInit { + /// Creates a deferred cloud-viewer terminal surface without spawning a local PTY. + pub fn new_for_tui_cloud_viewer( + initial_size: Vector2F, + block_spacing: BlockSpacing, + ctx: &mut AppContext, + ) -> Self { + let (wakeups_tx, wakeups_rx) = async_channel::unbounded(); + let (events_tx, events_rx) = async_channel::unbounded(); + let (executor_command_tx, _executor_command_rx) = async_channel::unbounded(); + let (pty_reads_tx, pty_reads_rx) = + async_broadcast::broadcast(PTY_READS_BROADCAST_CHANNEL_SIZE); + let inactive_pty_reads_rx = pty_reads_rx.deactivate(); + let channel_event_proxy = ChannelEventListener::new(wakeups_tx, events_tx, pty_reads_tx); + let sizes = terminal_manager::compute_block_size(initial_size, &block_spacing, ctx); + let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1; + let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); + let is_inverted = input_mode.is_inverted_blocklist(); + let model = TerminalModel::new_for_cloud_mode_shared_session_viewer( + sizes, + terminal_manager::terminal_colors_list(ctx), + channel_event_proxy, + ctx.background_executor().clone(), + block_spacing.show_memory_stats, + honor_ps1, + is_inverted, + ObfuscateSecrets::No, + ); + let colors = model.colors(); + let size_info = model.block_list().size().to_owned(); + let model = Arc::new(FairMutex::new(model)); + let sessions = ctx.add_model(|ctx| Sessions::new(executor_command_tx, ctx)); + let model_events = + ctx.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx)); + Self { + wakeups_rx, + model_events, + model, + sessions, + size_info, + colors, + inactive_pty_reads_rx, + } + } +} #[cfg(any(test, all(feature = "tui", feature = "test-util")))] impl TerminalSurfaceInit { /// Creates mock terminal surface inputs without spawning a PTY. diff --git a/app/src/terminal/view/ambient_agent/mod.rs b/app/src/terminal/view/ambient_agent/mod.rs index b968bda0d92..41f6ee41c3d 100644 --- a/app/src/terminal/view/ambient_agent/mod.rs +++ b/app/src/terminal/view/ambient_agent/mod.rs @@ -29,7 +29,6 @@ pub use host_selector::{ Host, HostSelector, HostSelectorAction, HostSelectorEvent, NakedHeaderButtonTheme, }; pub use loading_screen::{render_cloud_mode_error_screen, render_cloud_mode_loading_screen}; -pub(crate) use model::should_disable_snapshot; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] pub(crate) use model::PendingHandoff; pub use model::{AgentProgress, AmbientAgentViewModel, AmbientAgentViewModelEvent, Status}; diff --git a/app/src/terminal/view/ambient_agent/model.rs b/app/src/terminal/view/ambient_agent/model.rs index d9532286646..2fc16768798 100644 --- a/app/src/terminal/view/ambient_agent/model.rs +++ b/app/src/terminal/view/ambient_agent/model.rs @@ -18,10 +18,7 @@ use crate::ai::ambient_agents::github_auth_notifier::{GitHubAuthEvent, GitHubAut use crate::ai::ambient_agents::spawn::{spawn_task, submit_run_followup, AmbientAgentEvent}; use crate::ai::ambient_agents::task::{HarnessAuthSecretsConfig, HarnessConfig}; use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent; -use crate::ai::ambient_agents::{ - github_auth_url, AgentSource, AmbientAgentTaskId, OUT_OF_CREDITS_TASK_FAILURE_MESSAGE, - SERVER_OVERLOADED_TASK_FAILURE_MESSAGE, -}; +use crate::ai::ambient_agents::{AgentSource, AmbientAgentTaskId}; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::ai::blocklist::handoff::touched_repos::TouchedWorkspace; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] @@ -33,6 +30,10 @@ use crate::ai::execution_profiles::{ }; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::{LLMId, LLMPreferences}; +use crate::ai::orchestration::{ + classify_cloud_agent_startup_error, should_disable_snapshot, CloudAgentStartupBlocker, + CloudAgentStartupFailure, CloudAgentStartupIssue, +}; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; use crate::cloud_object::CloudObjectLookup as _; use crate::server::cloud_objects::update_manager::UpdateManager; @@ -42,14 +43,9 @@ use crate::server::server_api::ai::InitialSnapshotToken; use crate::server::server_api::ai::{ AgentConfigSnapshot, AmbientAgentTaskState, AttachmentInput, SpawnAgentRequest, }; -use crate::server::server_api::{ - AIApiError, ClientError, CloudAgentCapacityError, ServerApiProvider, -}; -use crate::settings::PrivacySettings; +use crate::server::server_api::ServerApiProvider; use crate::terminal::view::ambient_agent::{SetupCommandGroupId, SetupCommandState}; use crate::terminal::CLIAgent; -use crate::workspaces::user_workspaces::UserWorkspaces; -use crate::workspaces::workspace::AdminEnablementSetting; /// Wire prompt substituted for an empty-prompt handoff against an active source /// conversation that also carries uploaded snapshot content. @@ -1518,40 +1514,24 @@ impl AmbientAgentViewModel { ctx ); - if let Some(client_error) = err.downcast_ref::() { - if let Some(auth_url) = &client_error.auth_url { - self.handle_needs_github_auth(auth_url.clone(), client_error.error.clone(), ctx); - return; + match classify_cloud_agent_startup_error(&err) { + CloudAgentStartupIssue::Blocked(CloudAgentStartupBlocker::GitHubAuthRequired { + message, + auth_url, + }) => self.handle_needs_github_auth(auth_url, message, ctx), + CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::Capacity { message }) => { + self.handle_spawn_error(message, ctx); + ctx.emit(AmbientAgentViewModelEvent::ShowCloudAgentCapacityModal); } - } - if let Some(capacity_error) = err.downcast_ref::() { - self.handle_spawn_error(capacity_error.error.clone(), ctx); - ctx.emit(AmbientAgentViewModelEvent::ShowCloudAgentCapacityModal); - return; - } - if let Some(ai_api_error) = err.downcast_ref::() { - match ai_api_error { - AIApiError::QuotaLimit { - user_display_message, - } => { - let error_message = user_display_message - .clone() - .unwrap_or_else(|| OUT_OF_CREDITS_TASK_FAILURE_MESSAGE.to_string()); - self.handle_spawn_error(error_message, ctx); - ctx.emit(AmbientAgentViewModelEvent::ShowAICreditModal); - return; - } - AIApiError::ServerOverloaded => { - self.handle_spawn_error( - SERVER_OVERLOADED_TASK_FAILURE_MESSAGE.to_string(), - ctx, - ); - return; - } - _ => {} + CloudAgentStartupIssue::Failed(CloudAgentStartupFailure::OutOfCredits { message }) => { + self.handle_spawn_error(message, ctx); + ctx.emit(AmbientAgentViewModelEvent::ShowAICreditModal); } + CloudAgentStartupIssue::Failed( + CloudAgentStartupFailure::ServerOverloaded { message } + | CloudAgentStartupFailure::Other { message }, + ) => self.handle_spawn_error(message, ctx), } - self.handle_spawn_error(error_message, ctx); } /// Starts the periodic timer that updates the progress UI while waiting for a session. @@ -1652,7 +1632,7 @@ impl AmbientAgentViewModel { self.status = Status::NeedsGithubAuth { progress, error_message, - auth_url: github_auth_url::cloud_setup_auth_url_with_next(&auth_url), + auth_url, }; self.pending_followup_prompt = None; @@ -1874,17 +1854,6 @@ pub enum AmbientAgentViewModelEvent { RunLifecycleChanged, } -pub(crate) fn should_disable_snapshot(ctx: &AppContext) -> bool { - let privacy = PrivacySettings::as_ref(ctx); - if !privacy.is_cloud_conversation_storage_enabled { - return true; - } - matches!( - UserWorkspaces::as_ref(ctx).get_cloud_conversation_storage_enablement_setting(), - AdminEnablementSetting::Disable - ) -} - impl Entity for AmbientAgentViewModel { type Event = AmbientAgentViewModelEvent; } diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 7b5011843b9..a5c523c5563 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -65,7 +65,8 @@ pub use crate::ai::blocklist::history_model::{ ConversationStatusUpdate, }; pub use crate::ai::blocklist::orchestration_event_streamer::{ - register_agent_event_consumer, unregister_agent_event_consumer, + register_agent_event_consumer, unregister_agent_event_consumer, OrchestrationEventStreamer, + OrchestrationEventStreamerEvent, }; pub use crate::ai::blocklist::orchestration_topology::{ descendant_conversation_ids_in_spawn_order, descendant_conversations_in_pill_order, @@ -102,13 +103,15 @@ pub use crate::ai::harness_availability::{ pub use crate::ai::llms::{LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent}; pub use crate::ai::orchestration::{ accept_disabled_reason_with_auth, api_key_snapshot, auth_secret_selection_required, - empty_env_recommendation_message, environment_snapshot, harness_is_selectable, - harness_snapshot, host_snapshot, location_snapshot, model_snapshot, - persist_environment_selection, persist_host_selection, + classify_cloud_agent_startup_error, empty_env_recommendation_message, environment_snapshot, + harness_is_selectable, harness_snapshot, host_snapshot, location_snapshot, model_snapshot, + oz_run_url, persist_environment_selection, persist_host_selection, prepare_remote_child_launch, resolve_auth_secret_selection_for_harness, resolve_default_environment_id, - resolve_default_host_slug, should_show_auth_secret_picker, AuthSecretSelection, OptionBadge, + resolve_default_host_slug, should_show_auth_secret_picker, AuthSecretSelection, + CloudAgentStartupBlocker, CloudAgentStartupFailure, CloudAgentStartupIssue, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, OrchestrationConfigState, - OrchestrationEditState, ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, + OrchestrationEditState, PrepareRemoteChildLaunchError, PreparedRemoteChildLaunch, + RemoteChildLaunchConfig, ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, }; pub use crate::ai::skills::{SkillManager, SkillReference}; pub use crate::appearance::Appearance; @@ -125,6 +128,10 @@ pub use crate::search::slash_command_menu::static_commands::commands::{ self as slash_commands, COMMAND_REGISTRY, }; pub use crate::search::slash_command_menu::{SlashCommandId, StaticCommand}; +pub use crate::server::server_api::ai::{ + AIClient, AgentConfigSnapshot, SpawnAgentRequest, SpawnAgentResponse, +}; +pub use crate::server::server_api::ServerApiProvider; pub use crate::settings::AISettingsChangedEvent; pub use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll}; pub use crate::terminal::color::{Colors as TerminalColors, List as TerminalColorList}; diff --git a/app/src/tui_test_support.rs b/app/src/tui_test_support.rs index 212441acdf5..5e10bd8763c 100644 --- a/app/src/tui_test_support.rs +++ b/app/src/tui_test_support.rs @@ -25,8 +25,10 @@ use crate::network::NetworkStatus; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; use crate::settings::manager::SettingsManager; -use crate::settings::{init_and_register_user_preferences, AISettings}; +use crate::settings::{init_and_register_user_preferences, AISettings, PrivacySettings}; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::terminal::session_settings::SessionSettings; +use crate::terminal::settings::TerminalSettings; use crate::user_config::WarpConfig; use crate::workspaces::user_workspaces::UserWorkspaces; use crate::LaunchMode; @@ -50,6 +52,7 @@ pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AuthManager::new_for_test); + app.add_singleton_model(PrivacySettings::new_for_test); app.add_singleton_model(|ctx| { let (team_client, workspace_client) = { let provider = ServerApiProvider::as_ref(ctx); @@ -104,6 +107,8 @@ pub fn register_tui_session_view_test_singletons(app: &mut warpui::App) { app.update(crate::settings::ScrollSettings::register); app.update(crate::settings::EmacsBindingsSettings::register); app.update(crate::terminal::general_settings::GeneralSettings::register); + app.update(SessionSettings::register); + app.update(TerminalSettings::register); app.add_singleton_model(|_| repo_metadata::repositories::DetectedRepositories::default()); app.add_singleton_model(watcher::HomeDirectoryWatcher::new_for_test); diff --git a/crates/warp_tui/Cargo.toml b/crates/warp_tui/Cargo.toml index 0808331fe3d..f5aad5481fb 100644 --- a/crates/warp_tui/Cargo.toml +++ b/crates/warp_tui/Cargo.toml @@ -35,6 +35,7 @@ path = "src/bin/stable.rs" [dependencies] ai.workspace = true anyhow.workspace = true +async-broadcast.workspace = true async-channel.workspace = true async-fs.workspace = true base64.workspace = true diff --git a/crates/warp_tui/src/agent_message.rs b/crates/warp_tui/src/agent_message.rs index 3297a8a0a5b..54c94d0d3fe 100644 --- a/crates/warp_tui/src/agent_message.rs +++ b/crates/warp_tui/src/agent_message.rs @@ -25,7 +25,7 @@ struct AgentMessagePresentation { } /// Compact glyph for a conversation's lifecycle status. -fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { +pub(crate) fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { match status { ConversationStatus::InProgress | ConversationStatus::TransientError @@ -37,7 +37,7 @@ fn conversation_status_glyph(status: &ConversationStatus) -> &'static str { } /// Semantic theme style for a conversation's lifecycle glyph. -fn conversation_status_glyph_style( +pub(crate) fn conversation_status_glyph_style( status: &ConversationStatus, builder: &TuiUiBuilder, ) -> TuiStyle { diff --git a/crates/warp_tui/src/cloud_run.rs b/crates/warp_tui/src/cloud_run.rs new file mode 100644 index 00000000000..6fb6e1385ca --- /dev/null +++ b/crates/warp_tui/src/cloud_run.rs @@ -0,0 +1,96 @@ +use warp::tui_export::{ + AIConversationId, AmbientAgentTaskId, CloudAgentStartupBlocker, CloudAgentStartupFailure, +}; +use warpui_core::{Entity, ModelContext}; + +/// Startup presentation before shared conversation lifecycle becomes authoritative. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TuiCloudRunStartup { + Dispatching, + Blocked(CloudAgentStartupBlocker), + Failed(CloudAgentStartupFailure), + Spawned, +} + +/// Per-session metadata for a cloud child placeholder. +pub(crate) struct TuiCloudRunState { + conversation_id: Option, + startup: TuiCloudRunStartup, + task_id: Option, + run_id: Option, + run_url: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TuiCloudRunStateEvent { + Updated, +} + +impl TuiCloudRunState { + pub(crate) fn new() -> Self { + Self { + conversation_id: None, + startup: TuiCloudRunStartup::Dispatching, + task_id: None, + run_id: None, + run_url: None, + } + } + + pub(crate) fn conversation_id(&self) -> Option { + self.conversation_id + } + + pub(crate) fn startup(&self) -> &TuiCloudRunStartup { + &self.startup + } + + pub(crate) fn run_url(&self) -> Option<&str> { + self.run_url.as_deref() + } + + pub(crate) fn set_conversation_id( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + self.conversation_id = Some(conversation_id); + ctx.emit(TuiCloudRunStateEvent::Updated); + } + + pub(crate) fn set_blocked( + &mut self, + blocker: CloudAgentStartupBlocker, + ctx: &mut ModelContext, + ) { + self.startup = TuiCloudRunStartup::Blocked(blocker); + ctx.emit(TuiCloudRunStateEvent::Updated); + } + + pub(crate) fn set_failed( + &mut self, + failure: CloudAgentStartupFailure, + ctx: &mut ModelContext, + ) { + self.startup = TuiCloudRunStartup::Failed(failure); + ctx.emit(TuiCloudRunStateEvent::Updated); + } + + pub(crate) fn set_spawned( + &mut self, + task_id: AmbientAgentTaskId, + run_id: String, + run_url: String, + ctx: &mut ModelContext, + ) { + self.task_id = Some(task_id); + self.run_id = Some(run_id); + self.run_url = Some(run_url); + self.startup = TuiCloudRunStartup::Spawned; + ctx.emit(TuiCloudRunStateEvent::Updated); + } +} + +impl Entity for TuiCloudRunState { + type Event = TuiCloudRunStateEvent; +} diff --git a/crates/warp_tui/src/cloud_terminal_manager.rs b/crates/warp_tui/src/cloud_terminal_manager.rs new file mode 100644 index 00000000000..f2b13296986 --- /dev/null +++ b/crates/warp_tui/src/cloud_terminal_manager.rs @@ -0,0 +1,44 @@ +use std::any::Any; +use std::sync::Arc; + +use parking_lot::FairMutex; +use pathfinder_geometry::vector::Vector2F; +use warp::tui_export::{BlockSpacing, TerminalManagerTrait, TerminalModel, TerminalSurfaceInit}; +use warpui_core::AppContext; + +/// Retains the PTY-less terminal model for a deferred cloud session. +pub(crate) struct TuiCloudTerminalManager { + model: Arc>, + _inactive_pty_reads_rx: async_broadcast::InactiveReceiver>>, +} + +impl TuiCloudTerminalManager { + /// Creates the manager and surface inputs before a shared session exists. + pub(crate) fn new( + initial_size: Vector2F, + block_spacing: BlockSpacing, + ctx: &mut AppContext, + ) -> (Self, TerminalSurfaceInit) { + let surface_init = + TerminalSurfaceInit::new_for_tui_cloud_viewer(initial_size, block_spacing, ctx); + let manager = Self { + model: surface_init.model.clone(), + _inactive_pty_reads_rx: surface_init.inactive_pty_reads_rx.clone(), + }; + (manager, surface_init) + } +} + +impl TerminalManagerTrait for TuiCloudTerminalManager { + fn model(&self) -> Arc> { + self.model.clone() + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index 495ba596913..1c9b4b5d6ad 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -13,6 +13,8 @@ mod agent_message; mod alt_screen_view; mod autoupdate; mod clipboard; +mod cloud_run; +mod cloud_terminal_manager; pub mod input; pub mod root_view; pub mod session; @@ -31,6 +33,7 @@ mod inline_menu; mod input_mode_policy; mod input_suggestions_mode; mod keybindings; +mod link; mod mcp_menu; mod model_menu; mod option_selector; diff --git a/crates/warp_tui/src/link.rs b/crates/warp_tui/src/link.rs new file mode 100644 index 00000000000..a3fe910f846 --- /dev/null +++ b/crates/warp_tui/src/link.rs @@ -0,0 +1,47 @@ +use warpui_core::elements::tui::{Modifier, TuiElement, TuiEventContext, TuiHoverable, TuiText}; +use warpui_core::elements::MouseStateHandle; +use warpui_core::AppContext; + +use crate::tui_builder::TuiUiBuilder; + +/// Reusable link presentation with persistent hover state. +#[derive(Clone, Default)] +pub(crate) struct TuiLink { + hover_state: MouseStateHandle, +} + +impl TuiLink { + /// Renders caller-provided link text and invokes `on_open` on click. + pub(crate) fn render( + &self, + label: impl Into, + app: &AppContext, + on_open: impl FnMut(&mut TuiEventContext, &AppContext) + 'static, + ) -> Box { + let builder = TuiUiBuilder::from_app(app); + let is_hovered = self + .hover_state + .lock() + .is_ok_and(|state| state.is_hovered()); + let style = if is_hovered { + builder + .primary_text_style() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED) + } else { + builder + .primary_text_style() + .add_modifier(Modifier::UNDERLINED) + }; + TuiHoverable::new( + self.hover_state.clone(), + TuiText::new(label.into()).with_style(style).finish(), + ) + .on_click(on_open) + .finish() + } +} + +#[cfg(test)] +#[path = "link_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/link_tests.rs b/crates/warp_tui/src/link_tests.rs new file mode 100644 index 00000000000..b631974b681 --- /dev/null +++ b/crates/warp_tui/src/link_tests.rs @@ -0,0 +1,24 @@ +use warp::tui_export::Appearance; +use warpui_core::elements::tui::{Modifier, TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::App; + +use super::TuiLink; + +#[test] +fn link_renders_visible_underlined_text() { + App::test((), |app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.read(|ctx| { + let link = TuiLink::default(); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + link.render("https://example.com/run", ctx, |_, _| {}), + TuiRect::new(0, 0, 40, 1), + ctx, + ); + assert!(frame.buffer.to_lines()[0].starts_with("https://example.com/run")); + assert!(frame.buffer[(0, 0)].modifier.contains(Modifier::UNDERLINED)); + }); + }); +} diff --git a/crates/warp_tui/src/orchestration_model.rs b/crates/warp_tui/src/orchestration_model.rs index e8df4b5f455..144330af046 100644 --- a/crates/warp_tui/src/orchestration_model.rs +++ b/crates/warp_tui/src/orchestration_model.rs @@ -10,22 +10,26 @@ //! 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. +//! Native local and remote Oz children run in retained TUI sessions. Local +//! CLI-harness requests resolve with an explicit failure. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use warp::tui_export::{ - 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, + apply_child_agent_model_override, classify_cloud_agent_startup_error, + descendant_conversations_in_pill_order, inherit_child_agent_settings, + orchestration_root_conversation_id, oz_run_url, prepare_local_oz_child_launch, + prepare_remote_child_launch, register_agent_event_consumer, unregister_agent_event_consumer, + AIConversationId, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, CloudAgentStartupIssue, + ConversationStatus, Harness, OrchestrationEventStreamer, OrchestrationEventStreamerEvent, + PreparedRemoteChildLaunch, RemoteChildLaunchConfig, RenderableAIError, ServerApiProvider, + StartAgentExecutionMode, StartAgentRequest, }; use warpui::SingletonEntity; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle}; +use crate::cloud_run::TuiCloudRunState; use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::terminal_session_view::TuiTerminalSessionView; @@ -35,6 +39,7 @@ pub(crate) struct TuiOrchestrationTab { pub(crate) conversation_id: AIConversationId, pub(crate) label: String, pub(crate) spawn_index: usize, + pub(crate) status: ConversationStatus, } /// Live semantic state for the orchestration tab bar. @@ -69,6 +74,11 @@ pub(crate) enum TuiOrchestrationEvent { task_id: warp::tui_export::AmbientAgentTaskId, conversation_name: String, }, + CreateRemoteChildSession { + parent_session_id: TuiSessionId, + request: Box, + prepared: Box, + }, RemoveChildSession(TuiSessionId), } @@ -81,6 +91,13 @@ pub(crate) struct MaterializedLocalOzChildSession { pub(crate) task_id: warp::tui_export::AmbientAgentTaskId, pub(crate) conversation_name: String, } +pub(crate) struct MaterializedRemoteChildSession { + pub(crate) session_id: TuiSessionId, + pub(crate) session_view: ViewHandle, + pub(crate) cloud_run_state: ModelHandle, + pub(crate) request: StartAgentRequest, + pub(crate) prepared: PreparedRemoteChildLaunch, +} impl Entity for TuiOrchestrationModel { type Event = TuiOrchestrationEvent; @@ -92,6 +109,7 @@ impl TuiOrchestrationModel { /// Registers the singleton before sessions are created and wired to it. pub(crate) fn register(ctx: &mut AppContext) -> ModelHandle { let history = BlocklistAIHistoryModel::handle(ctx); + let streamer = OrchestrationEventStreamer::handle(ctx); let model = ctx.add_singleton_model(|_| Self { child_session_by_conversation: HashMap::new(), event_consumers_by_session: HashMap::new(), @@ -135,6 +153,12 @@ impl TuiOrchestrationModel { model_for_history.update(ctx, |model, ctx| model.topology_changed(ctx)); } }); + let model_for_streamer = model.clone(); + ctx.subscribe_to_model(&streamer, move |_, event, ctx| { + model_for_streamer.update(ctx, |model, ctx| { + model.handle_streamer_event(event, ctx); + }); + }); model } @@ -165,6 +189,7 @@ impl TuiOrchestrationModel { .unwrap_or("Agent") .to_owned(), spawn_index: descendant.spawn_index, + status: conversation.status().clone(), }) }) .collect::>(); @@ -267,18 +292,306 @@ impl TuiOrchestrationModel { ctx, ); } - StartAgentExecutionMode::Remote { .. } => { - // TODO(code-1822): remote children need a TUI materializer; - // the GUI's spawn path is coupled to ambient-agent panes. - self.fail_child_request( - &request, - "Cloud child agents aren't supported in the Warp TUI yet.".to_string(), + StartAgentExecutionMode::Remote { + environment_id, + skill_references, + model_id, + computer_use_enabled, + worker_host, + harness_type, + title, + auth_secret_name, + agent_identity_uid, + } => { + self.register_event_consumer( + parent_session_id, + request.parent_conversation_id, + ctx, + ); + self.launch_remote_child( + parent_session_id, + request, + RemoteChildLaunchConfig { + environment_id, + skill_references, + model_id, + computer_use_enabled, + worker_host, + harness_type, + title, + auth_secret_name, + agent_identity_uid, + }, ctx, ); } } } + fn launch_remote_child( + &mut self, + parent_session_id: TuiSessionId, + request: StartAgentRequest, + config: RemoteChildLaunchConfig, + ctx: &mut ModelContext, + ) { + let prepared = match prepare_remote_child_launch(&request, config, ctx) { + Ok(prepared) => prepared, + Err(error) => { + self.fail_child_request(&request, error.user_message(), ctx); + return; + } + }; + ctx.emit(TuiOrchestrationEvent::CreateRemoteChildSession { + parent_session_id, + request: Box::new(request), + prepared: Box::new(prepared), + }); + } + + pub(crate) fn register_remote_child_session( + &mut self, + materialized: MaterializedRemoteChildSession, + ctx: &mut ModelContext, + ) -> (AIConversationId, EntityId, ModelHandle) { + let MaterializedRemoteChildSession { + session_id, + session_view, + cloud_run_state, + request, + prepared, + } = materialized; + let PreparedRemoteChildLaunch { + display_name, + orchestration_harness, + spawn_request, + } = prepared; + let (conversation_id, child_surface_id, cloud_run_state) = self + .register_remote_child_state( + session_id, + session_view, + cloud_run_state, + &request, + display_name, + orchestration_harness, + ctx, + ); + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + let cloud_run_state_for_launch = cloud_run_state.clone(); + ctx.spawn( + async move { ai_client.spawn_agent(spawn_request).await }, + move |me, result, ctx| { + me.finish_remote_child_launch( + conversation_id, + child_surface_id, + cloud_run_state_for_launch, + result, + ctx, + ); + }, + ); + (conversation_id, child_surface_id, cloud_run_state) + } + + fn register_remote_child_state( + &mut self, + session_id: TuiSessionId, + session_view: ViewHandle, + cloud_run_state: ModelHandle, + request: &StartAgentRequest, + display_name: String, + orchestration_harness: Harness, + ctx: &mut ModelContext, + ) -> (AIConversationId, EntityId, ModelHandle) { + let child_surface_id = session_id.surface_id(); + let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + let conversation_id = history.start_new_child_conversation( + child_surface_id, + display_name, + request.parent_conversation_id, + Some(orchestration_harness), + ctx, + ); + history.mark_conversation_as_remote_child(conversation_id, ctx); + history.set_active_conversation_id(conversation_id, child_surface_id, ctx); + history.record_new_conversation_request_complete(request.id, conversation_id, ctx); + conversation_id + }); + cloud_run_state.update(ctx, |state, ctx| { + state.set_conversation_id(conversation_id, ctx); + }); + session_view.update(ctx, |view, ctx| { + view.initialize_orchestrated_child_conversation(conversation_id, ctx); + }); + self.child_session_by_conversation + .insert(conversation_id, session_id); + ctx.notify(); + (conversation_id, child_surface_id, cloud_run_state) + } + + #[cfg(test)] + fn materialize_remote_child( + &mut self, + request: &StartAgentRequest, + display_name: String, + orchestration_harness: Harness, + ctx: &mut ModelContext, + ) -> (AIConversationId, EntityId, ModelHandle) { + let sessions = TuiSessions::handle(ctx); + let window_id = { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let parent_surface_id = history + .terminal_surface_id_for_conversation(&request.parent_conversation_id) + .expect("the test parent conversation has a terminal surface"); + let parent_session_id = TuiSessions::as_ref(ctx) + .session_id_for_surface(parent_surface_id) + .expect("the test parent conversation has a retained session"); + TuiSessions::as_ref(ctx) + .session(parent_session_id) + .expect("the test parent session remains registered") + .view() + .window_id(ctx) + }; + let cloud_run_state = ctx.add_model(|_| TuiCloudRunState::new()); + let (session_id, session_view) = TuiSessions::create_cloud_terminal_session( + &sessions, + window_id, + cloud_run_state.clone(), + false, + ctx, + ); + self.register_remote_child_state( + session_id, + session_view, + cloud_run_state, + request, + display_name, + orchestration_harness, + ctx, + ) + } + + fn finish_remote_child_launch( + &mut self, + conversation_id: AIConversationId, + child_surface_id: EntityId, + cloud_run_state: ModelHandle, + result: anyhow::Result, + ctx: &mut ModelContext, + ) { + match result { + Ok(response) => { + let run_url = oz_run_url(&response.run_id); + cloud_run_state.update(ctx, |state, ctx| { + state.set_spawned(response.task_id, response.run_id.clone(), run_url, ctx); + }); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.assign_run_id_for_conversation( + conversation_id, + response.run_id, + Some(response.task_id), + child_surface_id, + ctx, + ); + }); + } + Err(error) => self.apply_remote_child_startup_issue( + conversation_id, + child_surface_id, + cloud_run_state, + classify_cloud_agent_startup_error(&error), + ctx, + ), + } + } + + fn apply_remote_child_startup_issue( + &mut self, + conversation_id: AIConversationId, + child_surface_id: EntityId, + cloud_run_state: ModelHandle, + issue: CloudAgentStartupIssue, + ctx: &mut ModelContext, + ) { + match issue { + CloudAgentStartupIssue::Blocked(blocker) => { + let message = blocker.message().to_string(); + cloud_run_state.update(ctx, |state, ctx| { + state.set_blocked(blocker, ctx); + }); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.update_conversation_status( + child_surface_id, + conversation_id, + ConversationStatus::Blocked { + blocked_action: message, + }, + ctx, + ); + }); + } + CloudAgentStartupIssue::Failed(failure) => { + let message = failure.message().to_string(); + cloud_run_state.update(ctx, |state, ctx| { + state.set_failed(failure, ctx); + }); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.update_conversation_status_with_error( + child_surface_id, + conversation_id, + ConversationStatus::Error, + Some(RenderableAIError::other(message, false)), + ctx, + ); + }); + } + } + } + + fn handle_streamer_event( + &mut self, + event: &OrchestrationEventStreamerEvent, + ctx: &mut ModelContext, + ) { + let OrchestrationEventStreamerEvent::WatchedRunStatusChanged { + owner_conversation_id, + run_id, + status, + } = event + else { + return; + }; + let child = { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let Some(conversation_id) = history.conversation_id_for_agent_id(run_id) else { + return; + }; + let Some(conversation) = history.conversation(&conversation_id) else { + return; + }; + let parent_matches = history + .resolved_parent_conversation_id_for_conversation(conversation) + == Some(*owner_conversation_id); + if !conversation.is_remote_child() || !parent_matches { + return; + } + let Some(surface_id) = history.terminal_surface_id_for_conversation(&conversation_id) + else { + return; + }; + if TuiSessions::as_ref(ctx) + .session_id_for_surface(surface_id) + .is_none() + { + return; + } + (conversation_id, surface_id) + }; + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.update_conversation_status(child.1, child.0, status.clone(), ctx); + }); + } + /// Starts server-side task creation. The completion callback creates the /// TUI session only after the task has a stable run id. fn begin_local_oz_child_launch( diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 79da2020394..bcb6ee36554 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -1,12 +1,17 @@ use warp::tui_export::{ - register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, Harness, - StartAgentExecutionMode, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome, + register_tui_session_view_test_singletons, AIConversationId, BlocklistAIHistoryModel, + CloudAgentStartupBlocker, CloudAgentStartupIssue, ConversationStatus, Harness, + OrchestrationEventStreamerEvent, StartAgentExecutionMode, StartAgentExecutor, + StartAgentExecutorEvent, StartAgentOutcome, StartAgentRequest, }; use warpui::platform::WindowStyle; use warpui::{AddWindowOptions, ModelHandle, ReadModel, SingletonEntity as _, UpdateModel}; -use warpui_core::{App, WindowId}; +use warpui_core::elements::tui::{TuiBufferExt, TuiRect}; +use warpui_core::presenter::tui::TuiPresenter; +use warpui_core::{App, TuiView as _, WindowId}; use super::TuiOrchestrationModel; +use crate::cloud_run::TuiCloudRunStartup; use crate::root_view::RootTuiView; use crate::session_registry::{TuiSessionId, TuiSessions}; use crate::test_fixtures::{add_test_semantic_selection, add_test_terminal_session}; @@ -16,6 +21,28 @@ struct OrchestrationFixture { window_id: WindowId, } +fn remote_request(parent_conversation_id: AIConversationId) -> StartAgentRequest { + StartAgentRequest { + id: Default::default(), + name: "cloud-researcher".to_string(), + prompt: "research the codebase".to_string(), + execution_mode: StartAgentExecutionMode::Remote { + environment_id: "env-1".to_string(), + skill_references: Vec::new(), + model_id: "auto".to_string(), + computer_use_enabled: false, + worker_host: "warp".to_string(), + harness_type: "oz".to_string(), + title: "Researcher".to_string(), + auth_secret_name: None, + agent_identity_uid: None, + }, + lifecycle_subscription: None, + parent_conversation_id, + parent_run_id: Some("parent-run-1".to_string()), + } +} + /// Boots the container + root + orchestration model wiring (no live PTYs). fn orchestration_fixture(app: &mut App) -> OrchestrationFixture { register_tui_session_view_test_singletons(app); @@ -191,6 +218,63 @@ fn local_harness_children_fail_cleanly() { }); } +#[test] +fn github_auth_blocker_keeps_the_remote_session_and_actionable_url() { + 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 request = remote_request(parent_conversation_id); + let (conversation_id, surface_id, cloud_run_state) = app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.materialize_remote_child( + &request, + "cloud-researcher".to_string(), + Harness::Oz, + ctx, + ) + }) + }); + app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.apply_remote_child_startup_issue( + conversation_id, + surface_id, + cloud_run_state.clone(), + CloudAgentStartupIssue::Blocked(CloudAgentStartupBlocker::GitHubAuthRequired { + message: "GitHub authentication required".to_string(), + auth_url: "https://example.com/auth".to_string(), + }), + ctx, + ); + }); + }); + app.read(|ctx| { + assert!(TuiSessions::as_ref(ctx) + .session_id_for_surface(surface_id) + .is_some()); + assert_eq!( + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .unwrap() + .status(), + &ConversationStatus::Blocked { + blocked_action: "GitHub authentication required".to_string(), + } + ); + let TuiCloudRunStartup::Blocked(blocker) = cloud_run_state.as_ref(ctx).startup() else { + panic!("expected blocked cloud startup state"); + }; + assert_eq!(blocker.primary_url(), "https://example.com/auth"); + }); + }); +} + #[test] fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { App::test((), |mut app| async move { @@ -293,30 +377,126 @@ fn snapshot_is_shared_across_tree_and_filters_conversations_without_sessions() { } #[test] -fn remote_children_fail_cleanly() { +fn remote_child_materialization_is_navigable_and_projects_lifecycle() { App::test((), |mut app| async move { let fixture = orchestration_fixture(&mut app); - let session_id = add_dispatching_session(&mut app, &fixture, true); - let executor = add_relayed_executor(&mut app, session_id); + 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 request = remote_request(parent_conversation_id); + let (conversation_id, surface_id, cloud_run_state) = app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.materialize_remote_child( + &request, + "cloud-researcher".to_string(), + Harness::Oz, + ctx, + ) + }) + }); + app.read(|ctx| { + let history = BlocklistAIHistoryModel::as_ref(ctx); + let conversation = history.conversation(&conversation_id).unwrap(); + assert!(conversation.is_remote_child()); + assert_eq!( + history.resolved_parent_conversation_id_for_conversation(conversation), + Some(parent_conversation_id) + ); + assert!(TuiSessions::as_ref(ctx) + .session_id_for_surface(surface_id) + .is_some()); + assert!(matches!( + cloud_run_state.as_ref(ctx).startup(), + TuiCloudRunStartup::Dispatching + )); + assert_eq!( + cloud_run_state.as_ref(ctx).conversation_id(), + Some(conversation_id) + ); + let session_id = TuiSessions::as_ref(ctx) + .session_id_for_surface(surface_id) + .unwrap(); + let view = TuiSessions::as_ref(ctx) + .session(session_id) + .unwrap() + .view() + .clone(); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + view.as_ref(ctx).render(ctx), + TuiRect::new(0, 0, 80, 12), + ctx, + ); + assert!(frame + .buffer + .to_lines() + .iter() + .any(|line| line.contains("Starting cloud run…"))); + }); - let (parent_conversation_id, outcome) = dispatch_and_recv( - &mut app, - session_id, - &executor, - StartAgentExecutionMode::Remote { - environment_id: "env-1".to_string(), - skill_references: Vec::new(), - model_id: "auto".to_string(), - computer_use_enabled: false, - worker_host: "warp".to_string(), - harness_type: "oz".to_string(), - title: "Researcher".to_string(), - auth_secret_name: None, - agent_identity_uid: None, - }, - ); - assert_error_containing(outcome, "Cloud child agents aren't supported"); - assert_failed_launch_cleaned_up(&app, &fixture, parent_conversation_id, 1); + app.update(|ctx| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.assign_run_id_for_conversation( + conversation_id, + "00000000-0000-0000-0000-000000000004".to_string(), + None, + surface_id, + ctx, + ); + }); + }); + app.read(|ctx| { + assert_eq!( + BlocklistAIHistoryModel::as_ref(ctx) + .conversation_id_for_agent_id("00000000-0000-0000-0000-000000000004"), + Some(conversation_id) + ); + }); + app.update(|ctx| { + TuiOrchestrationModel::handle(ctx).update(ctx, |model, ctx| { + model.handle_streamer_event( + &OrchestrationEventStreamerEvent::WatchedRunStatusChanged { + owner_conversation_id: parent_conversation_id, + run_id: "00000000-0000-0000-0000-000000000004".to_string(), + status: ConversationStatus::Success, + }, + ctx, + ); + }); + }); + app.read(|ctx| { + assert_eq!( + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .unwrap() + .status(), + &ConversationStatus::Success + ); + let child_session_id = TuiSessions::as_ref(ctx) + .session_id_for_surface(surface_id) + .unwrap(); + let child_view = TuiSessions::as_ref(ctx) + .session(child_session_id) + .unwrap() + .view() + .clone(); + let mut presenter = TuiPresenter::new(); + let frame = presenter.present_element( + child_view.as_ref(ctx).render(ctx), + TuiRect::new(0, 0, 80, 12), + ctx, + ); + let lines = frame.buffer.to_lines(); + let header = lines + .iter() + .find(|line| line.contains("cloud-researcher")) + .expect("orchestration tab should render"); + assert!(header.contains("✓"), "{header}"); + }); }); } diff --git a/crates/warp_tui/src/session_registry.rs b/crates/warp_tui/src/session_registry.rs index 9babe6bf1d5..37cc6e91c70 100644 --- a/crates/warp_tui/src/session_registry.rs +++ b/crates/warp_tui/src/session_registry.rs @@ -16,8 +16,12 @@ use warpui::SingletonEntity; use warpui_core::runtime::TuiDriverHandle; use warpui_core::{AppContext, Entity, EntityId, ModelContext, ModelHandle, ViewHandle, WindowId}; +use crate::cloud_run::TuiCloudRunState; +use crate::cloud_terminal_manager::TuiCloudTerminalManager; + use crate::orchestration_model::{ - MaterializedLocalOzChildSession, TuiOrchestrationEvent, TuiOrchestrationModel, + MaterializedLocalOzChildSession, MaterializedRemoteChildSession, TuiOrchestrationEvent, + TuiOrchestrationModel, }; use crate::resume::TuiExitSummaryHandle; use crate::terminal_session_view::{TuiTerminalSessionEvent, TuiTerminalSessionView}; @@ -133,6 +137,36 @@ impl TuiSessions { Self::register_session(sessions, manager.surface, manager.manager, focus, ctx); (session_id, surface) } + + /// Creates and registers a deferred PTY-less cloud terminal session. + pub(crate) fn create_cloud_terminal_session( + sessions: &ModelHandle, + window_id: WindowId, + cloud_run_state: ModelHandle, + focus: bool, + ctx: &mut AppContext, + ) -> (TuiSessionId, ViewHandle) { + let (exit_summary, keyboard_enhancement_supported) = sessions.read(ctx, |sessions, _| { + ( + sessions.exit_summary.clone(), + sessions.keyboard_enhancement_supported, + ) + }); + let (manager, surface_init) = + TuiCloudTerminalManager::new(Vector2F::new(120., 24.), TRANSCRIPT_BLOCK_SPACING, ctx); + let surface = ctx.add_typed_action_tui_view(window_id, |ctx| { + TuiTerminalSessionView::new_cloud( + surface_init, + cloud_run_state, + exit_summary, + keyboard_enhancement_supported, + ctx, + ) + }); + let manager = ctx.add_model(|_| Box::new(manager) as Box); + let session_id = Self::register_session(sessions, surface.clone(), manager, focus, ctx); + (session_id, surface) + } /// Wires a session view to orchestration before registering it. pub(crate) fn register_session( sessions: &ModelHandle, @@ -261,6 +295,38 @@ impl TuiSessions { ); }); } + TuiOrchestrationEvent::CreateRemoteChildSession { + parent_session_id, + request, + prepared, + } => { + let window_id = sessions + .as_ref(ctx) + .session(*parent_session_id) + .expect("the dispatching parent session must remain registered") + .view() + .window_id(ctx); + let cloud_run_state = ctx.add_model(|_| TuiCloudRunState::new()); + let (session_id, session_view) = Self::create_cloud_terminal_session( + &sessions, + window_id, + cloud_run_state.clone(), + false, + ctx, + ); + orchestration_for_events.update(ctx, |orchestration, ctx| { + orchestration.register_remote_child_session( + MaterializedRemoteChildSession { + session_id, + session_view, + cloud_run_state, + request: (**request).clone(), + prepared: (**prepared).clone(), + }, + ctx, + ); + }); + } TuiOrchestrationEvent::RemoveChildSession(session_id) => { sessions.update(ctx, |sessions, ctx| { sessions.remove_session(*session_id, ctx); @@ -353,6 +419,12 @@ impl TuiSessions { 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_map(|session| (session.id.surface_id() == surface_id).then_some(session.id)) + } /// Builds the loaded conversation-to-session index used by one topology snapshot. pub(crate) fn session_ids_by_conversation( &self, diff --git a/crates/warp_tui/src/tab_bar.rs b/crates/warp_tui/src/tab_bar.rs index 4a97d5c59cc..efc024f98af 100644 --- a/crates/warp_tui/src/tab_bar.rs +++ b/crates/warp_tui/src/tab_bar.rs @@ -28,7 +28,7 @@ const ELLIPSIS: &str = "..."; pub struct TuiTab { pub key: String, pub label: String, - leading: Option, + leading: Vec, } /// Styled text rendered before a tab label. @@ -44,13 +44,13 @@ impl TuiTab { Self { key: key.into(), label: label.into(), - leading: None, + leading: Vec::new(), } } /// Adds styled text rendered immediately before the tab label. pub fn with_leading_text(mut self, text: impl Into, style: TuiStyle) -> Self { - self.leading = Some(TuiTabLeading { + self.leading.push(TuiTabLeading { text: text.into(), style, }); @@ -450,10 +450,9 @@ fn render_tab( tab_style }; - let leading_and_label_are_present = tab.leading.is_some() && !tab.label.is_empty(); - let mut content = TuiFlex::row().with_spacing(u16::from(leading_and_label_are_present)); - - if let Some(leading) = &tab.leading { + let content_count = tab.leading.len() + usize::from(!tab.label.is_empty()); + let mut content = TuiFlex::row().with_spacing(u16::from(content_count > 1)); + for leading in &tab.leading { content.add_child( TuiText::new(leading.text.clone()) .with_style(leading.style) @@ -752,15 +751,15 @@ fn minimum_visible_label_width(tab: &TuiTab) -> u16 { fn tab_fixed_columns(tab: &TuiTab, padding_columns: u16) -> u16 { let leading_columns = tab .leading - .as_ref() + .iter() .map(|leading| text_width(&leading.text)) - .unwrap_or_default(); + .fold(0u16, u16::saturating_add); + let content_count = tab.leading.len() + usize::from(!tab.label.is_empty()); padding_columns .saturating_mul(2) .saturating_add(leading_columns) - .saturating_add(u16::from(tab.leading.is_some() && !tab.label.is_empty())) + .saturating_add(u16::try_from(content_count.saturating_sub(1)).unwrap_or(u16::MAX)) } - #[cfg(test)] #[path = "tab_bar_tests.rs"] mod tests; diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 95a990aac94..3a1b098fa6c 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -26,9 +26,9 @@ use warp::tui_export::{ BlocklistAIHistoryModel, BlocklistAIInputModel, CLISubagentController, CLISubagentEvent, CLISubagentTarget, CancellationReason, ChangelogModel, ChangelogModelEvent, ChangelogRequestType, CloudConversationData, CommandExecutionSource, ConversationFileExport, - ConversationSelection, ConversationSelectionHandle, ConversationUsageTotals, - ExecuteCommandEvent, GetRelevantFilesController, GitRepoModels, GitRepoStatusModel, - GitStatusMetadata, LLMId, LLMPreferences, LLMPreferencesEvent, ModelEvent, + ConversationSelection, ConversationSelectionHandle, ConversationStatus, + ConversationUsageTotals, ExecuteCommandEvent, GetRelevantFilesController, GitRepoModels, + GitRepoStatusModel, GitStatusMetadata, LLMId, LLMPreferences, LLMPreferencesEvent, ModelEvent, ParsedSlashCommandInput, PtyIntent, PtyIntentEvent, RepoDetectionSessionType, RepoDetectionSource, ServerConversationToken, ShellCommandExecutorEvent, SizeInfo, SizeUpdate, SkillReference, SlashCommandDataSource as _, SlashCommandSelectionBehavior, @@ -45,8 +45,10 @@ use warp_errors::report_error; use warp_util::local_or_remote_path::LocalOrRemotePath; use warpui::SingletonEntity; use warpui_core::elements::tui::{ - TuiChildView, TuiConstrainedBox, TuiContainer, TuiElement, TuiFlex, TuiSize, TuiStyle, TuiText, + TuiChildView, TuiConstrainedBox, TuiContainer, TuiElement, TuiEventHandler, TuiFlex, TuiSize, + TuiStyle, TuiText, }; +use warpui_core::elements::CrossAxisAlignment; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, EditableBinding, FixedBinding}; use warpui_core::platform::TerminationMode; @@ -55,9 +57,11 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; +use crate::agent_message::{conversation_status_glyph, conversation_status_glyph_style}; use crate::alt_screen_view::AltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; +use crate::cloud_run::{TuiCloudRunStartup, TuiCloudRunState}; use crate::conversation_menu::{TuiConversationMenuEvent, TuiConversationMenuModel}; use crate::conversation_selection::TuiConversationSelection; use crate::exit_confirmation::{ExitConfirmation, CTRL_C_EXIT_WINDOW}; @@ -69,6 +73,7 @@ use crate::keybindings::{ CONTEXTUAL_PLAN_TOGGLE_BINDING_NAME, KEYBOARD_ENHANCEMENT_AVAILABLE_FLAG, PLAN_TOGGLE_AVAILABLE_FLAG, PLAN_TOGGLE_BINDING_NAME, TUI_BINDING_GROUP, }; +use crate::link::TuiLink; use crate::mcp_menu::{TuiMcpMenuEvent, TuiMcpMenuModel}; use crate::model_menu::{TuiModelMenuEvent, TuiModelMenuModel}; use crate::orchestrated_agent_identity_styling::assign_agent_identity_indices; @@ -93,7 +98,9 @@ use crate::tui_builder::TuiUiBuilder; use crate::tui_cli_subagent_view::{ TuiCLISubagentView, HAND_BACK_KEY_BINDING, TAKE_CONTROL_KEY_BINDING, }; -use crate::ui::{compact_footer_path, conversation_restore_failed, conversation_restoring}; +use crate::ui::{ + centered_in_viewport, compact_footer_path, conversation_restore_failed, conversation_restoring, +}; use crate::usage::UsageToggle; use crate::warping_indicator::{render_response_summary, render_warping_indicator}; use crate::zero_state::render_zero_state; @@ -104,6 +111,7 @@ 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 CLOUD_PLACEHOLDER_FLAG: &str = "TuiCloudPlaceholder"; const ORCHESTRATION_TAB_BAR_FOCUSED_FLAG: &str = "TuiOrchestrationTabBarFocused"; const ORCHESTRATION_TAB_LABEL_MAX_COLUMNS: u16 = 20; @@ -130,6 +138,18 @@ pub(crate) enum TuiTerminalSessionEvent { conversation_id: AIConversationId, }, } +enum TuiTerminalSessionMode { + Local, + CloudPlaceholder(ModelHandle), +} + +struct TuiCloudPlaceholderPresentation { + status: ConversationStatus, + status_label: String, + detail: Option, + link_label: Option<&'static str>, + link_url: Option, +} impl PtyIntentEvent for TuiTerminalSessionEvent { fn pty_intent(&self) -> Option { @@ -251,6 +271,8 @@ pub(crate) enum TuiTerminalSessionAction { TogglePlan, /// Return keyboard focus from tabs to the session's default interaction target. FocusDefaultInteractionTarget, + /// Give keyboard focus to the orchestration tab bar. + FocusOrchestrationTabs, /// Select the previous tab using the tab view's semantic order. SelectPreviousOrchestrationTab, /// Select the next tab using the tab view's semantic order. @@ -259,10 +281,14 @@ pub(crate) enum TuiTerminalSessionAction { SelectFirstOrchestrationChild, /// Select the last child tab, excluding the orchestrator. SelectLastOrchestrationChild, + /// Open the primary URL rendered by a read-only cloud child. + OpenCloudRunUrl(String), } /// The authenticated terminal/session surface rendered inside [`RootTuiView`]. pub(crate) struct TuiTerminalSessionView { + mode: TuiTerminalSessionMode, + cloud_link: TuiLink, transcript: ViewHandle, input_view: ViewHandle, inline_menus: Vec, @@ -372,7 +398,16 @@ pub(crate) fn init(app: &mut AppContext) { // bindings above, so the two groups use different registration APIs. let tab_context = id!(TuiTerminalSessionView::ui_name()) & id!(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); + let cloud_context = id!(TuiTerminalSessionView::ui_name()) & id!(CLOUD_PLACEHOLDER_FLAG); app.register_editable_bindings([ + EditableBinding::new( + "tui:cloud_placeholder:focus_orchestration_tabs", + "Focus the orchestration tab bar", + TuiTerminalSessionAction::FocusOrchestrationTabs, + ) + .with_context_predicate(cloud_context) + .with_group(TUI_BINDING_GROUP) + .with_key_binding("shift-up"), EditableBinding::new( "tui:orchestration_tabs:previous", "Select the previous orchestration tab", @@ -438,6 +473,9 @@ impl TuiTerminalSessionView { /// for inline long-running commands currently controlled by the user, and /// drives the session's focus, rendering, and event-routing transitions. fn process_owns_input(&self) -> bool { + if self.is_cloud_placeholder() { + return false; + } let terminal_model = self.terminal_model.lock(); terminal_model.is_alt_screen_active() || inline_process_owns_input(&terminal_model) } @@ -447,7 +485,9 @@ impl TuiTerminalSessionView { } fn focus_current_owner(&mut self, ctx: &mut ViewContext) { - if self.process_owns_input() { + if self.is_cloud_placeholder() { + ctx.focus_self(); + } else if self.process_owns_input() { self.orchestration_tabs_focused = false; ctx.focus_self(); } else if let Some(blocker) = self.active_blocking_child(ctx) { @@ -587,6 +627,21 @@ impl TuiTerminalSessionView { ctx.notify(); } + fn handle_read_only_interrupt(&mut self, ctx: &mut ViewContext) { + let now = Instant::now(); + if self.exit_confirmation.should_exit(now) { + ctx.terminate_app(TerminationMode::ForceTerminate, None); + return; + } + let window_expires_at = self.exit_confirmation.arm(now); + ctx.spawn(Timer::after(CTRL_C_EXIT_WINDOW), move |view, _, ctx| { + if view.exit_confirmation.disarm_expired(window_expires_at) { + ctx.notify(); + } + }); + ctx.notify(); + } + fn handle_terminal_use_interrupt(&mut self, ctx: &mut ViewContext) -> bool { let control_state = self .cli_subagent_controller @@ -1183,6 +1238,8 @@ impl TuiTerminalSessionView { ctx.spawn_stream_local(terminal_resize_rx, Self::handle_terminal_resize, |_, _| {}); Self { + mode: TuiTerminalSessionMode::Local, + cloud_link: TuiLink::default(), transcript, input_view, inline_menus, @@ -1236,6 +1293,154 @@ impl TuiTerminalSessionView { controller.send_agent_query_in_conversation(prompt, conversation_id, ctx); }); } + /// Builds a read-only deferred cloud session over the normal terminal surface plumbing. + pub(crate) fn new_cloud( + surface_init: TerminalSurfaceInit, + cloud_run_state: ModelHandle, + exit_summary: TuiExitSummaryHandle, + keyboard_enhancement_supported: bool, + ctx: &mut ViewContext, + ) -> Self { + ctx.subscribe_to_model(&cloud_run_state, |_, _, _, ctx| ctx.notify()); + let mut view = Self::new( + surface_init, + exit_summary, + keyboard_enhancement_supported, + ctx, + ); + view.mode = TuiTerminalSessionMode::CloudPlaceholder(cloud_run_state); + view + } + + fn is_cloud_placeholder(&self) -> bool { + matches!(self.mode, TuiTerminalSessionMode::CloudPlaceholder(_)) + } + + fn cloud_placeholder_presentation( + &self, + ctx: &AppContext, + ) -> Option { + let TuiTerminalSessionMode::CloudPlaceholder(state) = &self.mode else { + return None; + }; + let state = state.as_ref(ctx); + Some(match state.startup() { + TuiCloudRunStartup::Dispatching => TuiCloudPlaceholderPresentation { + status: ConversationStatus::InProgress, + status_label: "Starting cloud run…".to_string(), + detail: None, + link_label: None, + link_url: None, + }, + TuiCloudRunStartup::Blocked(blocker) => TuiCloudPlaceholderPresentation { + status: ConversationStatus::Blocked { + blocked_action: blocker.message().to_owned(), + }, + status_label: "GitHub authentication required".to_string(), + detail: Some(format!( + "{} Authenticate, then run the orchestration request again.", + blocker.message() + )), + link_label: Some("Authenticate here:"), + link_url: Some(blocker.primary_url().to_string()), + }, + TuiCloudRunStartup::Failed(failure) => TuiCloudPlaceholderPresentation { + status: ConversationStatus::Error, + status_label: "Cloud run failed to start".to_string(), + detail: Some(failure.message().to_string()), + link_label: None, + link_url: None, + }, + TuiCloudRunStartup::Spawned => { + let status = state + .conversation_id() + .and_then(|conversation_id| { + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .map(|conversation| conversation.status()) + }) + .unwrap_or(&ConversationStatus::InProgress); + let status_label = match status { + ConversationStatus::InProgress + | ConversationStatus::TransientError + | ConversationStatus::WaitingForEvents => "Cloud run in progress", + ConversationStatus::Blocked { .. } => "Cloud run blocked", + ConversationStatus::Success => "Cloud run succeeded", + ConversationStatus::Error => "Cloud run failed", + ConversationStatus::Cancelled => "Cloud run cancelled", + }; + TuiCloudPlaceholderPresentation { + status: status.clone(), + status_label: status_label.to_string(), + detail: None, + link_label: Some("View cloud run here:"), + link_url: state.run_url().map(str::to_string), + } + } + }) + } + + fn render_cloud_placeholder( + &self, + presentation: TuiCloudPlaceholderPresentation, + orchestration_tabs: Option<&TuiOrchestrationSnapshot>, + builder: &TuiUiBuilder, + ctx: &AppContext, + ) -> Box { + let mut content = TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .child( + TuiText::from_spans([ + ( + format!("{} ", conversation_status_glyph(&presentation.status)), + conversation_status_glyph_style(&presentation.status, builder), + ), + (presentation.status_label, builder.primary_text_style()), + ]) + .finish(), + ); + if let Some(detail) = presentation.detail { + content = content.child( + TuiText::new(detail) + .with_style(builder.muted_text_style()) + .finish(), + ); + } + if let (Some(label), Some(url)) = (presentation.link_label, presentation.link_url.clone()) { + let click_url = url.clone(); + content = content + .child( + TuiText::new(label) + .with_style(builder.muted_text_style()) + .finish(), + ) + .child(self.cloud_link.render(url, ctx, move |event_ctx, _| { + event_ctx.dispatch_typed_action(TuiTerminalSessionAction::OpenCloudRunUrl( + click_url.clone(), + )); + })); + } + let placeholder = centered_in_viewport(content.finish()); + let placeholder = if let Some(url) = presentation.link_url { + TuiEventHandler::new(placeholder) + .on_key("enter", move |_, event_ctx, _| { + event_ctx.dispatch_typed_action(TuiTerminalSessionAction::OpenCloudRunUrl( + url.clone(), + )); + }) + .finish() + } else { + placeholder + }; + if orchestration_tabs.is_some() { + TuiFlex::column() + .child(TuiChildView::new(&self.orchestration_tab_bar).finish()) + .flex_child(placeholder) + .finish() + } else { + placeholder + } + } /// Initializes a background child session with the conversation it owns. pub(crate) fn initialize_orchestrated_child_conversation( @@ -1385,7 +1590,10 @@ impl TuiTerminalSessionView { .or_else(|| palette.first()) .cloned() .unwrap_or_default(); + let status_glyph = conversation_status_glyph(&tab.status); + let status_style = conversation_status_glyph_style(&tab.status, builder); TuiTab::new(tab.conversation_id.to_string(), tab.label.clone()) + .with_leading_text(status_glyph, status_style) .with_leading_text(identity.glyph, identity.style) }) .collect(); @@ -1741,7 +1949,9 @@ impl TuiTerminalSessionView { self.terminal_model.lock().resize(size_update); self.size_info = size_update.new_size(); - ctx.emit(TuiTerminalSessionEvent::Resize(size_update)); + if !self.is_cloud_placeholder() { + ctx.emit(TuiTerminalSessionEvent::Resize(size_update)); + } ctx.notify(); } /// Refreshes terminal model geometry and redraws only when this session is visible. @@ -1844,6 +2054,10 @@ impl TuiTerminalSessionView { ctx.terminate_app(TerminationMode::ForceTerminate, None); return; } + if self.is_cloud_placeholder() { + self.handle_read_only_interrupt(ctx); + return; + } if self.handle_terminal_use_interrupt(ctx) { self.exit_confirmation.disarm(); ctx.notify(); @@ -2569,6 +2783,9 @@ impl TuiTerminalSessionView { model: &Arc>, ctx: &mut ViewContext, ) { + if self.is_cloud_placeholder() { + return; + } match event { ShellCommandExecutorEvent::ExecuteCommand { action_id, command } => { let Some((session_id, conversation_id)) = (|| { @@ -2647,6 +2864,9 @@ impl TuiView for TuiTerminalSessionView { if self.orchestration_tabs_focused && !self.process_owns_input() { context.set.insert(ORCHESTRATION_TAB_BAR_FOCUSED_FLAG); } + if self.is_cloud_placeholder() { + context.set.insert(CLOUD_PLACEHOLDER_FLAG); + } if self.is_conversation_restore_loading() { context.set.insert(SESSION_CAN_CANCEL_RESTORE_FLAG); } @@ -2677,6 +2897,16 @@ impl TuiView for TuiTerminalSessionView { } ConversationRestoreState::Idle => {} } + if let Some(presentation) = self.cloud_placeholder_presentation(ctx) { + let builder = TuiUiBuilder::from_app(ctx); + let orchestration_tabs = self.compute_orchestration_tab_snapshot(ctx); + return self.render_cloud_placeholder( + presentation, + orchestration_tabs.as_ref(), + &builder, + ctx, + ); + } // While a full-screen (alt-screen) app is active, hand the whole pane to // it: render its grid and forward input, instead of the block UI. let (alt_screen_active, inline_process_owns_input) = { @@ -2866,6 +3096,8 @@ impl TypedActionView for TuiTerminalSessionView { TuiTerminalSessionAction::FocusDefaultInteractionTarget => { self.set_orchestration_tab_focus(false, ctx) } + TuiTerminalSessionAction::OpenCloudRunUrl(url) => ctx.open_url(url), + TuiTerminalSessionAction::FocusOrchestrationTabs => self.focus_orchestration_tabs(ctx), TuiTerminalSessionAction::SelectPreviousOrchestrationTab => { let key = self .orchestration_tab_bar @@ -2895,6 +3127,9 @@ impl TypedActionView for TuiTerminalSessionView { self.switch_to_orchestration_tab(key, true, ctx); } TuiTerminalSessionAction::ForwardUserPtyBytes(bytes) => { + if self.is_cloud_placeholder() { + return; + } // Raw passthrough: the bytes are already the app's escape // sequence, so write them to the PTY unmodified. ctx.emit(TuiTerminalSessionEvent::WriteUserInput(Cow::Owned( diff --git a/crates/warp_tui/src/ui.rs b/crates/warp_tui/src/ui.rs index 18ec40f606e..da12d7715ca 100644 --- a/crates/warp_tui/src/ui.rs +++ b/crates/warp_tui/src/ui.rs @@ -110,7 +110,7 @@ fn vertically_centered(content: TuiFlex) -> Box { } /// Centers `content` horizontally and vertically within the viewport. -fn centered_in_viewport(content: Box) -> Box { +pub(crate) fn centered_in_viewport(content: Box) -> Box { let centered_row = TuiFlex::row() .flex_child(TuiFlex::row().finish()) .child(content) diff --git a/specs/code-1822-tui-cloud-children/PRODUCT.md b/specs/code-1822-tui-cloud-children/PRODUCT.md index 059ea720b2e..5521b6b2b3c 100644 --- a/specs/code-1822-tui-cloud-children/PRODUCT.md +++ b/specs/code-1822-tui-cloud-children/PRODUCT.md @@ -86,6 +86,6 @@ Figma: none provided. ### Interaction boundaries 36. The cloud placeholder accepts no prompt or shell input. Printable keys are never forwarded to a local PTY or remote run. 37. The placeholder does not expose stop, kill, delete, retry, or follow-up actions. -38. Existing orchestration-tab keyboard and mouse navigation remains available. +38. `Shift+Up` focuses the orchestration tab bar directly from the cloud placeholder even though no prompt input is rendered. Once the tab bar is focused, its existing navigation keys behave identically to regular sessions; the placeholder does not add a visual hint for this shortcut. 39. Existing application exit behavior remains available; focusing a cloud placeholder must not make terminal-control keybindings act on a nonexistent local process. 40. The reusable link interaction behaves consistently for the Oz run URL and GitHub authentication URL, including hover treatment, click target, Enter activation, selection, and copying. diff --git a/specs/code-1822-tui-cloud-children/TECH.md b/specs/code-1822-tui-cloud-children/TECH.md index 84f767dbb37..68d837ef69e 100644 --- a/specs/code-1822-tui-cloud-children/TECH.md +++ b/specs/code-1822-tui-cloud-children/TECH.md @@ -47,10 +47,9 @@ Extract the launch-error interpretation needed by both frontends into shared dat The GUI maps these shared values into its existing `Status` and events without changing control flow. The TUI uses the same values in its placeholder presentation. Do not introduce a shared launch coordinator or duplicate `StartAgentExecutor` outcome state. ### 2. Add a minimal deferred TUI cloud terminal manager -Add `crates/warp_tui/src/cloud_terminal_manager.rs` with `TuiCloudTerminalManager`, implementing `TerminalManagerTrait`. It owns: -- A PTY-less `TerminalModel` constructed with `TerminalModel::new_for_cloud_mode_shared_session_viewer`. -- Wakeup/model-event channels, `Sessions`, `ModelEventDispatcher`, terminal colors/size, and the inactive PTY-read receiver required to construct `TerminalSurfaceInit`. -- No local PTY, network transport, session-sharing connection API, polling loop, or speculative connected-state enum. +Add `TerminalSurfaceInit::new_for_tui_cloud_viewer` in the shared app terminal layer to construct the PTY-less `TerminalModel`, wakeup/model-event channels, `Sessions`, `ModelEventDispatcher`, terminal colors/size, and inactive PTY-read receiver. Add `crates/warp_tui/src/cloud_terminal_manager.rs` with `TuiCloudTerminalManager`, implementing `TerminalManagerTrait`; it retains the terminal model and inactive receiver while the session view retains the surface's model handles. + +The TUI manager owns no local PTY, network transport, session-sharing connection API, polling loop, or speculative connected-state enum. **GUI prior art:** This deliberately mirrors the GUI's `shared_session::viewer::TerminalManager::new_deferred` construction and lifetime model: create the real terminal model, event plumbing, view, and concrete type-erased manager before a remote session exists, then retain their identities for an eventual in-place attachment. It does not reuse the GUI manager implementation because that implementation constructs `TerminalView` and owns GUI-specific network, orchestration polling, `ActiveAgentViewsModel`, permission, and session-attachment behavior. @@ -70,6 +69,7 @@ Add `TuiTerminalSessionMode::{Local, CloudPlaceholder(ModelHandle