supercode_harness/tui/mod.rs
1//! P5-4 (COMPOSABLE-HARNESS-DESIGN.md §2 module 30 `tui`; §1.9 recorded
2//! deviation; §2.1 `tools.question`/`permissions.approvals`(ask-UI) →
3//! `tui`|`server`): the full-screen interactive TUI, AND — because §2.1
4//! names it as the interactive surface three EARLIER phases explicitly
5//! deferred here — the home for the three handlers that close those
6//! deferred chains:
7//!
8//! 1. **P5-1's approval ask-UI** — [`handlers::TuiApprovalHandler`]
9//! implements `crate::permissions::PermissionsApprovalHandler`.
10//! 2. **P5-2's elicitation prompt** (+ OAuth device-code display) —
11//! [`handlers::TuiElicitationHandler`] implements
12//! `crate::mcp::McpElicitationHandler`;
13//! [`bridge::PendingOAuthDisplay`] carries the device-code modal push.
14//! 3. **P5-3's parent-answerable child approvals** —
15//! [`handlers::TuiChildApprovalHandler`], installed via
16//! `crate::agent::Agent::set_child_approval_handler_factory` (P5-4's own
17//! new seam — see that method's doc comment for why this can only ever
18//! grant what the rule engine already routed to a prompt, never
19//! escalate past a `Deny`).
20//!
21//! ## Two layers (why this crate only has ONE of them)
22//!
23//! A real terminal can't be driven headlessly in a unit test — so this
24//! module is split in two, and only the TESTABLE half lives here:
25//!
26//! - **The view-model core** ([`state`], [`key`], [`keymap`], [`theme`],
27//! [`history`], [`bridge`], [`handlers`]) — a pure `handle_key(KeyEvent)
28//! -> Vec<Action>` / `apply(Action)` state machine plus the three
29//! interactive handlers above. Zero terminal-library dependency (no
30//! `ratatui`/`crossterm` in `crates/harness`'s `Cargo.toml` at all — see
31//! [`key::KeyEvent`]'s doc comment) — every interaction in this crate is
32//! unit-testable by constructing a [`key::KeyEvent`] by hand.
33//! - **The render + event-loop layer** lives in `crates/cli/src/tui/` (a
34//! binary-crate concern: `ratatui` + `crossterm`, an alternate-screen
35//! terminal, real keyboard/paste input). It translates real
36//! `crossterm::event::KeyEvent`s into [`key::KeyEvent`], drives
37//! [`state::TuiState`], and renders the result — see that module's own
38//! doc comment for the render loop and its `TestBackend` smoke test.
39//!
40//! ## Activation
41//!
42//! `Config::tui_enabled` (`capabilities.tui.enabled`, default `false`) is
43//! the master gate — `crates/cli`'s `chat()` runs the pre-P5-4 rustyline
44//! REPL loop byte-for-byte when it's off, or when stdin/stdout/stderr
45//! aren't all a real tty (`--print`, a piped/non-interactive invocation,
46//! or any headless test harness) — see [`should_activate`].
47//!
48//! ## Shippable-complete vs honestly-staged
49//!
50//! - **Shippable-complete:** the view-model core (composer editing,
51//! scrollback, streaming accumulation, the three interactive-handler
52//! modals, theme toggle, configurable global keybindings, cross-session
53//! Ctrl+R prompt-history search, BASIC vim emulation), the render/
54//! event-loop layer, image-paste passthrough — a placeholder token +
55//! [`state::TuiState::pending_images`], drained by `crates/cli`'s
56//! render loop via [`state::TuiState::take_pending_images`] and routed
57//! into the turn as real multimodal content (`Agent::send_with_images`)
58//! — external-`$EDITOR` invocation.
59//! - **Honestly staged, not half-built:**
60//! - FULL vim emulation (registers, visual mode, `.`-repeat, counts,
61//! `dd`/`yy`/`p` as real two-keystroke commands rather than the
62//! single-keystroke approximation
63//! [`state::TuiState::handle_key`]'s vim-normal-mode match
64//! implements) — see [`state::VimMode`]'s doc comment.
65//! - OAuth device-code display (`bridge::TuiBridge::oauth_sender` /
66//! [`bridge::PendingOAuthDisplay`] / `Action::ShowOAuthModal`): the
67//! plumbing is complete and tested end-to-end (push → modal → render
68//! → dismiss), but `crates/cli` has no call site that ever SENDS into
69//! it — `attach_mcp`'s OAuth handling (`resolve_oauth_header`) only
70//! ever consults an ALREADY-stored, possibly-refreshed token; a fresh
71//! device-code flow (`mcp_oauth::run_device_flow`) only ever runs via
72//! the separate `supercode mcp login <name>` subcommand, which never
73//! activates the TUI. So a TUI session currently never has a moment
74//! where a device code needs displaying at all — wiring one in would
75//! mean teaching `attach_mcp` to trigger a live, session-startup-
76//! blocking device-code flow when no cached token exists, a real
77//! interactive-flow design decision (timeout? cancel-and-continue
78//! without the server? non-interactive callers?) out of scope for
79//! this module's own render-loop-and-handlers job. Kept (not removed)
80//! as the ready seam for whichever future unit makes that call —
81//! zero cost while unused, the same "installing a handler alone
82//! changes nothing" posture every other optional seam in this crate
83//! has. Cited as a follow-up for this module, not a broken partial
84//! implementation — same posture as the vim item above.
85
86pub mod bridge;
87pub mod handlers;
88pub mod history;
89pub mod key;
90pub mod keymap;
91pub mod state;
92pub mod theme;
93
94pub use bridge::{
95 PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
96};
97pub use handlers::{TuiApprovalHandler, TuiBridge, TuiChildApprovalHandler, TuiElicitationHandler};
98pub use history::PromptHistory;
99pub use key::{Key, KeyEvent};
100pub use keymap::{Keymap, KeymapAction};
101pub use state::{
102 Action, HistorySearchState, InputFocus, Modal, Role, StatusLine, TranscriptEntry, TuiState,
103 VimMode,
104};
105pub use theme::Theme;
106
107/// Whether the TUI should activate for this process: `config.tui_enabled`
108/// AND stdin/stdout/stderr are all a real terminal. `crates/cli`'s `chat()`
109/// is the sole call site that matters for the "default-off / non-tty /
110/// `--print` = byte-identical REPL" contract (P5-4's build brief) — a
111/// piped/non-interactive invocation (virtually every CI/test run,
112/// regardless of whether a parity preset sets `capabilities.tui.enabled =
113/// true`) always falls through to the pre-P5-4 REPL because THIS check
114/// fails, not because the config bit is off.
115///
116/// Takes the three tty booleans as explicit parameters (rather than
117/// calling `std::io::IsTerminal` itself) so this stays testable without a
118/// real terminal — `crates/cli` passes
119/// `io::stdin().is_terminal()`/`io::stdout().is_terminal()`/
120/// `io::stderr().is_terminal()` at the real call site.
121pub fn should_activate(
122 tui_enabled: bool,
123 stdin_tty: bool,
124 stdout_tty: bool,
125 stderr_tty: bool,
126) -> bool {
127 tui_enabled && stdin_tty && stdout_tty && stderr_tty
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn should_activate_requires_the_config_bit_and_every_tty_check() {
136 assert!(should_activate(true, true, true, true));
137 assert!(!should_activate(false, true, true, true));
138 assert!(!should_activate(true, false, true, true));
139 assert!(!should_activate(true, true, false, true));
140 assert!(!should_activate(true, true, true, false));
141 }
142}