Skip to main content

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}