supercode_harness/tui/handlers.rs
1//! P5-4's three load-bearing interactive handlers — the THIS-MODULE side of
2//! the three deferred chains P5-1/P5-2/P5-3 each explicitly left for `tui`:
3//!
4//! 1. [`TuiApprovalHandler`] implements
5//! [`crate::permissions::PermissionsApprovalHandler`] (P5-1's seam):
6//! installed via [`crate::agent::Agent::set_permissions_approval_handler`].
7//! 2. [`TuiElicitationHandler`] implements [`crate::mcp::McpElicitationHandler`]
8//! (P5-2's seam): installed via
9//! [`crate::mcp::McpClient::set_elicitation_handler`].
10//! 3. [`TuiChildApprovalHandler`] — also a `PermissionsApprovalHandler`,
11//! installed via the factory
12//! [`crate::agent::Agent::set_child_approval_handler_factory`] (P5-4's
13//! OWN new seam, added specifically to close P5-3's §2.2 C6 "queued,
14//! never-blocking" chain into a genuinely answerable one).
15//!
16//! All three follow the same shape: push a `Pending*` request (from
17//! [`crate::tui::bridge`]) onto a channel the render loop polls, then block
18//! (synchronously for the two `PermissionsApprovalHandler`s — `ask` is a
19//! sync trait method by design, see that trait's doc comment; via `.await`
20//! for the async `McpElicitationHandler`) for the reply. If the reply
21//! channel is ever dropped without a reply (the render loop panicked, or
22//! the TUI process is shutting down mid-request), every handler here
23//! resolves to the FAIL-CLOSED outcome (`Deny`/decline) — never a hang,
24//! and never an implicit allow.
25//!
26//! **Security invariant (repeated at each handler below).** None of these
27//! handlers can escalate what the permissions/elicitation engines already
28//! decided: [`crate::permissions::approval::resolve_ask`] only ever calls a
29//! `PermissionsApprovalHandler::ask` when the rule engine already resolved
30//! the call to `Ask` (`Deny` short-circuits before any handler runs;
31//! `Allow` never needs one) — a TUI "allow" here can only grant what the
32//! policy already routed to a human prompt. Likewise an elicitation answer
33//! is exactly what the user typed into the modal — never fabricated,
34//! never auto-accepted.
35
36use std::sync::{mpsc, Arc, Mutex};
37
38use async_trait::async_trait;
39
40use crate::mcp::{ElicitationRequest, ElicitationResponse, McpElicitationHandler};
41use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
42use crate::subagents::QueuedApproval;
43
44use super::bridge::{
45 PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
46};
47
48/// P5-1's interactive ask-UI, closing the deferred chain
49/// `crate::permissions::approval`'s module doc comment names. `ask` pushes
50/// the request onto `tx` and blocks on a fresh one-shot reply channel;
51/// `Self::tx` being closed (the render loop is gone) makes the blocking
52/// `recv()` return an `Err`, which resolves to
53/// [`ApprovalOutcome::Deny`] — fail-closed, matching the trait's own "no
54/// handler ⇒ deny" default posture for the "handler installed but
55/// unreachable" case too.
56pub struct TuiApprovalHandler {
57 tx: mpsc::Sender<PendingApprovalRequest>,
58}
59
60impl TuiApprovalHandler {
61 /// Construct a handler that feeds `tx` — the matching `Receiver` half
62 /// is [`TuiBridge::approval_rx`].
63 pub fn new(tx: mpsc::Sender<PendingApprovalRequest>) -> Self {
64 TuiApprovalHandler { tx }
65 }
66}
67
68impl PermissionsApprovalHandler for TuiApprovalHandler {
69 fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome {
70 let (reply_tx, reply_rx) = mpsc::channel();
71 let pending = PendingApprovalRequest {
72 tool: req.tool.to_string(),
73 subject: req.subject.map(String::from),
74 raw_args: req.raw_args.clone(),
75 reply_tx,
76 };
77 if self.tx.send(pending).is_err() {
78 return ApprovalOutcome::Deny;
79 }
80 reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
81 }
82}
83
84/// P5-3's answerable child-approval handler, closing the §2.2 C6 deferred
85/// chain — see [`crate::agent::Agent::set_child_approval_handler_factory`]'s
86/// doc comment for how this REPLACES (only when installed) the default
87/// never-blocking [`crate::subagents::ParentQueueApprovalHandler`]. Also
88/// records every request into the shared `queue` (the SAME
89/// `Agent::pending_child_approvals` audit trail `ParentQueueApprovalHandler`
90/// itself writes to), so [`crate::agent::Agent::pending_child_approvals`]
91/// stays a complete audit log regardless of which handler answered a given
92/// request.
93pub struct TuiChildApprovalHandler {
94 child_agent_id: String,
95 queue: Arc<Mutex<Vec<QueuedApproval>>>,
96 tx: mpsc::Sender<PendingChildApproval>,
97}
98
99impl TuiChildApprovalHandler {
100 /// Construct a per-child handler — see
101 /// `TuiBridge::child_approval_handler_factory` for the usual way one
102 /// of these gets built (one per spawn, via
103 /// [`crate::agent::Agent::set_child_approval_handler_factory`]).
104 pub fn new(
105 child_agent_id: String,
106 queue: Arc<Mutex<Vec<QueuedApproval>>>,
107 tx: mpsc::Sender<PendingChildApproval>,
108 ) -> Self {
109 TuiChildApprovalHandler {
110 child_agent_id,
111 queue,
112 tx,
113 }
114 }
115}
116
117impl PermissionsApprovalHandler for TuiChildApprovalHandler {
118 fn ask(&self, req: &ApprovalRequest) -> ApprovalOutcome {
119 // Recorded with no outcome: this handler genuinely blocks, so the
120 // entry IS a pending request until the operator answers it.
121 let queued = crate::subagents::queue_approval(
122 &self.queue,
123 QueuedApproval {
124 child_agent_id: self.child_agent_id.clone(),
125 tool: req.tool.to_string(),
126 subject: req.subject.map(String::from),
127 queued_at_ms: now_ms(),
128 outcome: None,
129 },
130 );
131 let (reply_tx, reply_rx) = mpsc::channel();
132 let pending = PendingChildApproval {
133 child_agent_id: self.child_agent_id.clone(),
134 tool: req.tool.to_string(),
135 subject: req.subject.map(String::from),
136 raw_args: req.raw_args.clone(),
137 reply_tx,
138 };
139 let outcome = if self.tx.send(pending).is_err() {
140 ApprovalOutcome::Deny
141 } else {
142 reply_rx.recv().unwrap_or(ApprovalOutcome::Deny)
143 };
144 if let Some(index) = queued {
145 crate::subagents::record_queued_outcome(&self.queue, index, outcome.into());
146 }
147 outcome
148 }
149}
150
151fn now_ms() -> i64 {
152 std::time::SystemTime::now()
153 .duration_since(std::time::UNIX_EPOCH)
154 .map(|d| d.as_millis() as i64)
155 .unwrap_or(0)
156}
157
158/// P5-2's interactive elicitation UI, closing the deferred chain
159/// [`crate::mcp::HeadlessElicitationHandler`]'s doc comment names. `handle`
160/// is ASYNC (the MCP trait's own shape), so this uses a `tokio::sync::
161/// oneshot` reply rather than blocking a thread — awaiting it yields the
162/// executor to other work while the modal is up. A dropped reply sender
163/// (render loop gone) resolves to [`crate::mcp::ElicitationAction::Cancel`]
164/// (the MCP spec's own "dismissed without a decision" outcome — the
165/// honest shape for "nobody answered", distinct from an explicit
166/// `Decline`).
167pub struct TuiElicitationHandler {
168 tx: mpsc::Sender<PendingElicitation>,
169}
170
171impl TuiElicitationHandler {
172 /// Construct a handler that feeds `tx` — the matching `Receiver` half
173 /// is [`TuiBridge::elicitation_rx`].
174 pub fn new(tx: mpsc::Sender<PendingElicitation>) -> Self {
175 TuiElicitationHandler { tx }
176 }
177}
178
179#[async_trait]
180impl McpElicitationHandler for TuiElicitationHandler {
181 async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse {
182 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
183 let pending = PendingElicitation {
184 message: request.message.clone(),
185 requested_schema: request.requested_schema.clone(),
186 reply_tx,
187 };
188 if self.tx.send(pending).is_err() {
189 return ElicitationResponse {
190 action: crate::mcp::ElicitationAction::Cancel,
191 content: None,
192 };
193 }
194 reply_rx.await.unwrap_or(ElicitationResponse {
195 action: crate::mcp::ElicitationAction::Cancel,
196 content: None,
197 })
198 }
199}
200
201/// The aggregate wiring point a `crates/cli` embedder uses: constructs
202/// every channel pair once, installs the SENDING halves onto the `Agent`/
203/// `McpClient`s that need them, and hands the RECEIVING halves to the
204/// render loop to poll each frame. See [`crate::tui::should_activate`]'s
205/// doc comment for why this is only ever built when the TUI is confirmed
206/// active — installing these on an agent that no render loop is draining
207/// would hang the first `Ask`-tier prompt or elicitation forever.
208pub struct TuiBridge {
209 approval_tx: mpsc::Sender<PendingApprovalRequest>,
210 /// Poll this each render frame (`try_recv`) for a new top-level
211 /// approval prompt to show.
212 pub approval_rx: mpsc::Receiver<PendingApprovalRequest>,
213 child_approval_tx: mpsc::Sender<PendingChildApproval>,
214 /// Poll this each render frame for a new child-approval prompt.
215 pub child_approval_rx: mpsc::Receiver<PendingChildApproval>,
216 elicitation_tx: mpsc::Sender<PendingElicitation>,
217 /// Poll this each render frame for a new elicitation prompt.
218 pub elicitation_rx: mpsc::Receiver<PendingElicitation>,
219 oauth_tx: mpsc::Sender<PendingOAuthDisplay>,
220 /// Poll this each render frame for a new OAuth device-code display.
221 pub oauth_rx: mpsc::Receiver<PendingOAuthDisplay>,
222}
223
224impl Default for TuiBridge {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230impl TuiBridge {
231 /// Build a fresh bridge — four independent channel pairs, all cheap
232 /// (unbounded `mpsc`, no background threads spawned here).
233 pub fn new() -> Self {
234 let (approval_tx, approval_rx) = mpsc::channel();
235 let (child_approval_tx, child_approval_rx) = mpsc::channel();
236 let (elicitation_tx, elicitation_rx) = mpsc::channel();
237 let (oauth_tx, oauth_rx) = mpsc::channel();
238 TuiBridge {
239 approval_tx,
240 approval_rx,
241 child_approval_tx,
242 child_approval_rx,
243 elicitation_tx,
244 elicitation_rx,
245 oauth_tx,
246 oauth_rx,
247 }
248 }
249
250 /// Install this bridge's top-level-approval and child-approval-factory
251 /// handlers onto `agent`. Does NOT touch MCP elicitation — an
252 /// `McpClient` needs [`Self::elicitation_handler`] installed on IT
253 /// directly (before the client is consumed into tool registration),
254 /// which is why that's a separate method the caller invokes per
255 /// client, earlier in its own connect sequence.
256 pub fn install_on(&self, agent: &mut crate::agent::Agent) {
257 agent.set_permissions_approval_handler(TuiApprovalHandler::new(self.approval_tx.clone()));
258 // BP-3 (§2 module 6 `tools.question`): the TUI is the other
259 // interactive surface §2.1's `tools_question → tui|server` edge
260 // names, so `ask_user` asks through the SAME overlay an MCP
261 // elicitation already uses here.
262 agent.set_user_question_handler(self.elicitation_handler());
263 agent.set_child_approval_handler_factory({
264 let tx = self.child_approval_tx.clone();
265 move |child_id, queue| {
266 Arc::new(TuiChildApprovalHandler::new(child_id, queue, tx.clone()))
267 as Arc<dyn PermissionsApprovalHandler>
268 }
269 });
270 }
271
272 /// A fresh [`TuiElicitationHandler`] wired to this bridge — install on
273 /// each `McpClient` via
274 /// [`crate::mcp::McpClient::set_elicitation_handler`] before that
275 /// client is consumed into tool registration.
276 pub fn elicitation_handler(&self) -> Arc<dyn McpElicitationHandler> {
277 Arc::new(TuiElicitationHandler::new(self.elicitation_tx.clone()))
278 }
279
280 /// The sender half for a one-shot OAuth device-code display push (P5-2
281 /// device flow's `on_prompt` callback) — see `crates/cli`'s OAuth login
282 /// wiring for the call site.
283 pub fn oauth_sender(&self) -> mpsc::Sender<PendingOAuthDisplay> {
284 self.oauth_tx.clone()
285 }
286}