Skip to main content

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}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use std::sync::mpsc;
954
955    fn state() -> TuiState {
956        TuiState::new_default()
957    }
958
959    // ---- composer basics ----
960
961    #[test]
962    fn typing_inserts_and_advances_cursor() {
963        let mut s = state();
964        s.on_key(KeyEvent::ch('h'));
965        s.on_key(KeyEvent::ch('i'));
966        assert_eq!(s.input, "hi");
967        assert_eq!(s.cursor, 2);
968    }
969
970    #[test]
971    fn backspace_removes_the_char_before_cursor() {
972        let mut s = state();
973        s.on_key(KeyEvent::ch('a'));
974        s.on_key(KeyEvent::ch('b'));
975        s.on_key(KeyEvent::plain(Key::Backspace));
976        assert_eq!(s.input, "a");
977        assert_eq!(s.cursor, 1);
978    }
979
980    #[test]
981    fn backspace_on_multibyte_char_removes_the_whole_char() {
982        let mut s = state();
983        for c in "hé".chars() {
984            s.on_key(KeyEvent::ch(c));
985        }
986        assert_eq!(s.cursor, "hé".len()); // 3 bytes: 'h' (1) + 'é' (2)
987        s.on_key(KeyEvent::plain(Key::Backspace));
988        assert_eq!(s.input, "h");
989        assert_eq!(s.cursor, 1);
990    }
991
992    #[test]
993    fn move_left_right_home_end() {
994        let mut s = state();
995        for c in "abc".chars() {
996            s.on_key(KeyEvent::ch(c));
997        }
998        s.on_key(KeyEvent::plain(Key::Home));
999        assert_eq!(s.cursor, 0);
1000        s.on_key(KeyEvent::plain(Key::Right));
1001        assert_eq!(s.cursor, 1);
1002        s.on_key(KeyEvent::plain(Key::End));
1003        assert_eq!(s.cursor, 3);
1004        s.on_key(KeyEvent::plain(Key::Left));
1005        assert_eq!(s.cursor, 2);
1006    }
1007
1008    #[test]
1009    fn submit_clears_composer_and_pushes_transcript_and_history() {
1010        let mut s = state();
1011        for c in "hello".chars() {
1012            s.on_key(KeyEvent::ch(c));
1013        }
1014        let actions = s.handle_key(KeyEvent::plain(Key::Enter));
1015        assert!(matches!(actions.as_slice(), [Action::Submit(t)] if t == "hello"));
1016        for a in actions {
1017            s.apply(a);
1018        }
1019        assert_eq!(s.input, "");
1020        assert_eq!(s.transcript.last().unwrap().text, "hello");
1021        assert_eq!(s.transcript.last().unwrap().role, Role::User);
1022        assert_eq!(s.history.search(""), vec!["hello"]);
1023    }
1024
1025    #[test]
1026    fn enter_on_empty_composer_does_nothing() {
1027        let s = state();
1028        let actions = s.handle_key(KeyEvent::plain(Key::Enter));
1029        assert!(actions.is_empty());
1030    }
1031
1032    #[test]
1033    fn newline_key_inserts_newline_without_submitting() {
1034        let mut s = state();
1035        s.on_key(KeyEvent::ch('a'));
1036        s.on_key(KeyEvent {
1037            key: Key::Enter,
1038            ctrl: false,
1039            alt: true,
1040            shift: false,
1041        });
1042        s.on_key(KeyEvent::ch('b'));
1043        assert_eq!(s.input, "a\nb");
1044        assert!(s.transcript.is_empty());
1045    }
1046
1047    // ---- quit (double ctrl-c) ----
1048
1049    #[test]
1050    fn ctrl_c_on_empty_composer_arms_then_quits_on_repeat() {
1051        let mut s = state();
1052        let a1 = s.handle_key(KeyEvent::ctrl(Key::Char('c')));
1053        assert!(matches!(a1.as_slice(), [Action::ArmQuit]));
1054        for a in a1 {
1055            s.apply(a);
1056        }
1057        assert!(!s.should_quit);
1058        assert!(s.status.notice.is_some());
1059
1060        let a2 = s.handle_key(KeyEvent::ctrl(Key::Char('c')));
1061        assert!(matches!(a2.as_slice(), [Action::Quit]));
1062        for a in a2 {
1063            s.apply(a);
1064        }
1065        assert!(s.should_quit);
1066    }
1067
1068    #[test]
1069    fn ctrl_c_disarms_after_an_intervening_keypress() {
1070        let mut s = state();
1071        s.on_key(KeyEvent::ctrl(Key::Char('c')));
1072        assert!(s.quit_armed);
1073        s.on_key(KeyEvent::ch('x'));
1074        assert!(!s.quit_armed);
1075        // Now Ctrl+C sees a non-empty composer, so it clears the line
1076        // instead of arming/quitting.
1077        s.on_key(KeyEvent::ctrl(Key::Char('c')));
1078        assert!(!s.should_quit);
1079        assert_eq!(s.input, "");
1080    }
1081
1082    #[test]
1083    fn ctrl_c_on_nonempty_composer_clears_the_line() {
1084        let mut s = state();
1085        for c in "oops".chars() {
1086            s.on_key(KeyEvent::ch(c));
1087        }
1088        s.on_key(KeyEvent::ctrl(Key::Char('c')));
1089        assert_eq!(s.input, "");
1090        assert!(!s.should_quit);
1091    }
1092
1093    // ---- theme toggle ----
1094
1095    #[test]
1096    fn ctrl_t_toggles_theme() {
1097        let mut s = state();
1098        assert_eq!(s.theme, Theme::Dark);
1099        s.on_key(KeyEvent::ctrl(Key::Char('t')));
1100        assert_eq!(s.theme, Theme::Light);
1101        s.on_key(KeyEvent::ctrl(Key::Char('t')));
1102        assert_eq!(s.theme, Theme::Dark);
1103    }
1104
1105    // ---- history search ----
1106
1107    #[test]
1108    fn ctrl_r_opens_history_search_and_narrows_by_typing() {
1109        let mut s = state();
1110        s.history.push("fix login bug");
1111        s.history.push("add tests");
1112        s.on_key(KeyEvent::ctrl(Key::Char('r')));
1113        assert_eq!(s.input_focus, InputFocus::HistorySearch);
1114        for c in "login".chars() {
1115            s.on_key(KeyEvent::ch(c));
1116        }
1117        assert_eq!(s.history_search.as_ref().unwrap().query, "login");
1118        s.on_key(KeyEvent::plain(Key::Enter));
1119        assert_eq!(s.input, "fix login bug");
1120        assert_eq!(s.input_focus, InputFocus::Composer);
1121    }
1122
1123    #[test]
1124    fn history_search_escape_cancels_without_changing_composer() {
1125        let mut s = state();
1126        s.history.push("something");
1127        s.on_key(KeyEvent::ctrl(Key::Char('r')));
1128        s.on_key(KeyEvent::ch('x'));
1129        s.on_key(KeyEvent::plain(Key::Escape));
1130        assert_eq!(s.input, "");
1131        assert_eq!(s.input_focus, InputFocus::Composer);
1132        assert!(s.history_search.is_none());
1133    }
1134
1135    // ---- vim basic mode ----
1136
1137    #[test]
1138    fn vim_disabled_by_default_i_inserts_char() {
1139        let mut s = state();
1140        assert!(!s.vim_enabled);
1141        s.on_key(KeyEvent::ch('i'));
1142        assert_eq!(s.input, "i");
1143    }
1144
1145    #[test]
1146    fn vim_enabled_starts_in_normal_mode_and_i_enters_insert() {
1147        let mut s = TuiState::new(
1148            Theme::default(),
1149            Keymap::default(),
1150            true,
1151            PromptHistory::new(),
1152        );
1153        assert_eq!(s.vim_mode, VimMode::Normal);
1154        s.on_key(KeyEvent::ch('i'));
1155        assert_eq!(s.vim_mode, VimMode::Insert);
1156        assert_eq!(s.input, "");
1157        s.on_key(KeyEvent::ch('a'));
1158        assert_eq!(s.input, "a");
1159    }
1160
1161    #[test]
1162    fn vim_normal_hjkl_and_x_and_dd() {
1163        let mut s = TuiState::new(
1164            Theme::default(),
1165            Keymap::default(),
1166            true,
1167            PromptHistory::new(),
1168        );
1169        s.on_key(KeyEvent::ch('i'));
1170        for c in "abc".chars() {
1171            s.on_key(KeyEvent::ch(c));
1172        }
1173        s.on_key(KeyEvent::plain(Key::Escape));
1174        assert_eq!(s.vim_mode, VimMode::Normal);
1175        assert_eq!(s.input, "abc");
1176        s.on_key(KeyEvent::ch('h'));
1177        s.on_key(KeyEvent::ch('h'));
1178        assert_eq!(s.cursor, 1);
1179        s.on_key(KeyEvent::ch('x'));
1180        assert_eq!(s.input, "ac");
1181        s.on_key(KeyEvent::ch('d'));
1182        assert_eq!(s.input, "");
1183    }
1184
1185    // ---- approval modal (P5-1 chain) ----
1186
1187    #[test]
1188    fn approval_modal_allow_for_session_sends_outcome_and_clears_modal() {
1189        let mut s = state();
1190        let (tx, rx) = mpsc::channel();
1191        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1192            tool: "bash".to_string(),
1193            subject: Some("rm -rf /tmp/x".to_string()),
1194            raw_args: serde_json::json!({}),
1195            reply_tx: tx,
1196        }));
1197        assert!(matches!(s.modal, Some(Modal::Approval(_))));
1198        let actions = s.handle_key(KeyEvent::ch('s'));
1199        assert!(matches!(
1200            actions.as_slice(),
1201            [Action::ResolveApproval(ApprovalOutcome::AllowForSession)]
1202        ));
1203        for a in actions {
1204            s.apply(a);
1205        }
1206        assert!(s.modal.is_none());
1207        assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::AllowForSession));
1208        assert!(s
1209            .transcript
1210            .last()
1211            .unwrap()
1212            .text
1213            .contains("allowed (for session)"));
1214    }
1215
1216    #[test]
1217    fn approval_modal_deny_sends_deny() {
1218        let mut s = state();
1219        let (tx, rx) = mpsc::channel();
1220        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1221            tool: "bash".to_string(),
1222            subject: None,
1223            raw_args: serde_json::json!({}),
1224            reply_tx: tx,
1225        }));
1226        s.on_key(KeyEvent::ch('n'));
1227        assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Deny));
1228    }
1229
1230    #[test]
1231    fn approval_modal_escape_denies() {
1232        let mut s = state();
1233        let (tx, rx) = mpsc::channel();
1234        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1235            tool: "write_file".to_string(),
1236            subject: Some("x.txt".to_string()),
1237            raw_args: serde_json::json!({}),
1238            reply_tx: tx,
1239        }));
1240        s.on_key(KeyEvent::plain(Key::Escape));
1241        assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Deny));
1242    }
1243
1244    /// F3 (Fable-5 adversarial review — MEDIUM): Ctrl+A / Ctrl+S in an
1245    /// approval modal must NOT resolve it (a reflexive "select all"/
1246    /// "save" chord from another program must never allow a tool call).
1247    #[test]
1248    fn approval_modal_ignores_ctrl_a_and_ctrl_s() {
1249        let mut s = state();
1250        let (tx, rx) = mpsc::channel();
1251        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1252            tool: "bash".to_string(),
1253            subject: Some("rm -rf /tmp/x".to_string()),
1254            raw_args: serde_json::json!({}),
1255            reply_tx: tx,
1256        }));
1257        assert!(s.handle_key(KeyEvent::ctrl(Key::Char('a'))).is_empty());
1258        assert!(s.handle_key(KeyEvent::ctrl(Key::Char('s'))).is_empty());
1259        assert!(
1260            matches!(s.modal, Some(Modal::Approval(_))),
1261            "the modal must still be showing — neither chord may resolve it"
1262        );
1263        assert!(rx.try_recv().is_err(), "no reply must have been sent");
1264
1265        // A plain (unmodified) 'y' still works — the guard only screens
1266        // out ctrl/alt, it doesn't disable the modal.
1267        s.on_key(KeyEvent::ch('y'));
1268        assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Allow));
1269    }
1270
1271    /// F3: Alt-modified keys are ignored the same way.
1272    #[test]
1273    fn approval_modal_ignores_alt_modified_keys() {
1274        let mut s = state();
1275        let (tx, rx) = mpsc::channel();
1276        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1277            tool: "bash".to_string(),
1278            subject: None,
1279            raw_args: serde_json::json!({}),
1280            reply_tx: tx,
1281        }));
1282        let alt_y = KeyEvent {
1283            key: Key::Char('y'),
1284            ctrl: false,
1285            alt: true,
1286            shift: false,
1287        };
1288        assert!(s.handle_key(alt_y).is_empty());
1289        assert!(rx.try_recv().is_err());
1290    }
1291
1292    #[test]
1293    fn a_second_request_queues_behind_the_first_modal() {
1294        let mut s = state();
1295        let (tx1, rx1) = mpsc::channel();
1296        let (tx2, rx2) = mpsc::channel();
1297        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1298            tool: "bash".to_string(),
1299            subject: None,
1300            raw_args: serde_json::json!({}),
1301            reply_tx: tx1,
1302        }));
1303        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1304            tool: "write_file".to_string(),
1305            subject: None,
1306            raw_args: serde_json::json!({}),
1307            reply_tx: tx2,
1308        }));
1309        // Second request hasn't been shown yet.
1310        assert!(rx2.try_recv().is_err());
1311        s.on_key(KeyEvent::ch('y')); // resolve the first
1312        assert_eq!(rx1.try_recv(), Ok(ApprovalOutcome::Allow));
1313        // The second is now the active modal.
1314        assert!(matches!(s.modal, Some(Modal::Approval(_))));
1315        s.on_key(KeyEvent::ch('n'));
1316        assert_eq!(rx2.try_recv(), Ok(ApprovalOutcome::Deny));
1317    }
1318
1319    // ---- child approval modal (P5-3 chain) ----
1320
1321    #[test]
1322    fn child_approval_modal_allow_sends_outcome_tagged_with_child_id() {
1323        let mut s = state();
1324        let (tx, rx) = mpsc::channel();
1325        s.apply(Action::ShowChildApprovalModal(PendingChildApproval {
1326            child_agent_id: "agent-bg-1".to_string(),
1327            tool: "bash".to_string(),
1328            subject: Some("curl evil.example".to_string()),
1329            raw_args: serde_json::json!({}),
1330            reply_tx: tx,
1331        }));
1332        s.on_key(KeyEvent::ch('y'));
1333        assert_eq!(rx.try_recv(), Ok(ApprovalOutcome::Allow));
1334        assert!(s.transcript.last().unwrap().text.contains("agent-bg-1"));
1335    }
1336
1337    /// F3 (Fable-5 adversarial review — MEDIUM): same guard, the
1338    /// child-approval modal.
1339    #[test]
1340    fn child_approval_modal_ignores_ctrl_a_and_ctrl_s() {
1341        let mut s = state();
1342        let (tx, rx) = mpsc::channel();
1343        s.apply(Action::ShowChildApprovalModal(PendingChildApproval {
1344            child_agent_id: "agent-bg-2".to_string(),
1345            tool: "bash".to_string(),
1346            subject: Some("curl evil.example".to_string()),
1347            raw_args: serde_json::json!({}),
1348            reply_tx: tx,
1349        }));
1350        assert!(s.handle_key(KeyEvent::ctrl(Key::Char('a'))).is_empty());
1351        assert!(s.handle_key(KeyEvent::ctrl(Key::Char('s'))).is_empty());
1352        assert!(matches!(s.modal, Some(Modal::ChildApproval(_))));
1353        assert!(rx.try_recv().is_err());
1354    }
1355
1356    // ---- elicitation modal (P5-2 chain) ----
1357
1358    #[tokio::test]
1359    async fn elicitation_modal_accept_wraps_answer_under_the_single_schema_property() {
1360        let mut s = state();
1361        let (tx, rx) = tokio::sync::oneshot::channel();
1362        s.apply(Action::ShowElicitationModal(PendingElicitation {
1363            message: "What's your name?".to_string(),
1364            requested_schema: serde_json::json!({
1365                "type": "object",
1366                "properties": { "name": {"type": "string"} }
1367            }),
1368            reply_tx: tx,
1369        }));
1370        for c in "Ada".chars() {
1371            s.on_key(KeyEvent::ch(c));
1372        }
1373        s.on_key(KeyEvent::plain(Key::Enter));
1374        assert!(s.modal.is_none());
1375        let resp = rx.await.unwrap();
1376        assert_eq!(resp.action, ElicitationAction::Accept);
1377        assert_eq!(resp.content, Some(serde_json::json!({"name": "Ada"})));
1378    }
1379
1380    /// F9 (Fable-5 adversarial review — LOW security bit): the answer
1381    /// sent BACK TO THE SERVER (`resp.content`) must still be the real,
1382    /// unmasked text — masking is purely a transcript-echo (human-screen)
1383    /// concern, never a protocol-correctness one.
1384    #[tokio::test]
1385    async fn elicitation_modal_accept_masks_the_transcript_echo_but_not_the_reply_content() {
1386        let mut s = state();
1387        let (tx, rx) = tokio::sync::oneshot::channel();
1388        s.apply(Action::ShowElicitationModal(PendingElicitation {
1389            message: "What's the API token?".to_string(),
1390            requested_schema: serde_json::json!({
1391                "type": "object",
1392                "properties": { "token": {"type": "string"} }
1393            }),
1394            reply_tx: tx,
1395        }));
1396        for c in "sk-super-secret".chars() {
1397            s.on_key(KeyEvent::ch(c));
1398        }
1399        s.on_key(KeyEvent::plain(Key::Enter));
1400
1401        let resp = rx.await.unwrap();
1402        assert_eq!(
1403            resp.content,
1404            Some(serde_json::json!({"token": "sk-super-secret"})),
1405            "the server must still receive the real answer"
1406        );
1407
1408        let echoed = &s.transcript.last().unwrap().text;
1409        assert!(
1410            !echoed.contains("sk-super-secret"),
1411            "the transcript echo must not contain the raw answer: {echoed}"
1412        );
1413        assert!(
1414            echoed.contains("••••"),
1415            "the transcript echo must show a mask placeholder: {echoed}"
1416        );
1417    }
1418
1419    #[test]
1420    fn mask_elicitation_answer_gives_empty_its_own_placeholder() {
1421        assert_eq!(mask_elicitation_answer(""), "(empty)");
1422        assert_eq!(mask_elicitation_answer("x"), "••••");
1423        assert_eq!(mask_elicitation_answer("a very long secret token"), "••••");
1424    }
1425
1426    #[tokio::test]
1427    async fn elicitation_modal_escape_cancels() {
1428        let mut s = state();
1429        let (tx, rx) = tokio::sync::oneshot::channel();
1430        s.apply(Action::ShowElicitationModal(PendingElicitation {
1431            message: "…".to_string(),
1432            requested_schema: serde_json::json!({}),
1433            reply_tx: tx,
1434        }));
1435        s.on_key(KeyEvent::plain(Key::Escape));
1436        let resp = rx.await.unwrap();
1437        assert_eq!(resp.action, ElicitationAction::Cancel);
1438        assert_eq!(resp.content, None);
1439    }
1440
1441    #[tokio::test]
1442    async fn elicitation_modal_f2_declines() {
1443        let mut s = state();
1444        let (tx, rx) = tokio::sync::oneshot::channel();
1445        s.apply(Action::ShowElicitationModal(PendingElicitation {
1446            message: "…".to_string(),
1447            requested_schema: serde_json::json!({}),
1448            reply_tx: tx,
1449        }));
1450        s.on_key(KeyEvent::plain(Key::F(2)));
1451        let resp = rx.await.unwrap();
1452        assert_eq!(resp.action, ElicitationAction::Decline);
1453    }
1454
1455    #[test]
1456    fn elicitation_backspace_edits_the_answer_buffer() {
1457        let mut s = state();
1458        let (tx, _rx) = tokio::sync::oneshot::channel();
1459        s.apply(Action::ShowElicitationModal(PendingElicitation {
1460            message: "…".to_string(),
1461            requested_schema: serde_json::json!({}),
1462            reply_tx: tx,
1463        }));
1464        s.on_key(KeyEvent::ch('a'));
1465        s.on_key(KeyEvent::ch('b'));
1466        s.on_key(KeyEvent::plain(Key::Backspace));
1467        if let Some(Modal::Elicitation { answer, .. }) = &s.modal {
1468            assert_eq!(answer, "a");
1469        } else {
1470            panic!("expected elicitation modal");
1471        }
1472    }
1473
1474    // ---- OAuth device-code display ----
1475
1476    #[test]
1477    fn oauth_modal_shows_and_dismisses_on_enter() {
1478        let mut s = state();
1479        s.apply(Action::ShowOAuthModal(PendingOAuthDisplay {
1480            server_name: "acme".to_string(),
1481            user_code: "ABCD-1234".to_string(),
1482            verification_uri: "https://example.com/device".to_string(),
1483            verification_uri_complete: None,
1484            expires_in_secs: 600,
1485        }));
1486        assert!(matches!(s.modal, Some(Modal::OAuthDeviceCode(_))));
1487        s.on_key(KeyEvent::plain(Key::Enter));
1488        assert!(s.modal.is_none());
1489    }
1490
1491    // ---- streaming / transcript ----
1492
1493    #[test]
1494    fn streaming_deltas_accumulate_and_finalize_into_transcript() {
1495        let mut s = state();
1496        s.apply(Action::AppendStreamingDelta("Hel".to_string()));
1497        s.apply(Action::AppendStreamingDelta("lo".to_string()));
1498        assert_eq!(s.streaming.as_deref(), Some("Hello"));
1499        s.apply(Action::FinalizeStreaming);
1500        assert!(s.streaming.is_none());
1501        assert_eq!(s.transcript.last().unwrap().text, "Hello");
1502        assert_eq!(s.transcript.last().unwrap().role, Role::Assistant);
1503    }
1504
1505    // ---- image paste ----
1506
1507    #[test]
1508    fn paste_image_inserts_a_placeholder_and_records_the_reference() {
1509        let mut s = state();
1510        s.apply(Action::PasteImage("/tmp/screenshot.png".to_string()));
1511        assert!(s.input.contains("[image: /tmp/screenshot.png]"));
1512        assert_eq!(s.pending_images, vec!["/tmp/screenshot.png".to_string()]);
1513    }
1514
1515    /// F5 (Fable-5 adversarial review): `pending_images` used to be
1516    /// cleared inside `apply(Action::Submit(_))` itself — before the CLI
1517    /// layer's render loop ever got a chance to read `last_submission`
1518    /// (that only happens on the NEXT tick) — so a pasted image was
1519    /// already gone by the time anything could route it into the turn.
1520    /// Proves the fix: the images are still there right after `Submit`,
1521    /// and `take_pending_images` is what drains them (once).
1522    #[test]
1523    fn pending_images_survive_submit_and_are_drained_by_take_pending_images() {
1524        let mut s = state();
1525        s.apply(Action::PasteImage("/tmp/screenshot.png".to_string()));
1526        for c in "describe this".chars() {
1527            s.on_key(KeyEvent::ch(c));
1528        }
1529        s.on_key(KeyEvent::plain(Key::Enter));
1530        assert_eq!(
1531            s.take_submission().as_deref(),
1532            Some("[image: /tmp/screenshot.png]describe this")
1533        );
1534        assert_eq!(
1535            s.pending_images,
1536            vec!["/tmp/screenshot.png".to_string()],
1537            "the image must still be there for the CLI layer to drain, \
1538             right after the submission is read"
1539        );
1540        assert_eq!(
1541            s.take_pending_images(),
1542            vec!["/tmp/screenshot.png".to_string()]
1543        );
1544        assert!(
1545            s.pending_images.is_empty(),
1546            "take_pending_images must clear, not just read"
1547        );
1548    }
1549
1550    // ---- external editor ----
1551
1552    #[test]
1553    fn external_editor_request_then_result_replaces_composer() {
1554        let mut s = state();
1555        s.on_key(KeyEvent::ctrl(Key::Char('e')));
1556        assert!(s.external_editor_requested);
1557        s.apply(Action::ExternalEditorResult("edited text".to_string()));
1558        assert!(!s.external_editor_requested);
1559        assert_eq!(s.input, "edited text");
1560    }
1561
1562    /// F9 (Fable-5 adversarial review — LOW): an `$EDITOR` spawn failure
1563    /// must clear the pending flag (so `run_loop` doesn't retry-loop
1564    /// launching a nonexistent editor forever) and leave a visible trace
1565    /// — WITHOUT touching the composer's existing text, unlike a success.
1566    #[test]
1567    fn external_editor_failed_clears_the_flag_and_notes_it_without_touching_the_composer() {
1568        let mut s = state();
1569        s.on_key(KeyEvent::ctrl(Key::Char('e')));
1570        assert!(s.external_editor_requested);
1571        for c in "unsaved draft".chars() {
1572            s.on_key(KeyEvent::ch(c));
1573        }
1574        s.apply(Action::ExternalEditorFailed(
1575            "No such file or directory (os error 2)".to_string(),
1576        ));
1577        assert!(!s.external_editor_requested);
1578        assert_eq!(
1579            s.input, "unsaved draft",
1580            "a failed editor invocation must not clobber the composer"
1581        );
1582        assert!(s
1583            .transcript
1584            .last()
1585            .unwrap()
1586            .text
1587            .contains("No such file or directory"));
1588    }
1589
1590    // ---- D-1 (Fable-5 delta review — MEDIUM): fail-closing pending
1591    // modals so a blocked `ask()`/elicitation caller can never be left
1592    // stuck once the render loop that would have answered it is gone ----
1593
1594    /// The active modal's reply sender is dropped, and a blocked
1595    /// `recv()` on the paired receiver (exactly what
1596    /// `TuiApprovalHandler::ask`/`TuiChildApprovalHandler::ask` do) sees
1597    /// the channel close — which is already their documented fail-closed
1598    /// path (`.unwrap_or(ApprovalOutcome::Deny)`), proven directly here
1599    /// via the raw receiver rather than trusting the handler.
1600    #[test]
1601    fn fail_close_pending_modals_drops_the_active_approval_reply_sender() {
1602        let mut s = state();
1603        let (tx, rx) = mpsc::channel();
1604        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1605            tool: "bash".to_string(),
1606            subject: Some("rm -rf /tmp/x".to_string()),
1607            raw_args: serde_json::json!({}),
1608            reply_tx: tx,
1609        }));
1610        assert!(matches!(s.modal, Some(Modal::Approval(_))));
1611
1612        s.fail_close_pending_modals();
1613
1614        assert!(s.modal.is_none());
1615        assert_eq!(
1616            rx.recv(),
1617            Err(mpsc::RecvError),
1618            "the sender must have been dropped without a reply — that's \
1619             what makes ask()'s blocked recv() resolve Deny"
1620        );
1621    }
1622
1623    /// Same guard, the child-approval modal's reply sender.
1624    #[test]
1625    fn fail_close_pending_modals_drops_the_active_child_approval_reply_sender() {
1626        let mut s = state();
1627        let (tx, rx) = mpsc::channel();
1628        s.apply(Action::ShowChildApprovalModal(PendingChildApproval {
1629            child_agent_id: "agent-bg-3".to_string(),
1630            tool: "bash".to_string(),
1631            subject: None,
1632            raw_args: serde_json::json!({}),
1633            reply_tx: tx,
1634        }));
1635        s.fail_close_pending_modals();
1636        assert!(s.modal.is_none());
1637        assert_eq!(rx.recv(), Err(mpsc::RecvError));
1638    }
1639
1640    /// The elicitation modal's `oneshot` reply sender is dropped too —
1641    /// an awaited `rx.await` on the paired receiver resolves `Err`, which
1642    /// `McpElicitationHandler::handle` already maps to a declined
1643    /// response (see [`crate::mcp::ElicitationResponse`]'s handling in
1644    /// `crate::tui::handlers`), never a silent accept.
1645    #[tokio::test]
1646    async fn fail_close_pending_modals_drops_the_active_elicitation_reply_sender() {
1647        let mut s = state();
1648        let (tx, rx) = tokio::sync::oneshot::channel();
1649        s.apply(Action::ShowElicitationModal(PendingElicitation {
1650            message: "…".to_string(),
1651            requested_schema: serde_json::json!({}),
1652            reply_tx: tx,
1653        }));
1654        s.fail_close_pending_modals();
1655        assert!(s.modal.is_none());
1656        assert!(
1657            rx.await.is_err(),
1658            "the oneshot sender must have been dropped without a reply"
1659        );
1660    }
1661
1662    /// A request queued BEHIND the active modal must also be fail-closed
1663    /// — not just the one currently showing. Before this fix, a plain
1664    /// `state.modal = None` alone would have promoted the queued request
1665    /// into view (via the normal `dequeue_modal` path elsewhere), which
1666    /// is exactly the still-unanswered-modal state D-1 exists to avoid.
1667    #[test]
1668    fn fail_close_pending_modals_also_drops_everything_still_queued() {
1669        let mut s = state();
1670        let (tx1, rx1) = mpsc::channel();
1671        let (tx2, rx2) = mpsc::channel();
1672        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1673            tool: "bash".to_string(),
1674            subject: None,
1675            raw_args: serde_json::json!({}),
1676            reply_tx: tx1,
1677        }));
1678        s.apply(Action::ShowApprovalModal(PendingApprovalRequest {
1679            tool: "write_file".to_string(),
1680            subject: Some("x.txt".to_string()),
1681            raw_args: serde_json::json!({}),
1682            reply_tx: tx2,
1683        }));
1684        assert!(matches!(s.modal, Some(Modal::Approval(_))));
1685        assert_eq!(s.modal_queue.len(), 1);
1686
1687        s.fail_close_pending_modals();
1688
1689        assert!(s.modal.is_none());
1690        assert_eq!(s.modal_queue.len(), 0);
1691        assert_eq!(rx1.recv(), Err(mpsc::RecvError));
1692        assert_eq!(
1693            rx2.recv(),
1694            Err(mpsc::RecvError),
1695            "the QUEUED request's sender must be dropped too, not just the active one"
1696        );
1697    }
1698}