Skip to main content

Module tui

Module tui 

Source
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:

  1. P5-1’s approval ask-UIhandlers::TuiApprovalHandler implements crate::permissions::PermissionsApprovalHandler.
  2. P5-2’s elicitation prompt (+ OAuth device-code display) — handlers::TuiElicitationHandler implements crate::mcp::McpElicitationHandler; bridge::PendingOAuthDisplay carries the device-code modal push.
  3. P5-3’s parent-answerable child approvalshandlers::TuiChildApprovalHandler, installed via crate::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 a Deny).

§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 pure handle_key(KeyEvent) -> Vec<Action> / apply(Action) state machine plus the three interactive handlers above. Zero terminal-library dependency (no ratatui/crossterm in crates/harness’s Cargo.toml at all — see key::KeyEvent’s doc comment) — every interaction in this crate is unit-testable by constructing a key::KeyEvent by 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 real crossterm::event::KeyEvents into key::KeyEvent, drives state::TuiState, and renders the result — see that module’s own doc comment for the render loop and its TestBackend smoke 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 by crates/cli’s render loop via state::TuiState::take_pending_images and routed into the turn as real multimodal content (Agent::send_with_images) — external-$EDITOR invocation.
  • Honestly staged, not half-built:
    • FULL vim emulation (registers, visual mode, .-repeat, counts, dd/yy/p as real two-keystroke commands rather than the single-keystroke approximation state::TuiState::handle_key’s vim-normal-mode match implements) — see state::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), but crates/cli has 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 separate supercode 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 teaching attach_mcp to 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.

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) and crate::tui::handlers (the actual PermissionsApprovalHandler/ McpElicitationHandler implementations) 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 Vec that resets on exit). Deliberately its own small file format (one prompt per line, blank lines and \n collapsed to a literal \n escape so a multi-line prompt round-trips as ONE history entry — NOT the same file rustyline’s REPL history uses, since rustyline’s DefaultHistory serialization 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_key state machine consumes THIS type, not crossterm::event::KeyEvent, so the whole view-model core stays free of a crossterm/ratatui dependency (neither is in crates/harness’s Cargo.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 real crossterm::event::KeyEvent into this shape before handing it to the state machine.
keymap
P5-4 (§3.1 capabilities.tui.keymap.<action> = "<key>", “configurable keybindings”): a name→KeyEvent table for the small set of GLOBAL actions a user can rebind, layered over sensible defaults. Modal-local navigation (arrow keys, Enter/Esc to confirm/cancel a prompt) is deliberately NOT part of this table — those are fixed, universal conventions, not a rebind surface — only the actions listed in KeymapAction::ALL are.
state
P5-4 (§2 module 30): the TESTABLE view-model core — a pure handle_key (keypress → intended Actions) plus apply (mutate TuiState for ANY action, whether it came from a keypress or from an interactive handler’s request landing on the bridge channels — see crate::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 no ratatui::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§

HistorySearchState
Cross-session prompt-history search state (Ctrl+R), live while TuiState::input_focus is InputFocus::HistorySearch.
KeyEvent
A Key plus 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_key for every action, with any Self::with_overrides substitutions applied.
PendingApprovalRequest
One Ask-tier request from the TOP-LEVEL agent’s own permissions gate (P5-1’s crate::permissions::PermissionsApprovalHandler::ask), waiting on Self::reply_tx for the TUI’s decision. ask blocks the calling thread on the paired Receiver until a reply arrives (or the sending end is dropped — see crate::tui::handlers::TuiApprovalHandler’s doc comment for why that’s still fail-closed).
PendingChildApproval
The child-spawn analog of PendingApprovalRequest (P5-3 §2.2 C6, closed by crate::tui::handlers::TuiChildApprovalHandler): additionally carries which BACKGROUND CHILD raised the request, since a parent may have several background children in flight at once.
PendingElicitation
One server→client elicitation/create request (crate::mcp::ElicitationRequest), waiting on Self::reply_tx for the user’s answer. Uses a tokio::sync::oneshot (not std::sync::mpsc like the two structs above) because crate::mcp::McpElicitationHandler::handle is an ASYNC trait method — it .awaits the reply rather than blocking a thread.
PendingOAuthDisplay
The OAuth device-code flow’s ONE-TIME display push (P5-2’s run_device_flow’s on_prompt callback) — 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.
PromptHistory
A persisted, searchable prompt history.
StatusLine
The status line’s contents — deliberately minimal (a renderer decorates this, doesn’t reinterpret it).
TranscriptEntry
One line (or block) in the scrollback transcript.
TuiApprovalHandler
P5-1’s interactive ask-UI, closing the deferred chain crate::permissions::approval’s module doc comment names. ask pushes the request onto tx and blocks on a fresh one-shot reply channel; Self::tx being closed (the render loop is gone) makes the blocking recv() return an Err, which resolves to ApprovalOutcome::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/cli embedder uses: constructs every channel pair once, installs the SENDING halves onto the Agent/ McpClients that need them, and hands the RECEIVING halves to the render loop to poll each frame. See crate::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 first Ask-tier prompt or elicitation forever.
TuiChildApprovalHandler
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-blocking crate::subagents::ParentQueueApprovalHandler. Also records every request into the shared queue (the SAME Agent::pending_child_approvals audit trail ParentQueueApprovalHandler itself writes to), so crate::agent::Agent::pending_child_approvals stays a complete audit log regardless of which handler answered a given request.
TuiElicitationHandler
P5-2’s interactive elicitation UI, closing the deferred chain crate::mcp::HeadlessElicitationHandler’s doc comment names. handle is ASYNC (the MCP trait’s own shape), so this uses a tokio::sync:: oneshot reply 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 to crate::mcp::ElicitationAction::Cancel (the MCP spec’s own “dismissed without a decision” outcome — the honest shape for “nobody answered”, distinct from an explicit Decline).
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_key and the input to TuiState::apply. Not Clone/PartialEq-derived as a whole: the Show*Modal variants embed a one-shot reply channel (std::sync::mpsc::Sender/ tokio::sync::oneshot::Sender, neither of which is PartialEq) — tests assert on the resulting TuiState, not on raw Action equality.
InputFocus
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 past F(12), or a media key) can be added without breaking a downstream match.
KeymapAction
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. Dark is the default (matches Config::tui_theme’s "dark" default).
VimMode
Vim-emulation sub-mode (only consulted when TuiState::vim_enabled is true — see that field’s doc comment for the deliberately-basic scope: hjkl motion, i/a/o mode entry, x/dd deletion. 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-level tui module doc comment’s “shippable vs staged” note.

Functions§

should_activate
Whether the TUI should activate for this process: config.tui_enabled AND stdin/stdout/stderr are all a real terminal. crates/cli’s chat() 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 sets capabilities.tui.enabled = true) always falls through to the pre-P5-4 REPL because THIS check fails, not because the config bit is off.