Expand description
P5-4 (COMPOSABLE-HARNESS-DESIGN.md §2 module 30 tui; §1.9 recorded
deviation; §2.1 tools.question/permissions.approvals(ask-UI) →
tui|server): the full-screen interactive TUI, AND — because §2.1
names it as the interactive surface three EARLIER phases explicitly
deferred here — the home for the three handlers that close those
deferred chains:
- P5-1’s approval ask-UI —
handlers::TuiApprovalHandlerimplementscrate::permissions::PermissionsApprovalHandler. - P5-2’s elicitation prompt (+ OAuth device-code display) —
handlers::TuiElicitationHandlerimplementscrate::mcp::McpElicitationHandler;bridge::PendingOAuthDisplaycarries the device-code modal push. - P5-3’s parent-answerable child approvals —
handlers::TuiChildApprovalHandler, installed viacrate::agent::Agent::set_child_approval_handler_factory(P5-4’s own new seam — see that method’s doc comment for why this can only ever grant what the rule engine already routed to a prompt, never escalate past aDeny).
§Two layers (why this crate only has ONE of them)
A real terminal can’t be driven headlessly in a unit test — so this module is split in two, and only the TESTABLE half lives here:
- The view-model core (
state,key,keymap,theme,history,bridge,handlers) — a purehandle_key(KeyEvent) -> Vec<Action>/apply(Action)state machine plus the three interactive handlers above. Zero terminal-library dependency (noratatui/crosstermincrates/harness’sCargo.tomlat all — seekey::KeyEvent’s doc comment) — every interaction in this crate is unit-testable by constructing akey::KeyEventby hand. - The render + event-loop layer lives in
crates/cli/src/tui/(a binary-crate concern:ratatui+crossterm, an alternate-screen terminal, real keyboard/paste input). It translates realcrossterm::event::KeyEvents intokey::KeyEvent, drivesstate::TuiState, and renders the result — see that module’s own doc comment for the render loop and itsTestBackendsmoke test.
§Activation
Config::tui_enabled (capabilities.tui.enabled, default false) is
the master gate — crates/cli’s chat() runs the pre-P5-4 rustyline
REPL loop byte-for-byte when it’s off, or when stdin/stdout/stderr
aren’t all a real tty (--print, a piped/non-interactive invocation,
or any headless test harness) — see should_activate.
§Shippable-complete vs honestly-staged
- Shippable-complete: the view-model core (composer editing,
scrollback, streaming accumulation, the three interactive-handler
modals, theme toggle, configurable global keybindings, cross-session
Ctrl+R prompt-history search, BASIC vim emulation), the render/
event-loop layer, image-paste passthrough — a placeholder token +
state::TuiState::pending_images, drained bycrates/cli’s render loop viastate::TuiState::take_pending_imagesand routed into the turn as real multimodal content (Agent::send_with_images) — external-$EDITORinvocation. - Honestly staged, not half-built:
- FULL vim emulation (registers, visual mode,
.-repeat, counts,dd/yy/pas real two-keystroke commands rather than the single-keystroke approximationstate::TuiState::handle_key’s vim-normal-mode match implements) — seestate::VimMode’s doc comment. - OAuth device-code display (
bridge::TuiBridge::oauth_sender/bridge::PendingOAuthDisplay/Action::ShowOAuthModal): the plumbing is complete and tested end-to-end (push → modal → render → dismiss), butcrates/clihas no call site that ever SENDS into it —attach_mcp’s OAuth handling (resolve_oauth_header) only ever consults an ALREADY-stored, possibly-refreshed token; a fresh device-code flow (mcp_oauth::run_device_flow) only ever runs via the separatesupercode mcp login <name>subcommand, which never activates the TUI. So a TUI session currently never has a moment where a device code needs displaying at all — wiring one in would mean teachingattach_mcpto trigger a live, session-startup- blocking device-code flow when no cached token exists, a real interactive-flow design decision (timeout? cancel-and-continue without the server? non-interactive callers?) out of scope for this module’s own render-loop-and-handlers job. Kept (not removed) as the ready seam for whichever future unit makes that call — zero cost while unused, the same “installing a handler alone changes nothing” posture every other optional seam in this crate has. Cited as a follow-up for this module, not a broken partial implementation — same posture as the vim item above.
- FULL vim emulation (registers, visual mode,
Re-exports§
pub use bridge::PendingApprovalRequest;pub use bridge::PendingChildApproval;pub use bridge::PendingElicitation;pub use bridge::PendingOAuthDisplay;pub use handlers::TuiApprovalHandler;pub use handlers::TuiBridge;pub use handlers::TuiChildApprovalHandler;pub use handlers::TuiElicitationHandler;pub use history::PromptHistory;pub use key::Key;pub use key::KeyEvent;pub use keymap::Keymap;pub use keymap::KeymapAction;pub use state::Action;pub use state::HistorySearchState;pub use state::InputFocus;pub use state::Modal;pub use state::Role;pub use state::StatusLine;pub use state::TranscriptEntry;pub use state::TuiState;pub use state::VimMode;pub use theme::Theme;
Modules§
- bridge
- P5-4: the data shapes carried across the thread boundary between an
interactive-handler call (blocked on a background/agent thread) and the
render/event-loop’s main thread — deliberately just data + a reply
channel, no trait impls, so
crate::tui::state(the pure view-model) andcrate::tui::handlers(the actualPermissionsApprovalHandler/McpElicitationHandlerimplementations) can both depend on this module without a circular dependency between them. - handlers
- P5-4’s three load-bearing interactive handlers — the THIS-MODULE side of
the three deferred chains P5-1/P5-2/P5-3 each explicitly left for
tui: - history
- P5-4 (§3.1, D5 “cross-session prompt history”; S6: homed here per the
design’s own §2 module-30 row — “Ctrl+R-style search is a
composer/UX affordance”): a persisted, searchable list of prompts the
user has submitted, surviving across TUI sessions (unlike an in-memory
Vecthat resets on exit). Deliberately its own small file format (one prompt per line, blank lines and\ncollapsed to a literal\nescape so a multi-line prompt round-trips as ONE history entry — NOT the same filerustyline’s REPL history uses, since rustyline’sDefaultHistoryserialization is a private implementation detail of that crate, not a format this crate should parse) so a TUI session started days later still has yesterday’s prompts to Ctrl+R through. - key
- P5-4 (§2 module 30): a terminal-library-agnostic key event — the
crate::tui::state::TuiState::handle_keystate machine consumes THIS type, notcrossterm::event::KeyEvent, so the whole view-model core stays free of acrossterm/ratatuidependency (neither is incrates/harness’sCargo.toml— see that crate’s own doc comment for why: a real terminal can’t be driven in a unit test, but this struct can be constructed by hand).crates/cli’s render/event-loop layer is the one place that translates a realcrossterm::event::KeyEventinto this shape before handing it to the state machine. - keymap
- P5-4 (§3.1
capabilities.tui.keymap.<action> = "<key>", “configurable keybindings”): a name→KeyEventtable for the small set of GLOBAL actions a user can rebind, layered over sensible defaults. Modal-local navigation (arrow keys,Enter/Escto confirm/cancel a prompt) is deliberately NOT part of this table — those are fixed, universal conventions, not a rebind surface — only the actions listed inKeymapAction::ALLare. - state
- P5-4 (§2 module 30): the TESTABLE view-model core — a pure
handle_key(keypress → intendedActions) plusapply(mutateTuiStatefor ANY action, whether it came from a keypress or from an interactive handler’s request landing on the bridge channels — seecrate::tui::handlers). Neither function touches a terminal; every test in this module drives the whole thing by hand. - theme
- P5-4 (§3.1
capabilities.tui.theme, D8 “themes”): the SEMANTIC theme choice — which of the built-in named roles (accent, dim, error, …) a renderer should map to actual terminal colors. Deliberately carries noratatui::style::Color(or any other terminal-library type) so this stays part of the terminal-free view-model core;crates/cli’s render layer owns the actual RGB/ANSI mapping.
Functions§
- should_
activate - Whether the TUI should activate for this process:
config.tui_enabledAND stdin/stdout/stderr are all a real terminal.crates/cli’schat()is the sole call site that matters for the “default-off / non-tty /--print= byte-identical REPL” contract (P5-4’s build brief) — a piped/non-interactive invocation (virtually every CI/test run, regardless of whether a parity preset setscapabilities.tui.enabled = true) always falls through to the pre-P5-4 REPL because THIS check fails, not because the config bit is off.