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}