23 KiB
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-tuilaunches 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. 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 defines the normal layout, rendering, presentation, event, and cursor lifecycle, while TuiPresenter 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 sum tree orders terminal blocks and rich content and tracks accumulated height, count, and block count in BlockList. Terminal output updates model-authoritative block heights, while view-measured rich content uses dirty marking and height writeback (mark_rich_content_dirty, update_rich_content_heights). The GUI follows the same canonical-order model by inserting one rich-content AI block per exchange in TerminalView::handle_ai_history_model_event.
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:
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, andAIBlockModelImpl - terminal block/grid/list/rich-content types and terminal colors
CommandExecutionSource,ExecuteCommandEvent, andshould_show_task_in_blocklistAppearanceand 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 byEntityrather than GUIView, allowingTuiAgentBlockViewto reuse the production exchange model.TerminalSurfaceis bounded byEntityrather than GUIView, with default no-op callbacks for optional GUI-oriented hooks.- selected terminal model test helpers are exposed behind
test-utilforwarp_tuitests.
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()withtheme.terminal_colors().normal.cyanat 10% opacity twice, matching the twocyan_overlay_1layers 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 (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:
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 BlocklistAIHistoryEvents and mirrors the existing GUI model-level lifecycle:
AppendedExchangecreates a simple TUI agent block view and inserts oneRichContentIteminto the canonicalBlockList.UpdatedStreamingExchangemarks the corresponding canonical rich-content item dirty and notifies the transcript.ReassignedExchangeupdates 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_idunset. That field encodes GUI Agent View filtering; setting it while the TUI block list remains inAgentViewState::Inactivecauses the sharedBlockListheight-update path to hide the entry. The TUI transcript keeps its conversation/exchange association in its own registration map while retaining canonical outer ordering inBlockList.
The transcript wraps TuiViewportedList in TuiScrollable for mouse-wheel handling, renders TuiBlockListViewportSource through that viewport, and stores viewport position in its view-owned handle:
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 TuiAgentBlockSections:
- the exchange's displayable user input, preserving multi-line input as individual submitted prompt lines
- concatenated streamed
AIAgentTextSection::PlainTextoutput
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
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.
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_ycoordinates - directional and axis-specific
TuiContainerpadding
Transcript integration tests
Current warpui::App::test and TuiPresenter coverage verifies:
- terminal and simple agent blocks appear in canonical
BlockListorder - 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
TuiViewportedListgrow-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 realBlocklistAIHistoryModelfixtures- submitted input prompt produces a streamed agent block in canonical
BlockListorder - 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/formatcargo check -p warp_tui --all-targetscargo nextest run -p warp_tuicargo nextest run -p warpui_core --features tuicargo clippy -p warp_tui --all-targets -- -D warningscargo clippy -p warpui_core --features tui --all-targets -- -D warningscargo 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::BlockListas the only canonical order;TuiBlockListViewportSourceis 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)