supercode_harness/tui/state.rs
1//! P5-4 (§2 module 30): the TESTABLE view-model core — a pure `handle_key`
2//! (keypress → intended [`Action`]s) plus `apply` (mutate [`TuiState`] for
3//! ANY action, whether it came from a keypress or from an interactive
4//! handler's request landing on the bridge channels — see
5//! [`crate::tui::handlers`]). Neither function touches a terminal; every
6//! test in this module drives the whole thing by hand.
7
8use std::collections::VecDeque;
9
10use crate::mcp::{ElicitationAction, ElicitationResponse};
11use crate::permissions::ApprovalOutcome;
12
13use super::bridge::{
14 PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
15};
16use super::history::PromptHistory;
17use super::key::{Key, KeyEvent};
18use super::keymap::{Keymap, KeymapAction};
19use super::theme::Theme;
20
21/// Who said one transcript line.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Role {
24 /// The human operator.
25 User,
26 /// The model's own text.
27 Assistant,
28 /// A tool call/result rendered as chrome (not model-authored text).
29 Tool,
30 /// A TUI-local notice (a resolved approval, a mode change, …) — never
31 /// sent to the model, purely for the human's own record.
32 System,
33}
34
35/// One line (or block) in the scrollback transcript.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TranscriptEntry {
38 /// Who said it.
39 pub role: Role,
40 /// The line's text.
41 pub text: String,
42}
43
44/// Vim-emulation sub-mode (only consulted when [`TuiState::vim_enabled`] is
45/// `true` — see that field's doc comment for the deliberately-basic scope:
46/// `hjkl` motion, `i`/`a`/`o` mode entry, `x`/`dd` deletion. This is NOT a
47/// full vim emulation (no registers, no visual mode, no `.`-repeat, no
48/// counts) — a shippable-complete BASIC modal editor, with full vim cited
49/// as a follow-up rather than half-built. See the crate-level `tui` module
50/// doc comment's "shippable vs staged" note.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum VimMode {
53 /// Every key inserts/edits (the default when vim emulation is off, or
54 /// the sub-mode `i`/`a`/`o` enter).
55 #[default]
56 Insert,
57 /// Motion/command keys (`h`/`l`/`x`/`d`/…) — the mode `Esc` returns to.
58 Normal,
59}
60
61/// What the input composer is showing right now.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum InputFocus {
64 /// The normal text-entry composer.
65 #[default]
66 Composer,
67 /// Ctrl+R cross-session prompt-history search is live.
68 HistorySearch,
69}
70
71/// Cross-session prompt-history search state (Ctrl+R), live while
72/// [`TuiState::input_focus`] is [`InputFocus::HistorySearch`].
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct HistorySearchState {
75 /// What the user has typed to narrow the search.
76 pub query: String,
77 /// Index into the CURRENT `history.search(query)` result list — `0` is
78 /// the most recent match.
79 pub selected: usize,
80}
81
82/// An interactive modal covering the composer — at most one at a time
83/// (§2.28: approval / elicitation / child-approval / OAuth-code-display).
84/// A later request queues behind the earlier one still on screen (see
85/// `TuiState::modal_queue`) rather than clobbering it.
86#[derive(Debug)]
87pub enum Modal {
88 /// P5-1's `Ask`-tier decision, surfaced interactively (closes the
89 /// deferred `tui`-implements-the-ask-UI chain).
90 Approval(PendingApprovalRequest),
91 /// P5-3 §2.2 C6's queued child approval, now answerable (closes that
92 /// deferred chain).
93 ChildApproval(PendingChildApproval),
94 /// P5-2's MCP `elicitation/create`, with the free-text answer buffer
95 /// the (deliberately basic — see the crate doc comment) form widget
96 /// accumulates.
97 Elicitation {
98 /// The server's request (message + schema) plus reply channel.
99 request: PendingElicitation,
100 /// The free-text answer typed into the modal so far.
101 answer: String,
102 },
103 /// P5-2's OAuth device-code display — no reply, dismiss-only.
104 OAuthDeviceCode(PendingOAuthDisplay),
105}
106
107/// The status line's contents — deliberately minimal (a renderer decorates
108/// this, doesn't reinterpret it).
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct StatusLine {
111 /// The active model's display label.
112 pub model_label: String,
113 /// Whether a turn is currently in flight.
114 pub turn_active: bool,
115 /// A one-shot notice (e.g. "press Ctrl+C again to exit") — cleared by
116 /// the NEXT keypress that doesn't re-arm it, so it never lingers stale.
117 pub notice: Option<String>,
118}
119
120/// A pure description of a state transition — the output of
121/// [`TuiState::handle_key`] and the input to [`TuiState::apply`]. Not
122/// `Clone`/`PartialEq`-derived as a whole: the `Show*Modal` variants embed
123/// a one-shot reply channel ([`std::sync::mpsc::Sender`]/
124/// `tokio::sync::oneshot::Sender`, neither of which is `PartialEq`) — tests
125/// assert on the resulting [`TuiState`], not on raw `Action` equality.
126#[derive(Debug)]
127pub enum Action {
128 /// Insert one character at the cursor.
129 InsertChar(char),
130 /// Delete the character before the cursor.
131 Backspace,
132 /// Delete the character at (after) the cursor.
133 DeleteForward,
134 /// Move the cursor one character left.
135 MoveLeft,
136 /// Move the cursor one character right.
137 MoveRight,
138 /// Move the cursor to the start of the composer.
139 MoveHome,
140 /// Move the cursor to the end of the composer.
141 MoveEnd,
142 /// Insert a newline without submitting (multi-line composing).
143 Newline,
144 /// Clear the composer buffer without submitting (non-empty-line
145 /// Ctrl+C, and vim `dd`).
146 ClearComposerLine,
147 /// The composer's contents were submitted — `apply` clears the
148 /// composer, appends the text to history + the transcript. The CLI
149 /// event loop is the one that actually calls `agent.send(text)`; it
150 /// sees this variant in `handle_key`'s returned `Vec<Action>` BEFORE
151 /// calling `apply` (see the module doc comment on the render layer).
152 Submit(String),
153 /// Scroll the transcript up one page.
154 ScrollUp,
155 /// Scroll the transcript down one page.
156 ScrollDown,
157 /// Toggle the dark/light theme.
158 ToggleTheme,
159 /// Enter Ctrl+R cross-session prompt-history search.
160 OpenHistorySearch,
161 /// Type one character into the history-search query.
162 HistorySearchType(char),
163 /// Delete the last character of the history-search query.
164 HistorySearchBackspace,
165 /// Select the next (older) matching history entry.
166 HistorySearchNext,
167 /// Select the previous (more recent) matching history entry.
168 HistorySearchPrev,
169 /// Accept the selected history entry into the composer.
170 HistorySearchConfirm,
171 /// Leave history search without changing the composer.
172 HistorySearchCancel,
173 /// Switch vim sub-mode (Normal/Insert).
174 VimSetMode(VimMode),
175 /// Vim `h` — move left.
176 VimMoveLeft,
177 /// Vim `l` — move right.
178 VimMoveRight,
179 /// Vim `0` — move to line start.
180 VimMoveHome,
181 /// Vim `$` — move to line end.
182 VimMoveEnd,
183 /// Vim `x` — delete the character under the cursor.
184 VimDeleteChar,
185 /// Vim `dd` (approximated as one keystroke — see [`VimMode`]'s doc
186 /// comment) — clear the composer line.
187 VimDeleteLine,
188 /// The CLI layer should shell out to `$EDITOR` — `apply` only flips a
189 /// flag ([`TuiState::external_editor_requested`]); the actual process
190 /// spawn is terminal I/O, out of the view-model's scope.
191 RequestExternalEditor,
192 /// The CLI layer's `$EDITOR` invocation finished — replaces the
193 /// composer with the edited text.
194 ExternalEditorResult(String),
195 /// F9 (Fable-5 adversarial review — LOW): the CLI layer's `$EDITOR`
196 /// invocation failed to spawn (editor not found, exec error, …) —
197 /// clears [`TuiState::external_editor_requested`] (same as
198 /// `ExternalEditorResult`, so `run_loop` doesn't keep retrying it
199 /// every tick) WITHOUT touching the composer, and surfaces `message`
200 /// as a system transcript notice so the session stays alive and the
201 /// user actually sees why nothing happened, instead of the whole TUI
202 /// process exiting out from under them.
203 ExternalEditorFailed(String),
204 /// An image was pasted/attached (path or a data reference) — appended
205 /// to the composer as a placeholder token and recorded in
206 /// [`TuiState::pending_images`] for the CLI layer to route into the
207 /// multimodal read path.
208 PasteImage(String),
209 /// A new top-level `Ask`-tier approval request arrived — show (or
210 /// queue) it as a modal.
211 ShowApprovalModal(PendingApprovalRequest),
212 /// The user resolved the active approval modal.
213 ResolveApproval(ApprovalOutcome),
214 /// A new background-child approval request arrived — show (or queue)
215 /// it as a modal.
216 ShowChildApprovalModal(PendingChildApproval),
217 /// The user resolved the active child-approval modal.
218 ResolveChildApproval(ApprovalOutcome),
219 /// A new MCP elicitation request arrived — show (or queue) it as a
220 /// modal.
221 ShowElicitationModal(PendingElicitation),
222 /// Type one character into the elicitation answer buffer.
223 ElicitationType(char),
224 /// Delete the last character of the elicitation answer buffer.
225 ElicitationBackspace,
226 /// Accept the elicitation with the typed answer.
227 ResolveElicitationAccept,
228 /// Decline the elicitation.
229 ResolveElicitationDecline,
230 /// Cancel/dismiss the elicitation without a decision.
231 ResolveElicitationCancel,
232 /// A new OAuth device-code display arrived — show (or queue) it.
233 ShowOAuthModal(PendingOAuthDisplay),
234 /// Dismiss the OAuth device-code modal (no reply — see
235 /// [`PendingOAuthDisplay`]'s doc comment).
236 DismissOAuthModal,
237 /// A chunk of streaming assistant text arrived.
238 AppendStreamingDelta(String),
239 /// The in-progress streaming turn is done — move it into the
240 /// transcript.
241 FinalizeStreaming,
242 /// Append one entry directly to the transcript (tool events, system
243 /// notices raised outside a modal resolution).
244 PushTranscript(TranscriptEntry),
245 /// Update the status line's model label.
246 SetModelLabel(String),
247 /// Update the status line's turn-in-flight indicator.
248 SetTurnActive(bool),
249 /// First Ctrl+C on an empty, non-modal composer — arms the "press
250 /// again to exit" notice without quitting yet.
251 ArmQuit,
252 /// Second consecutive Ctrl+C — actually quit.
253 Quit,
254 /// A key the current focus/modal doesn't bind to anything — carries no
255 /// mutation; `apply` is a no-op for it. `handle_key` prefers returning
256 /// an empty `Vec` over this where possible; it exists for the rare
257 /// case a caller wants an explicit "nothing happened" marker (e.g. a
258 /// disabled modal option).
259 Noop,
260}
261
262/// The whole TUI view-model — see the module doc comment. Constructed
263/// fresh per TUI session by the CLI render layer; every mutation goes
264/// through [`Self::apply`].
265#[derive(Debug)]
266pub struct TuiState {
267 /// The composer's current text.
268 pub input: String,
269 /// Byte offset into `input` — always on a `char` boundary.
270 pub cursor: usize,
271 /// The scrollback transcript, oldest first.
272 pub transcript: Vec<TranscriptEntry>,
273 /// In-progress assistant text (streaming) — `None` when no turn is
274 /// mid-flight.
275 pub streaming: Option<String>,
276 /// The currently-showing modal, if any.
277 pub modal: Option<Modal>,
278 /// Requests that arrived while a modal was already showing — FIFO,
279 /// drained into `modal` by [`Self::dequeue_modal`] once the current one
280 /// resolves.
281 modal_queue: VecDeque<Modal>,
282 /// The active display theme.
283 pub theme: Theme,
284 /// The resolved (default + overrides) keybinding table.
285 pub keymap: Keymap,
286 /// D8 "vim" — whether modal editing is active at all
287 /// ([`crate::Config::tui_vim_mode`]). `false` (the default): every key
288 /// is a plain insert/navigate, [`VimMode`] is never consulted.
289 pub vim_enabled: bool,
290 /// The current vim sub-mode (only meaningful when `vim_enabled`).
291 pub vim_mode: VimMode,
292 /// The cross-session prompt history.
293 pub history: PromptHistory,
294 /// Live Ctrl+R search state, if [`Self::input_focus`] is
295 /// [`InputFocus::HistorySearch`].
296 pub history_search: Option<HistorySearchState>,
297 /// What the composer area is currently showing.
298 pub input_focus: InputFocus,
299 /// The status line's contents.
300 pub status: StatusLine,
301 /// Current transcript scroll offset (pages back from the bottom).
302 pub scroll: usize,
303 /// Set once the user has asked to quit — the render loop's exit
304 /// signal.
305 pub should_quit: bool,
306 /// Whether the FIRST of a double-Ctrl+C-to-quit has already landed.
307 quit_armed: bool,
308 /// Set while the CLI layer's `$EDITOR` invocation is in flight.
309 pub external_editor_requested: bool,
310 /// Image references pasted into the composer, in submission order —
311 /// drained by the CLI layer once it reads [`Action::Submit`].
312 pub pending_images: Vec<String>,
313 /// Set by `apply(Action::Submit(text))` to `Some(text)` — the CLI event
314 /// loop's ONE polling point for "a turn needs to be sent": call
315 /// [`Self::take_submission`] after every [`Self::on_key`] (or manual
316 /// `apply`) to both read and clear it in one step, so a submission is
317 /// never double-sent.
318 pub last_submission: Option<String>,
319}
320
321impl TuiState {
322 /// A fresh, empty state — `theme`/`vim_enabled`/`keymap` typically come
323 /// from the resolved [`crate::Config`] (`tui_theme`/`tui_vim_mode`/
324 /// `tui_keymap`), `history` from [`PromptHistory::load_from_file`].
325 pub fn new(theme: Theme, keymap: Keymap, vim_enabled: bool, history: PromptHistory) -> Self {
326 TuiState {
327 input: String::new(),
328 cursor: 0,
329 transcript: Vec::new(),
330 streaming: None,
331 modal: None,
332 modal_queue: VecDeque::new(),
333 theme,
334 keymap,
335 vim_enabled,
336 vim_mode: if vim_enabled {
337 VimMode::Normal
338 } else {
339 VimMode::Insert
340 },
341 history,
342 history_search: None,
343 input_focus: InputFocus::Composer,
344 status: StatusLine::default(),
345 scroll: 0,
346 should_quit: false,
347 quit_armed: false,
348 external_editor_requested: false,
349 pending_images: Vec::new(),
350 last_submission: None,
351 }
352 }
353
354 /// Convenience for a caller that doesn't need `Default::default()`-style
355 /// construction control — plain-mode, dark theme, default keymap, empty
356 /// history. Handy for tests and the render layer's smoke-test harness.
357 pub fn new_default() -> Self {
358 TuiState::new(
359 Theme::default(),
360 Keymap::default(),
361 false,
362 PromptHistory::new(),
363 )
364 }
365
366 /// Translate one keypress into the [`Action`]s it produces — READS
367 /// state (to be context-sensitive: a modal open, history-search
368 /// active, vim normal-mode all change what a key means) but never
369 /// mutates it. Call [`Self::apply`] on each returned action (in order)
370 /// to actually realize the transition — the render layer's `on_key`
371 /// convenience does exactly that.
372 pub fn handle_key(&self, key: KeyEvent) -> Vec<Action> {
373 if let Some(modal) = &self.modal {
374 return self.handle_key_in_modal(modal, key);
375 }
376 match self.input_focus {
377 InputFocus::HistorySearch => self.handle_key_in_history_search(key),
378 InputFocus::Composer => self.handle_key_in_composer(key),
379 }
380 }
381
382 fn handle_key_in_composer(&self, key: KeyEvent) -> Vec<Action> {
383 let km = &self.keymap;
384 if key == km.key_for(KeymapAction::Quit) {
385 return if self.input.is_empty() {
386 if self.quit_armed {
387 vec![Action::Quit]
388 } else {
389 vec![Action::ArmQuit]
390 }
391 } else {
392 // Non-empty composer: Ctrl+C clears the line (matches the
393 // pre-P5-4 REPL's own "a lone idle Ctrl-C only clears the
394 // current line" convention — see `crates/cli/src/main.rs`
395 // `chat()`'s doc comment).
396 vec![Action::MoveHome, Action::ClearComposerLine]
397 };
398 }
399 if key == km.key_for(KeymapAction::HistorySearch) {
400 return vec![Action::OpenHistorySearch];
401 }
402 if key == km.key_for(KeymapAction::ToggleTheme) {
403 return vec![Action::ToggleTheme];
404 }
405 if key == km.key_for(KeymapAction::ScrollUp) {
406 return vec![Action::ScrollUp];
407 }
408 if key == km.key_for(KeymapAction::ScrollDown) {
409 return vec![Action::ScrollDown];
410 }
411 if key == km.key_for(KeymapAction::ExternalEditor) {
412 return vec![Action::RequestExternalEditor];
413 }
414 if key == km.key_for(KeymapAction::Newline) {
415 return vec![Action::Newline];
416 }
417 if self.vim_enabled && self.vim_mode == VimMode::Normal {
418 return self.handle_key_vim_normal(key);
419 }
420 if key == km.key_for(KeymapAction::Submit) {
421 if self.input.is_empty() {
422 return vec![];
423 }
424 return vec![Action::Submit(self.input.clone())];
425 }
426 match key.key {
427 Key::Char(c) if !key.ctrl && !key.alt => vec![Action::InsertChar(c)],
428 Key::Backspace => vec![Action::Backspace],
429 Key::Delete => vec![Action::DeleteForward],
430 Key::Left => vec![Action::MoveLeft],
431 Key::Right => vec![Action::MoveRight],
432 Key::Home => vec![Action::MoveHome],
433 Key::End => vec![Action::MoveEnd],
434 Key::Escape if self.vim_enabled => vec![Action::VimSetMode(VimMode::Normal)],
435 _ => vec![],
436 }
437 }
438
439 fn handle_key_vim_normal(&self, key: KeyEvent) -> Vec<Action> {
440 if key.ctrl || key.alt {
441 return vec![];
442 }
443 match key.key {
444 Key::Char('i') => vec![Action::VimSetMode(VimMode::Insert)],
445 Key::Char('a') => vec![Action::VimMoveRight, Action::VimSetMode(VimMode::Insert)],
446 Key::Char('o') => vec![
447 Action::VimMoveEnd,
448 Action::Newline,
449 Action::VimSetMode(VimMode::Insert),
450 ],
451 Key::Char('h') => vec![Action::VimMoveLeft],
452 Key::Char('l') => vec![Action::VimMoveRight],
453 Key::Char('0') => vec![Action::VimMoveHome],
454 Key::Char('$') => vec![Action::VimMoveEnd],
455 Key::Char('x') => vec![Action::VimDeleteChar],
456 // `dd` (delete the whole composer line) is approximated as a
457 // single `d` press — see the crate doc comment's "basic, not
458 // full vim" scope note (no two-keystroke command buffering).
459 Key::Char('d') => vec![Action::VimDeleteLine],
460 Key::Enter if self.input.is_empty() => vec![],
461 Key::Enter => vec![Action::Submit(self.input.clone())],
462 _ => vec![],
463 }
464 }
465
466 fn handle_key_in_history_search(&self, key: KeyEvent) -> Vec<Action> {
467 match key.key {
468 Key::Escape => vec![Action::HistorySearchCancel],
469 Key::Enter => vec![Action::HistorySearchConfirm],
470 Key::Up => vec![Action::HistorySearchPrev],
471 Key::Down => vec![Action::HistorySearchNext],
472 _ if key == self.keymap.key_for(KeymapAction::HistorySearch) => {
473 vec![Action::HistorySearchNext]
474 }
475 Key::Backspace => vec![Action::HistorySearchBackspace],
476 Key::Char(c) if !key.ctrl && !key.alt => vec![Action::HistorySearchType(c)],
477 _ => vec![],
478 }
479 }
480
481 fn handle_key_in_modal(&self, modal: &Modal, key: KeyEvent) -> Vec<Action> {
482 match modal {
483 // F3 (Fable-5 adversarial review — MEDIUM): both approval
484 // arms below now guard on `!key.ctrl && !key.alt`, matching
485 // the elicitation arm's own `Key::Char(c) if !key.ctrl &&
486 // !key.alt` guard further down — WITHOUT it, Ctrl+A (a common
487 // "select all"/readline chord in plenty of other programs)
488 // resolved `Allow`, and Ctrl+S resolved `AllowForSession`, on
489 // a modal whose whole POINT is a deliberate human decision;
490 // a reflexive chord muscle-memoried from another program must
491 // never resolve one.
492 Modal::Approval(_) if !key.ctrl && !key.alt => match key.key {
493 Key::Char('y') | Key::Char('a') => {
494 vec![Action::ResolveApproval(ApprovalOutcome::Allow)]
495 }
496 Key::Char('s') => vec![Action::ResolveApproval(ApprovalOutcome::AllowForSession)],
497 Key::Char('n') | Key::Char('d') | Key::Escape => {
498 vec![Action::ResolveApproval(ApprovalOutcome::Deny)]
499 }
500 _ => vec![],
501 },
502 Modal::Approval(_) => vec![],
503 Modal::ChildApproval(_) if !key.ctrl && !key.alt => match key.key {
504 Key::Char('y') | Key::Char('a') => {
505 vec![Action::ResolveChildApproval(ApprovalOutcome::Allow)]
506 }
507 Key::Char('s') => vec![Action::ResolveChildApproval(
508 ApprovalOutcome::AllowForSession,
509 )],
510 Key::Char('n') | Key::Char('d') | Key::Escape => {
511 vec![Action::ResolveChildApproval(ApprovalOutcome::Deny)]
512 }
513 _ => vec![],
514 },
515 Modal::ChildApproval(_) => vec![],
516 Modal::Elicitation { .. } => match key.key {
517 Key::Enter => vec![Action::ResolveElicitationAccept],
518 Key::Escape => vec![Action::ResolveElicitationCancel],
519 Key::F(2) => vec![Action::ResolveElicitationDecline],
520 Key::Backspace => vec![Action::ElicitationBackspace],
521 Key::Char(c) if !key.ctrl && !key.alt => vec![Action::ElicitationType(c)],
522 _ => vec![],
523 },
524 Modal::OAuthDeviceCode(_) => match key.key {
525 Key::Enter | Key::Escape => vec![Action::DismissOAuthModal],
526 _ => vec![],
527 },
528 }
529 }
530
531 /// Apply one [`Action`] — the only place [`TuiState`] mutates. Any key
532 /// OTHER than the one that just armed `Self::quit_armed` disarms it
533 /// (so "Ctrl+C, type something, Ctrl+C" does NOT quit — only two
534 /// CONSECUTIVE Ctrl+C presses do), except `ArmQuit`/`Quit` themselves.
535 pub fn apply(&mut self, action: Action) {
536 if !matches!(action, Action::ArmQuit | Action::Quit) {
537 if self.quit_armed {
538 self.status.notice = None;
539 }
540 self.quit_armed = false;
541 }
542 match action {
543 Action::InsertChar(c) => {
544 self.input.insert(self.cursor, c);
545 self.cursor += c.len_utf8();
546 }
547 Action::Backspace => {
548 if self.cursor > 0 {
549 let mut idx = self.cursor - 1;
550 while !self.input.is_char_boundary(idx) {
551 idx -= 1;
552 }
553 self.input.remove(idx);
554 self.cursor = idx;
555 }
556 }
557 Action::DeleteForward => {
558 if self.cursor < self.input.len() {
559 self.input.remove(self.cursor);
560 }
561 }
562 Action::MoveLeft => {
563 if self.cursor > 0 {
564 let mut idx = self.cursor - 1;
565 while !self.input.is_char_boundary(idx) {
566 idx -= 1;
567 }
568 self.cursor = idx;
569 }
570 }
571 Action::MoveRight => {
572 if self.cursor < self.input.len() {
573 let mut idx = self.cursor + 1;
574 while idx < self.input.len() && !self.input.is_char_boundary(idx) {
575 idx += 1;
576 }
577 self.cursor = idx;
578 }
579 }
580 Action::MoveHome => self.cursor = 0,
581 Action::MoveEnd => self.cursor = self.input.len(),
582 Action::Newline => {
583 self.input.insert(self.cursor, '\n');
584 self.cursor += 1;
585 }
586 Action::ClearComposerLine => {
587 self.input.clear();
588 self.cursor = 0;
589 }
590 Action::Submit(text) => {
591 self.history.push(text.clone());
592 self.transcript.push(TranscriptEntry {
593 role: Role::User,
594 text: text.clone(),
595 });
596 self.input.clear();
597 self.cursor = 0;
598 // F5 (Fable-5 adversarial review): this used to clear
599 // `pending_images` right here — BEFORE the CLI layer's
600 // render loop ever gets a chance to read `last_submission`
601 // (that only happens on the NEXT tick, via
602 // `Self::take_submission`). A pasted image's path was
603 // gone by the time anything could route it into the turn
604 // — the model only ever saw the literal `[image: …]`
605 // placeholder text. `pending_images` now stays put until
606 // [`Self::take_pending_images`] drains it — see that
607 // method's doc comment for the paired contract.
608 self.last_submission = Some(text);
609 }
610 Action::ScrollUp => self.scroll = self.scroll.saturating_add(1),
611 Action::ScrollDown => self.scroll = self.scroll.saturating_sub(1),
612 Action::ToggleTheme => self.theme = self.theme.toggled(),
613 Action::OpenHistorySearch => {
614 self.input_focus = InputFocus::HistorySearch;
615 self.history_search = Some(HistorySearchState::default());
616 }
617 Action::HistorySearchType(c) => {
618 if let Some(s) = &mut self.history_search {
619 s.query.push(c);
620 s.selected = 0;
621 }
622 }
623 Action::HistorySearchBackspace => {
624 if let Some(s) = &mut self.history_search {
625 s.query.pop();
626 s.selected = 0;
627 }
628 }
629 Action::HistorySearchNext => {
630 if let Some(s) = &mut self.history_search {
631 let n = self.history.search(&s.query).len();
632 if n > 0 {
633 s.selected = (s.selected + 1) % n;
634 }
635 }
636 }
637 Action::HistorySearchPrev => {
638 if let Some(s) = &mut self.history_search {
639 let n = self.history.search(&s.query).len();
640 if n > 0 {
641 s.selected = (s.selected + n - 1) % n;
642 }
643 }
644 }
645 Action::HistorySearchConfirm => {
646 if let Some(s) = self.history_search.take() {
647 if let Some(&hit) = self.history.search(&s.query).get(s.selected) {
648 self.input = hit.to_string();
649 self.cursor = self.input.len();
650 }
651 }
652 self.input_focus = InputFocus::Composer;
653 }
654 Action::HistorySearchCancel => {
655 self.history_search = None;
656 self.input_focus = InputFocus::Composer;
657 }
658 Action::VimSetMode(mode) => self.vim_mode = mode,
659 Action::VimMoveLeft => self.apply(Action::MoveLeft),
660 Action::VimMoveRight => self.apply(Action::MoveRight),
661 Action::VimMoveHome => self.apply(Action::MoveHome),
662 Action::VimMoveEnd => self.apply(Action::MoveEnd),
663 Action::VimDeleteChar => self.apply(Action::DeleteForward),
664 Action::VimDeleteLine => self.apply(Action::ClearComposerLine),
665 Action::RequestExternalEditor => self.external_editor_requested = true,
666 Action::ExternalEditorResult(text) => {
667 self.external_editor_requested = false;
668 self.input = text;
669 self.cursor = self.input.len();
670 }
671 Action::ExternalEditorFailed(message) => {
672 self.external_editor_requested = false;
673 self.transcript.push(TranscriptEntry {
674 role: Role::System,
675 text: format!("$EDITOR failed: {message}"),
676 });
677 }
678 Action::PasteImage(reference) => {
679 self.pending_images.push(reference.clone());
680 let token = format!("[image: {reference}]");
681 self.input.insert_str(self.cursor, &token);
682 self.cursor += token.len();
683 }
684 Action::ShowApprovalModal(req) => self.enqueue_or_show(Modal::Approval(req)),
685 Action::ResolveApproval(outcome) => {
686 if let Some(Modal::Approval(req)) =
687 self.take_modal_if(|m| matches!(m, Modal::Approval(_)))
688 {
689 let note = approval_note(&req.tool, req.subject.as_deref(), outcome);
690 let _ = req.reply_tx.send(outcome);
691 self.transcript.push(TranscriptEntry {
692 role: Role::System,
693 text: note,
694 });
695 }
696 self.dequeue_modal();
697 }
698 Action::ShowChildApprovalModal(req) => self.enqueue_or_show(Modal::ChildApproval(req)),
699 Action::ResolveChildApproval(outcome) => {
700 if let Some(Modal::ChildApproval(req)) =
701 self.take_modal_if(|m| matches!(m, Modal::ChildApproval(_)))
702 {
703 let note = format!(
704 "child `{}` {}",
705 req.child_agent_id,
706 approval_note(&req.tool, req.subject.as_deref(), outcome)
707 );
708 let _ = req.reply_tx.send(outcome);
709 self.transcript.push(TranscriptEntry {
710 role: Role::System,
711 text: note,
712 });
713 }
714 self.dequeue_modal();
715 }
716 Action::ShowElicitationModal(req) => self.enqueue_or_show(Modal::Elicitation {
717 request: req,
718 answer: String::new(),
719 }),
720 Action::ElicitationType(c) => {
721 if let Some(Modal::Elicitation { answer, .. }) = &mut self.modal {
722 answer.push(c);
723 }
724 }
725 Action::ElicitationBackspace => {
726 if let Some(Modal::Elicitation { answer, .. }) = &mut self.modal {
727 answer.pop();
728 }
729 }
730 Action::ResolveElicitationAccept => {
731 if let Some(Modal::Elicitation { request, answer }) =
732 self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
733 {
734 let content = elicitation_content(&request.requested_schema, &answer);
735 // F9 (Fable-5 adversarial review — LOW security bit):
736 // this used to echo `answer` VERBATIM into the
737 // scrollback transcript — a server asking for a token/
738 // password left it sitting in plaintext, visible on
739 // screen and in anything that later scrolls back
740 // through the transcript. The MCP elicitation schema
741 // has no standardized "this field is a secret" signal
742 // to key an exemption off (see
743 // `PendingElicitation::requested_schema`'s shape), so
744 // the safe default is masking EVERY elicitation
745 // answer's echo, not guessing from the field name —
746 // the actual `content` sent back to the server (right
747 // above) is unaffected, this only changes what the
748 // HUMAN'S OWN screen shows afterward.
749 self.transcript.push(TranscriptEntry {
750 role: Role::System,
751 text: format!("elicitation answered: {}", mask_elicitation_answer(&answer)),
752 });
753 let _ = request.reply_tx.send(ElicitationResponse {
754 action: ElicitationAction::Accept,
755 content: Some(content),
756 });
757 }
758 self.dequeue_modal();
759 }
760 Action::ResolveElicitationDecline => {
761 if let Some(Modal::Elicitation { request, .. }) =
762 self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
763 {
764 self.transcript.push(TranscriptEntry {
765 role: Role::System,
766 text: "elicitation declined".to_string(),
767 });
768 let _ = request.reply_tx.send(ElicitationResponse {
769 action: ElicitationAction::Decline,
770 content: None,
771 });
772 }
773 self.dequeue_modal();
774 }
775 Action::ResolveElicitationCancel => {
776 if let Some(Modal::Elicitation { request, .. }) =
777 self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
778 {
779 let _ = request.reply_tx.send(ElicitationResponse {
780 action: ElicitationAction::Cancel,
781 content: None,
782 });
783 }
784 self.dequeue_modal();
785 }
786 Action::ShowOAuthModal(display) => {
787 self.enqueue_or_show(Modal::OAuthDeviceCode(display))
788 }
789 Action::DismissOAuthModal => {
790 self.take_modal_if(|m| matches!(m, Modal::OAuthDeviceCode(_)));
791 self.dequeue_modal();
792 }
793 Action::AppendStreamingDelta(delta) => {
794 self.streaming
795 .get_or_insert_with(String::new)
796 .push_str(&delta);
797 }
798 Action::FinalizeStreaming => {
799 if let Some(text) = self.streaming.take() {
800 self.transcript.push(TranscriptEntry {
801 role: Role::Assistant,
802 text,
803 });
804 }
805 }
806 Action::PushTranscript(entry) => self.transcript.push(entry),
807 Action::SetModelLabel(label) => self.status.model_label = label,
808 Action::SetTurnActive(active) => self.status.turn_active = active,
809 Action::ArmQuit => {
810 self.quit_armed = true;
811 self.status.notice = Some("press Ctrl+C again to exit".to_string());
812 }
813 Action::Quit => self.should_quit = true,
814 Action::Noop => {}
815 }
816 }
817
818 /// Run [`Self::handle_key`], then [`Self::apply`] every resulting
819 /// action in order — the render layer's one-call-per-keypress
820 /// convenience. Every externally-relevant outcome (a turn to send, an
821 /// editor to launch, …) lands in a dedicated `TuiState` field
822 /// ([`Self::last_submission`]/[`Self::external_editor_requested`]/
823 /// [`Self::should_quit`]) the caller polls afterward — `Action` itself
824 /// is intentionally NOT `Clone` (it carries one-shot reply channels),
825 /// so this doesn't hand actions back; a caller that needs to react to
826 /// the RAW action stream (e.g. a test) calls `handle_key`+`apply`
827 /// directly instead, as most of this module's own tests do.
828 pub fn on_key(&mut self, key: KeyEvent) {
829 for action in self.handle_key(key) {
830 self.apply(action);
831 }
832 }
833
834 /// Take (and clear) the most recent submission, if any — see
835 /// [`Self::last_submission`]'s doc comment.
836 pub fn take_submission(&mut self) -> Option<String> {
837 self.last_submission.take()
838 }
839
840 /// F5 (Fable-5 adversarial review): the CLI layer's paired polling
841 /// point alongside [`Self::take_submission`] — call both together,
842 /// same tick, right after `take_submission` returns `Some`: this
843 /// drains (and clears) every image path staged via
844 /// [`Action::PasteImage`] for THAT submission, for the caller to
845 /// route into the turn's multimodal content (e.g.
846 /// `Agent::send_with_images`). Previously `Action::Submit` cleared
847 /// `pending_images` eagerly, before the CLI layer could ever read it
848 /// — this method is what makes draining it the CLI's job instead, so
849 /// a pasted image path actually reaches the model.
850 pub fn take_pending_images(&mut self) -> Vec<String> {
851 std::mem::take(&mut self.pending_images)
852 }
853
854 fn enqueue_or_show(&mut self, modal: Modal) {
855 if self.modal.is_none() {
856 self.modal = Some(modal);
857 } else {
858 self.modal_queue.push_back(modal);
859 }
860 }
861
862 fn dequeue_modal(&mut self) {
863 if self.modal.is_none() {
864 self.modal = self.modal_queue.pop_front();
865 }
866 }
867
868 fn take_modal_if(&mut self, pred: impl FnOnce(&Modal) -> bool) -> Option<Modal> {
869 if self.modal.as_ref().is_some_and(pred) {
870 self.modal.take()
871 } else {
872 None
873 }
874 }
875
876 /// D-1 (Fable-5 delta review — MEDIUM, "error-path indefinite hang"):
877 /// drop the active modal AND everything still queued behind it,
878 /// without sending a reply. Each [`Modal`] variant that carries a
879 /// reply channel (`Approval`/`ChildApproval`'s `std::sync::mpsc::Sender`,
880 /// `Elicitation`'s `tokio::sync::oneshot::Sender`) has its sender
881 /// dropped as part of this — the corresponding blocked caller
882 /// (`TuiApprovalHandler::ask`/elicitation) already treats a closed
883 /// channel as its documented fail-closed default
884 /// (`ApprovalOutcome::Deny` / a declined `ElicitationResponse`; see
885 /// `crate::tui::handlers`), so this never silently allows anything.
886 /// `OAuthDeviceCode` carries no reply channel — dropping it is a plain
887 /// dismissal.
888 ///
889 /// The CLI's render loop (`run_turn_blocking_with_input`) calls this
890 /// when its own terminal I/O has failed while a modal is still
891 /// unanswered: nothing is left alive to answer it (crossterm is
892 /// broken), and the in-flight turn's worker thread is parked in a
893 /// blocking `recv()`/`.await` on that modal's reply channel that
894 /// `std::thread::scope` will join before the loop can return ANY
895 /// value, including its own I/O error — so leaving the modal pending
896 /// would hang the whole session forever instead of surfacing that
897 /// error.
898 pub fn fail_close_pending_modals(&mut self) {
899 self.modal = None;
900 self.modal_queue.clear();
901 }
902}
903
904fn approval_note(tool: &str, subject: Option<&str>, outcome: ApprovalOutcome) -> String {
905 let verdict = match outcome {
906 ApprovalOutcome::Deny => "denied",
907 ApprovalOutcome::Allow => "allowed (once)",
908 ApprovalOutcome::AllowForSession => "allowed (for session)",
909 };
910 match subject {
911 Some(s) => format!("approval: {tool} `{s}` — {verdict}"),
912 None => format!("approval: {tool} — {verdict}"),
913 }
914}
915
916/// P5-2: build the `content` an [`ElicitationResponse::Accept`] carries
917/// from the modal's free-text `answer` — deliberately basic (not a full
918/// JSON-Schema-driven form builder, see the crate doc comment's
919/// shippable-vs-staged note): if `schema` names exactly one top-level
920/// property, the answer is wrapped under THAT property's name (so a
921/// single-field schema round-trips as the field the server actually asked
922/// for); otherwise it's wrapped under a generic `"value"` key.
923fn elicitation_content(schema: &serde_json::Value, answer: &str) -> serde_json::Value {
924 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
925 if props.len() == 1 {
926 if let Some(name) = props.keys().next() {
927 return serde_json::json!({ name: answer });
928 }
929 }
930 }
931 serde_json::json!({ "value": answer })
932}
933
934/// F9 (Fable-5 adversarial review — LOW security bit): mask an
935/// elicitation answer before it's echoed into the (human-visible-only)
936/// transcript — see [`TuiState::apply`]'s `ResolveElicitationAccept` arm
937/// for why every answer is masked rather than trying to guess which ones
938/// are "sensitive" from the field name. A fixed-width placeholder (not
939/// one bullet per character) so the mask itself doesn't leak the
940/// answer's length; an empty answer gets its own honest placeholder
941/// rather than a mask that looks identical to a real one.
942fn mask_elicitation_answer(answer: &str) -> &'static str {
943 if answer.is_empty() {
944 "(empty)"
945 } else {
946 "••••"
947 }
948}