Files

214 lines
23 KiB
Markdown

# TUI transcript view — TECH
## Context
This PR builds the first production-shaped conversation transcript view for Warp's TUI. It proves the transcript container and canonical ordering path with two intentionally simple block renderers:
- an agent block that renders user input and streamed plain-text agent output
- a terminal block that renders command/input and streamed terminal output
Bare `warp-tui` launches a real login-gated TUI root. Once authenticated, the root delegates to an authenticated terminal session view containing an editor-backed input docked at the bottom and a transcript above it. Submitting the input sends a prompt to the surface's conversation, streaming the response into the transcript as an agent block.
Rich block content and interactive block affordances are outside this PR. Those features must extend the block-render boundary established here rather than alter the transcript container or introduce a TUI-specific blocklist.
The generalized, content-agnostic TUI viewport this transcript renders into (the virtualized list, scroll/anchor model, height reconciliation, and wheel/event plumbing) is a dependency provided by the downstack branch and specified in [`specs/tui-viewport/TECH.md`](../tui-viewport/TECH.md). This spec covers only the terminal-backed transcript built on top of it.
The current TUI prompt path is owned by `crates/warp_tui/src/terminal_session_view.rs`. `TuiTerminalSessionView` creates the production AI context/input/action/controller models for its terminal surface, owns a `TuiConversationSelection`, and sends submitted prompts through `BlocklistAIController::send_user_query_in_conversation`. The earlier one-shot stdout prompt-streaming path and separate `TuiConversationModel` are removed in this PR.
WarpUI already has a TUI-specific element/view/presenter stack. [`TuiElement`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/crates/warpui_core/src/elements/tui/mod.rs#L96-L140) defines the normal layout, rendering, presentation, event, and cursor lifecycle, while [`TuiPresenter`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/crates/warpui_core/src/presenter/tui.rs#L81-L208) retains laid-out trees and records child-view embeddings. The transcript must return normal visible `TuiElement` trees so this lifecycle remains intact; it must not use a context-free raw-buffer row renderer.
`TerminalModel::BlockList` is the canonical ordered presentation model for a terminal surface. Its heterogeneous [`BlockHeightItem`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/app/src/terminal/model/blocks.rs#L121-L196) sum tree orders terminal blocks and rich content and tracks accumulated height, count, and block count in [`BlockList`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/app/src/terminal/model/blocks.rs#L225-L270). Terminal output updates model-authoritative block heights, while view-measured rich content uses dirty marking and height writeback ([`mark_rich_content_dirty`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/app/src/terminal/model/blocks.rs#L1173-L1180), [`update_rich_content_heights`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/app/src/terminal/model/blocks.rs#L2349-L2351)). The GUI follows the same canonical-order model by inserting one rich-content AI block per exchange in [`TerminalView::handle_ai_history_model_event`](https://github.com/warpdotdev/warp/blob/e36e8ddf823d6a25a5225251a7db60698f5da74d/app/src/terminal/view.rs#L6030-L6220).
The TUI transcript will use this existing order. It will not own a second transcript order or introduce a `TUIBlocklistElement`.
## Proposed changes
### TUI transcript composition root
Change the no-prompt TUI frontend callback in `crates/warp_tui/src/lib.rs`: after app-side authentication, bare `warp-tui` starts a real TUI session instead of printing the authenticated user ID and exiting.
Add a root TUI view that owns login branching and the optional terminal-session child handle. The root window and TUI driver start immediately so the login placeholder can render, but `TuiTerminalSessionView` and the local terminal manager are created only after `TuiLoginPhase::LoggedIn`. `RootTuiView::ensure_terminal_session` creates the terminal child through the root view's own `ViewContext`, preserving normal TUI view hierarchy and parentage, while `crates/warp_tui/src/session.rs` owns the driver and retains the terminal manager handle after login.
When logged in and the terminal child exists, the root renders `TuiTerminalSessionView`. The authenticated session view renders a transcript above a bordered bottom input:
```rust
TuiColumn::new()
.flex_child(TuiChildView::new(&transcript_view))
.child(bordered_input)
```
The transcript fills remaining rows above the input. Short transcript content is bottom-aligned so it grows upward from the input; once content reaches the top of the transcript region, the existing viewport scrolling behavior takes over. The bordered input uses the real layout width minus the session padding, not a fixed input width. The input border uses the Figma cyan token through the active theme's terminal palette.
`TuiTerminalSessionView` is the `TerminalSurface` driven by the normal local terminal manager, so its transcript reads the same `TerminalModel` that receives shell output. `crates/warp_tui/src/session.rs` keeps the `spawn_tui_driver` handle alive from startup and fills in the retained terminal manager handle after login.
For now, `crates/warp_tui/src/session.rs::init` forces the already-initialized `Appearance` singleton to the TUI dark theme before constructing the TUI session. This is scoped to the TUI process and preserves the user's font and non-theme appearance settings. It does not change normal GUI theme selection. The temporary override exists because the current TUI transcript design is dark-mode-only.
This PR also relies on small TUI-core support changes: `TuiViewportedList` supports `TuiViewportVerticalAlignment::GrowFromBottom` for short transcript content, `TuiEventContext::set_origin_view` is public for TUI event tests, `AppContext::subscribe_to_view` documents that `ViewHandle<S>` is the proof that `S` is a GUI or TUI view while `S: Entity` supplies event typing, `TuiContainer` supports uniform, per-axis, and per-side padding so callers can express one-sided spacing without local spacer elements, and `warpui_core::elements::tui::color` converts GUI `Fill` values into Ratatui `Color` values at the final TUI rendering boundary.
### App integration surface
The `warp_tui` crate accesses app-owned terminal and AI types through `app/src/tui_export.rs`. This PR expands that export boundary for the transcript, including:
- agent exchange/input/output types, `AIBlockModel`, and `AIBlockModelImpl`
- terminal block/grid/list/rich-content types and terminal colors
- `CommandExecutionSource`, `ExecuteCommandEvent`, and `should_show_task_in_blocklist`
- `Appearance` and the TUI dark theme constructor needed by the TUI startup theme override
The app-side trait bounds are relaxed where TUI surfaces now share production models:
- `AIBlockModelImpl<V>` is bounded by `Entity` rather than GUI `View`, allowing `TuiAgentBlockView` to reuse the production exchange model.
- `TerminalSurface` is bounded by `Entity` rather than GUI `View`, with default no-op callbacks for optional GUI-oriented hooks.
- selected terminal model test helpers are exposed behind `test-util` for `warp_tui` tests.
### Appearance and color handling
TUI transcript colors are derived from the same `Appearance::theme()` source of truth used by GUI code. The TUI does not own feature-local fixed color constants or a separate `crates/warp_tui/src/theme.rs` color role module.
The Figma node for `Terminal/Dark/True` defines the relevant dark tokens as `background: #050505`, `foreground: #ffffff`, `cyan: #d0d1fe`, `cyan_overlay_1: #d0d1fe1a`, `white: #f1f1f1`, `green: #b4fa72`, `red: #ff8272`, and `ANSI Bright/ANSI Bright black: #8e8e8e`. The TUI uses Warp's regular bundled dark theme for now rather than introducing a near-duplicate TUI-specific theme for the small background difference.
TUI render code reads the active `WarpTheme` and chooses base theme tokens at each call site rather than adding transcript-specific theme methods:
- terminal surface and agent-block background use `theme.surface_1()`
- submitted prompt text uses `theme.foreground()`
- submitted prompt row background blends `theme.background()` with `theme.terminal_colors().normal.cyan` at 10% opacity twice, matching the two `cyan_overlay_1` layers in Figma
- agent plain-text output uses `theme.terminal_colors().normal.white`
- input border color uses `theme.terminal_colors().normal.cyan`
The explicit background blending is required because Ratatui terminal colors are opaque cell colors. The generic conversion from GUI `Fill` to Ratatui `Color` does not invent a background context for translucent fills. Callers must blend overlays against the intended background before converting.
The shared conversion lives in `crates/warpui_core/src/elements/tui/color.rs` as `impl From<warpui_core::elements::Fill> for ratatui::style::Color`. This keeps Ratatui-specific knowledge in the TUI element layer. `warp_tui` call sites first convert theme-layer `warp_core::ui::theme::Fill` values into `warpui_core::elements::Fill`, then into TUI `Color`.
The TUI dark theme's terminal palette also supplies future richer transcript roles. Diff additions/removals should use `theme.terminal_colors().normal.green` and `.normal.red`, and lower-priority status text should use `theme.terminal_colors().bright.black` when matching the Figma terminal token semantics.
### Interactive input hookup
`TuiTerminalSessionView` embeds the editor-backed [`TuiInputView`](../../crates/warp_tui/src/input/view.rs) (a `warp::editor::CodeEditorModel` in char-cell mode) as the fixed bottom child. It subscribes to `TuiInputViewEvent::Submitted`; on submit it trims the text, ignores empty prompts, creates a selected `AgentViewEntryOrigin::Tui` conversation through `TuiConversationSelection` if needed, and sends through `BlocklistAIController`. `TuiInputView::submit` already clears the editor buffer, so the input resets after each send.
The input box is rendered with a styled `TuiContainer` border using `theme.terminal_colors().normal.cyan`. The input view reports cursor coordinates through the normal `TuiElement::cursor_position` path.
The input view drives agent prompts only. Running shell commands from the TUI is future work, so `TuiTerminalSessionEvent` emits no direct PTY intents; terminal-block rendering is exercised by tests that drive `TerminalModel` directly rather than by interactive input. `TuiTerminalSessionView` refreshes on a positive allowlist of terminal model events that affect block, grid, or prompt rendering and relies on the terminal wakeup stream for PTY output redraws.
### TUI block-list viewport source
Add a `TuiBlockListViewportSource` adapter under `crates/warp_tui/src/` over the canonical `TerminalModel::BlockList` sum tree.
The adapter maps canonical entries to owned TUI transcript descriptors:
```rust
enum TuiBlockListViewportItemId {
TerminalBlock(BlockId),
AgentBlock(EntityId),
}
enum TuiBlockListVisibleItem {
TerminalBlock { block_id: BlockId },
AgentBlock {
registration: AgentBlockRegistration,
},
}
```
The adapter uses the `BlockList` sum tree as the source of truth and seeks by the accumulated `BlockHeight` dimension to the requested `scroll_top`, then walks only until the viewport bottom. It skips unsupported blocklist item kinds in this PR rather than rendering placeholders for them. Visible item origins use one coordinate system: absolute content-space rows from the top of the canonical block list.
`TuiBlockListViewportSource` refreshes rich-content heights before slicing, inside `visible_items`. It measures the union of `BlockList`'s dirty rich-content queue and every non-dirty registered `TuiAgentBlockView` whose rows fall within the viewport padded by an overhang band (`OVERHANG_ROWS`), measuring each through the view-level `desired_height` helper and writing the results back through `BlockList::update_rich_content_heights_in_lines`. Because the source owns this cheap, exact predictor and commits heights before windowing, the generic viewport needs no post-layout height reconciliation. The overhang band mirrors the GUI blocklist and keeps near-off-screen reflow (e.g. width changes) correct; agent blocks farther off-screen than the band retain their cached height until dirtied or scrolled near the viewport.
`BlockList::take_dirty_rich_content_items` is public so the TUI viewport source can consume pending rich-content height invalidations. The `warp_tui` crate accesses that helper and other app-owned model types only through the narrow `warp::tui_export` boundary.
### Transcript view and exchange lifecycle
Add a TUI transcript view under `crates/warp_tui/src/` that owns the generalized viewport state and the terminal-history integration. The terminal session view embeds it as the flex child above the bottom input in this PR. It subscribes to terminal-surface-scoped `BlocklistAIHistoryEvent`s and mirrors the existing GUI model-level lifecycle:
- `AppendedExchange` creates a simple TUI agent block view and inserts one `RichContentItem` into the canonical `BlockList`.
- `UpdatedStreamingExchange` marks the corresponding canonical rich-content item dirty and notifies the transcript.
- `ReassignedExchange` updates the block's conversation association.
- removal, deletion, clear, and transfer events remove the affected TUI agent rich-content entries.
TUI agent rich-content entries intentionally leave `agent_view_conversation_id` unset. That field encodes GUI Agent View filtering; setting it while the TUI block list remains in `AgentViewState::Inactive` causes the shared `BlockList` height-update path to hide the entry. The TUI transcript keeps its conversation/exchange association in its own registration map while retaining canonical outer ordering in `BlockList`.
The transcript wraps `TuiViewportedList` in `TuiScrollable` for mouse-wheel handling, renders `TuiBlockListViewportSource` through that viewport, and stores viewport position in its view-owned handle:
```rust
let source = TuiBlockListViewportSource::new(
self.model.clone(),
self.agent_blocks.clone(),
);
TuiScrollable::new(
TuiViewportedList::new(self.viewport.clone(), source)
.with_vertical_alignment(TuiViewportVerticalAlignment::GrowFromBottom),
)
```
### Simple terminal block
Add a simple terminal-block rows element under `crates/warp_tui/src/terminal_block.rs`. It exposes the terminal block's full logical row height to the generic viewport, which then clips top/bottom visibility through `TuiClipped`. The element locks the terminal model during TUI render, matching existing GUI terminal paint prior art, and directly paints terminal cell glyphs and styles from the block's prompt/command grid followed by its output grid into the TUI buffer.
The renderer preserves terminal cell glyphs and styles and supports incremental output because terminal block heights and grid contents are already updated by `TerminalModel`.
### Simple agent block
Add a simple `TuiAgentBlockView` keyed by `(AIConversationId, AIAgentExchangeId)`. This remains a registered TUI view because the shared block-list rich-content infrastructure stores and resolves rich content by view id. The rendering logic is intentionally thin and could be separated into a pure renderer later, but that would add indirection without changing the current infrastructure requirement. The view reads the current exchange from `BlocklistAIHistoryModel` and extracts logical `TuiAgentBlockSection`s:
- the exchange's displayable user input, preserving multi-line input as individual submitted prompt lines
- concatenated streamed `AIAgentTextSection::PlainText` output
The view returns a normal generic TUI element tree from those sections rather than introducing a custom agent-block element. User input is rendered with `TuiContainer` and `TuiText` using a full-width background derived from the TUI dark theme background plus two cyan overlay layers, matching the Figma prompt row. Each submitted input line receives the `≫ ` prefix and the same styled `TuiText` treatment until the TUI text element supports mixed-style spans. Plain-text output is rendered with `TuiText` using the active theme's terminal white token. The agent block body paints the same transcript background as its parent so clipped intermediate buffers do not leak a default/reset cell background; visually, only the submitted prompt row is highlighted. Input/output separation and bottom padding are expressed with `TuiContainer` padding in the composed `TuiColumn` tree, not with a separate manual height formula.
The rich-content height adapter measures the same composed element tree at the actual viewport width through `TuiAgentBlockView::desired_height` and writes that height back to `BlockList`. The viewport renders the agent block through the registered view handle and clips the resulting generic element tree through the viewport item boundary. It intentionally omits all non-plain-text agent output rather than inventing placeholder production behavior in this PR.
## End-to-end flow
```mermaid
flowchart TD
Init["bare warp-tui"] --> Root["RootTuiView<br/>login shell"]
Root --> Driver["Invalidation-driven<br/>TUI driver"]
Root -->|after LoggedIn| Session["TuiTerminalSessionView<br/>TerminalSurface"]
Session --> Input["TuiInputView<br/>bottom bordered"]
Input -->|submit prompt| Selection["TuiConversationSelection"]
Selection --> Controller["BlocklistAIController"]
Controller --> History
History["BlocklistAIHistoryEvent"] --> Transcript["TUI transcript view"]
Session --> Transcript
TerminalModel --> BlockList["TerminalModel::BlockList<br/>canonical SumTree"]
Transcript -->|append/update/remove agent rich content| BlockList
BlockList --> Index["TuiBlockListViewportSource<br/>canonical adapter"]
Index -->|owned visible descriptors| Viewport["TuiViewportedList"]
Viewport --> Render["Injected item renderer"]
Render --> Terminal["TerminalBlockRowsElement<br/>visible rows"]
Render --> Agent["Simple agent block<br/>visible rows"]
Terminal --> Presenter["Normal TuiElement lifecycle"]
Agent --> Presenter
Presenter --> Frame["TUI frame"]
Driver --> Presenter
```
## Testing and validation
### Generalized viewport tests
The generalized viewport element, scroll/anchor model, height reconciliation, and wheel/event conversion are tested in the downstack branch; see [`specs/tui-viewport/TECH.md`](../tui-viewport/TECH.md).
### Block renderer tests
Current focused `warp_tui` crate unit tests cover:
- agent block rendering of user input and streamed plain-text output
- agent block colors sourced from `Appearance::theme()` rather than fixed hex constants
- agent block width-dependent height measurement through composed TUI layout
- rich-content height writeback using the agent block view's measured height
- omission of unsupported agent sections until the TUI renders them intentionally
- terminal block row slicing through the block-list viewport source
- viewport SumTree seeking by scroll position and unified `origin_y` coordinates
- directional and axis-specific `TuiContainer` padding
### Transcript integration tests
Current `warpui::App::test` and `TuiPresenter` coverage verifies:
- terminal and simple agent blocks appear in canonical `BlockList` order
- terminal block visible-row slicing
- TUI agent rich content stays visible without GUI Agent View state
- transcript rendering from canonical terminal blocks
- agent rich-content dirty marking and removal from canonical `BlockList`
- mouse-wheel scrolling while key events remain unhandled by the transcript viewport
- `TuiViewportedList` grow-from-bottom alignment for short content
Additional validation that still needs attention before treating the TUI transcript as production-complete:
- `AppendedExchange`, reassignment, clear, deletion, and transfer events through real `BlocklistAIHistoryModel` fixtures
- submitted input prompt produces a streamed agent block in canonical `BlockList` order
- resize reflows agent text, updates rich-content height, and stabilizes the current frame
- follow-bottom remains pinned while streaming and anchored scrolling remains stable away from the bottom
- terminal session/root composition tests for login-gated child-view ownership
### Manual validation
- Run bare `cargo run -p warp_tui`; verify it enters the alternate screen and displays a bordered input docked at the bottom, with the transcript above it.
- Type a prompt and press Enter; verify the input clears and an agent block with streamed plain-text output appears.
- Create enough blocks to overflow the screen, then use the mouse wheel; verify the transcript preserves its anchor away from the bottom and resumes following after scrolling back to the end.
- Resize the terminal; verify the transcript reflows and preserves/follows its anchor as appropriate.
- Exit with Ctrl-C; verify the alternate screen and terminal mode restore cleanly.
Run:
- `./script/format`
- `cargo check -p warp_tui --all-targets`
- `cargo nextest run -p warp_tui`
- `cargo nextest run -p warpui_core --features tui`
- `cargo clippy -p warp_tui --all-targets -- -D warnings`
- `cargo clippy -p warpui_core --features tui --all-targets -- -D warnings`
- `cargo fmt -- --check`
## Parallelization
Parallel implementation agents are not proposed. The generalized viewport API, TUI block-list viewport source, block renderers, and transcript lifecycle are tightly coupled through evolving associated types and height/locking contracts; parallel branches would spend significant time restacking and reconciling the same interfaces. Implement sequentially on `harry/tui-transcript-view`, then run focused validation in parallel where the test runner permits it.
## Risks and mitigations
- **A second transcript order diverges from the terminal model.** Use `TerminalModel::BlockList` as the only canonical order; `TuiBlockListViewportSource` is an adapter, not storage.
- **Viewport abstraction leaks terminal or agent types.** Keep descriptors opaque to `TuiViewportedList`; all type-specific rendering stays in the injected app-layer function.
- **Terminal-model deadlock or UI stall.** Avoid nested terminal-model locks; terminal block rows lock during render consistently with GUI terminal paint, while viewport indexing and height writeback keep their lock scopes separate.
- **Hidden O(N) traversal defeats virtualization.** Seek the canonical block-height SumTree to the requested scroll window and stop at the viewport bottom rather than walking from the start of the block list.
- **Streaming height changes cause visual jumps.** Preserve stable anchors, batch height feedback, and stabilize visible layout in the current pass.
- **TUI and GUI behavior regress together.** Keep the new viewport TUI-specific; reuse backend-neutral pure algorithms only when their contracts truly match.
- **Simple test blocks become accidental production taxonomy.** Keep their scope explicit and verify the block-render seam rather than expanding content behavior in this PR.
- **TUI launch leaves the host terminal in raw/alternate-screen mode.** Tie terminal restoration to the owned driver handle and cover teardown in runtime tests.
## Outside this PR
- final production agent/terminal block styling and content taxonomy
- rich or interactive block affordances
- production-grade input affordances beyond submitting a prompt (history, completions, richer multi-line UX)