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.

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) 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.

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.