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,
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.
Structs§
- History
Search State - Cross-session prompt-history search state (Ctrl+R), live while
TuiState::input_focusisInputFocus::HistorySearch. - KeyEvent
- A
Keyplus the modifier keys held with it. Named fields (not a bitflag) so a test/keymap-string reader can construct one by hand without needing to know a bit layout. - Keymap
- The resolved action→key table —
KeymapAction::default_keyfor every action, with anySelf::with_overridessubstitutions applied. - Pending
Approval Request - One
Ask-tier request from the TOP-LEVEL agent’s own permissions gate (P5-1’scrate::permissions::PermissionsApprovalHandler::ask), waiting onSelf::reply_txfor the TUI’s decision.askblocks the calling thread on the pairedReceiveruntil a reply arrives (or the sending end is dropped — seecrate::tui::handlers::TuiApprovalHandler’s doc comment for why that’s still fail-closed). - Pending
Child Approval - The child-spawn analog of
PendingApprovalRequest(P5-3 §2.2 C6, closed bycrate::tui::handlers::TuiChildApprovalHandler): additionally carries which BACKGROUND CHILD raised the request, since a parent may have several background children in flight at once. - Pending
Elicitation - One server→client
elicitation/createrequest (crate::mcp::ElicitationRequest), waiting onSelf::reply_txfor the user’s answer. Uses atokio::sync::oneshot(notstd::sync::mpsclike the two structs above) becausecrate::mcp::McpElicitationHandler::handleis an ASYNC trait method — it.awaits the reply rather than blocking a thread. - PendingO
Auth Display - The OAuth device-code flow’s ONE-TIME display push (P5-2’s
run_device_flow’son_promptcallback) — informational only, no reply: the device flow polls the token endpoint regardless of whether the user has acknowledged seeing this, so there is nothing to block on. - Prompt
History - A persisted, searchable prompt history.
- Status
Line - The status line’s contents — deliberately minimal (a renderer decorates this, doesn’t reinterpret it).
- Transcript
Entry - One line (or block) in the scrollback transcript.
- TuiApproval
Handler - P5-1’s interactive ask-UI, closing the deferred chain
crate::permissions::approval’s module doc comment names.askpushes the request ontotxand blocks on a fresh one-shot reply channel;Self::txbeing closed (the render loop is gone) makes the blockingrecv()return anErr, which resolves toApprovalOutcome::Deny— fail-closed, matching the trait’s own “no handler ⇒ deny” default posture for the “handler installed but unreachable” case too. - TuiBridge
- The aggregate wiring point a
crates/cliembedder uses: constructs every channel pair once, installs the SENDING halves onto theAgent/McpClients that need them, and hands the RECEIVING halves to the render loop to poll each frame. Seecrate::tui::should_activate’s doc comment for why this is only ever built when the TUI is confirmed active — installing these on an agent that no render loop is draining would hang the firstAsk-tier prompt or elicitation forever. - TuiChild
Approval Handler - P5-3’s answerable child-approval handler, closing the §2.2 C6 deferred
chain — see
crate::agent::Agent::set_child_approval_handler_factory’s doc comment for how this REPLACES (only when installed) the default never-blockingcrate::subagents::ParentQueueApprovalHandler. Also records every request into the sharedqueue(the SAMEAgent::pending_child_approvalsaudit trailParentQueueApprovalHandleritself writes to), socrate::agent::Agent::pending_child_approvalsstays a complete audit log regardless of which handler answered a given request. - TuiElicitation
Handler - P5-2’s interactive elicitation UI, closing the deferred chain
crate::mcp::HeadlessElicitationHandler’s doc comment names.handleis ASYNC (the MCP trait’s own shape), so this uses atokio::sync:: oneshotreply rather than blocking a thread — awaiting it yields the executor to other work while the modal is up. A dropped reply sender (render loop gone) resolves tocrate::mcp::ElicitationAction::Cancel(the MCP spec’s own “dismissed without a decision” outcome — the honest shape for “nobody answered”, distinct from an explicitDecline). - TuiState
- The whole TUI view-model — see the module doc comment. Constructed
fresh per TUI session by the CLI render layer; every mutation goes
through
Self::apply.
Enums§
- Action
- A pure description of a state transition — the output of
TuiState::handle_keyand the input toTuiState::apply. NotClone/PartialEq-derived as a whole: theShow*Modalvariants embed a one-shot reply channel (std::sync::mpsc::Sender/tokio::sync::oneshot::Sender, neither of which isPartialEq) — tests assert on the resultingTuiState, not on rawActionequality. - Input
Focus - What the input composer is showing right now.
- Key
- One logical key, independent of any terminal library’s own enum.
#[non_exhaustive]so a future key (e.g. a specific F-key pastF(12), or a media key) can be added without breaking a downstreammatch. - Keymap
Action - A rebindable global TUI action.
- Modal
- An interactive modal covering the composer — at most one at a time
(§2.28: approval / elicitation / child-approval / OAuth-code-display).
A later request queues behind the earlier one still on screen (see
TuiState::modal_queue) rather than clobbering it. - Role
- Who said one transcript line.
- Theme
- A built-in theme name.
Darkis the default (matchesConfig::tui_theme’s"dark"default). - VimMode
- Vim-emulation sub-mode (only consulted when
TuiState::vim_enabledistrue— see that field’s doc comment for the deliberately-basic scope:hjklmotion,i/a/omode entry,x/dddeletion. This is NOT a full vim emulation (no registers, no visual mode, no.-repeat, no counts) — a shippable-complete BASIC modal editor, with full vim cited as a follow-up rather than half-built. See the crate-leveltuimodule doc comment’s “shippable vs staged” note.
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.