Skip to main content

oxicode/tui_vt/
main_loop.rs

1#![allow(
2    clippy::field_reassign_with_default,
3    clippy::let_and_return,
4    clippy::borrow_interior_mutable_const,
5    clippy::derivable_impls
6)]
7//! TUI main event loop — connects oxicode's `AgentSession` to vtcode-ui's
8//! `InlineSession` protocol and a ratatui rendering backend.
9
10use std::io::{self, Stdout, Write};
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use anyhow::Result;
16use crossterm::{
17    cursor::{Hide, Show},
18    event::{
19        self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEventKind,
20        KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
21        PushKeyboardEnhancementFlags,
22    },
23    execute,
24    terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
25};
26use oxicode_agent::AgentEvent;
27use oxicode_agent::config::Mode;
28use oxicode_agent::tools::TodoStateProvider;
29use oxicode_agent::tools::todo::TodoStatus;
30use oxicode_vtui::theme::{ThemeStyles, active_styles};
31use oxicode_vtui::tui::core::{
32    AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
33    InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
34    InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
35    SecurePromptConfig,
36};
37use ratatui::{
38    Frame, Terminal,
39    backend::CrosstermBackend,
40    layout::{Alignment, Rect},
41    style::{Color, Modifier, Style},
42    text::{Line, Span},
43    widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
44};
45
46use crate::App;
47use crate::app::agent_hub_registry::HubEntry;
48use crate::app::agent_session::SessionEvent;
49use crate::tui_vt::slash::file_commands::FileCommand;
50use crate::tui_vt::slash::registry::{SlashCtx, SlashOutcome, SlashRegistry};
51
52// ─────────────────────────────────────────────────────────────────────────
53// Terminal lifecycle (RAII)
54// ─────────────────────────────────────────────────────────────────────────
55
56/// Terminal wrapper with deterministic enter / exit / Drop semantics.
57///
58/// Each cleanup step in `exit` is independent — a failure in one stage
59/// (e.g. `PopKeyboardEnhancementFlags`) MUST NOT prevent later stages
60/// (`disable_raw_mode`) from running, or the user's terminal is left in
61/// raw mode (no echo, no line editing).
62pub struct Tui {
63    terminal: Terminal<CrosstermBackend<Stdout>>,
64    tty_ok: bool,
65}
66
67impl Tui {
68    /// Enter the alternate screen, enable raw mode, push keyboard flags,
69    /// enable bracketed paste, hide the cursor, install the panic hook.
70    pub fn enter() -> Result<Self> {
71        Self::set_panic_hook();
72
73        let tty_ok = enable_raw_mode().is_ok();
74        let mut stdout = io::stdout();
75
76        if tty_ok {
77            // Report event types so key-release / repeat events arrive as
78            // distinct codes. Full Kitty flag set is gated on
79            // OXICODE_KITTY_KEYBOARD=1; default mirrors pre-Kitty behavior.
80            let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
81                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
82                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
83                    | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
84            } else {
85                KeyboardEnhancementFlags::REPORT_EVENT_TYPES
86            };
87            let _ = execute!(
88                stdout,
89                Hide,
90                EnableBracketedPaste,
91                PushKeyboardEnhancementFlags(flags)
92            );
93            let _ = stdout.flush();
94        }
95
96        let backend = CrosstermBackend::new(stdout);
97        let mut terminal = Terminal::new(backend)?;
98        if tty_ok {
99            let _ = terminal.clear();
100        }
101
102        Ok(Self { terminal, tty_ok })
103    }
104
105    /// Restore the terminal to its pre-TUI state. Each step is independent;
106    /// errors are swallowed so a partial restoration never strands the user
107    /// in raw mode.
108    pub fn exit(&mut self) -> Result<()> {
109        if self.tty_ok {
110            let _ = execute!(
111                self.terminal.backend_mut(),
112                PopKeyboardEnhancementFlags,
113                DisableBracketedPaste
114            );
115            let _ = self.terminal.show_cursor();
116            // disable_raw_mode is the most critical — always attempt it.
117            disable_raw_mode()?;
118            self.tty_ok = false;
119        }
120        Ok(())
121    }
122
123    /// Install a panic hook that restores the terminal before printing the
124    /// panic message. Without this, a panic inside the TUI strands the
125    /// user's shell in raw mode / alternate screen.
126    fn set_panic_hook() {
127        let original_hook = std::panic::take_hook();
128        std::panic::set_hook(Box::new(move |panic_info| {
129            let _ = execute!(io::stdout(), Show);
130            let _ = disable_raw_mode();
131            original_hook(panic_info);
132        }));
133    }
134}
135
136impl Drop for Tui {
137    fn drop(&mut self) {
138        let _ = self.exit();
139    }
140}
141
142// ─────────────────────────────────────────────────────────────────────────
143// Render state — shared between the input thread and the main loop.
144// ─────────────────────────────────────────────────────────────────────────
145
146/// Mutable state the input thread edits (text buffer, scroll, footer) and
147/// the main loop reads for rendering.
148#[derive(Default)]
149pub struct RenderState {
150    /// Editable text in the composer.
151    pub input_buffer: String,
152    /// Cursor position inside `input_buffer` (byte index).
153    pub input_cursor: usize,
154    /// Transcript lines, in display order.
155    pub transcript: Vec<TranscriptLine>,
156    /// Index of the line currently pinned at the top of the viewport.
157    /// `usize::MAX` means "follow the tail" (auto-scroll).
158    pub scroll_offset: usize,
159    /// Header context mirrored from `InlineHeaderContext`.
160    pub header_context: InlineHeaderContext,
161    /// Composer enabled state — mirrored from `SetInputEnabled`.
162    pub input_enabled: bool,
163    /// Footer status (left + right) — mirrored from `SetInputStatus`.
164    pub footer_left: Option<String>,
165    pub footer_right: Option<String>,
166    /// Composer prompt prefix — mirrored from `SetPrompt`.
167    pub prompt_prefix: String,
168    /// Composer placeholder — mirrored from `SetPlaceholder`.
169    pub placeholder: Option<String>,
170    /// Shutdown signal received from the harness.
171    pub shutdown_requested: bool,
172    /// Accumulated text for markdown rendering at message end.
173    pub message_buffer: String,
174    /// Agent Hub overlay open.
175    pub agent_hub_open: bool,
176    /// Hub entries snapshotted when the overlay was opened (`/agents`).
177    pub hub_entries: Vec<(String, HubEntry)>,
178    /// First Ctrl+C armed a quit; a second press exits (two-press quit).
179    pub pending_quit: bool,
180    /// Slash-command autocomplete popup state.
181    pub slash_popup: SlashPopup,
182    /// Current reasoning/tool stage (e.g. "tool: read"), shown above the composer.
183    pub reasoning_stage: Option<String>,
184    /// Overlay modal/list state — `Some` when an overlay is open.
185    pub overlay: Option<OverlayState>,
186    /// Model IDs for the /model overlay picker (ordered same as overlay items).
187    pub overlay_model_ids: Vec<String>,
188    /// `(provider, model_id)` pairs backing the `/models` catalog browser
189    /// overlay (ordered same as overlay items).
190    pub overlay_catalog_models: Vec<(String, String)>,
191    /// Provider names backing the `/providers` overlay (ordered same as items).
192    pub overlay_providers: Vec<String>,
193    /// Model catalog port handle, captured once at TUI startup so slash
194    /// commands (`/models`, `/providers`) can browse the full catalog.
195    pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
196    /// Queued input prompts (waiting to be processed).
197    pub queued_inputs: Vec<String>,
198    /// Queued input prompts — interactive panel open (Ctrl+; toggles).
199    pub queue_panel_open: bool,
200    /// Selected index within the queue panel (when interactive).
201    pub queue_selected: usize,
202    /// Shell mode — `!` prefix for direct bash commands (grok-build parity).
203    pub shell_mode: bool,
204    /// Follow-up suggestion chips.
205    pub follow_ups: Vec<String>,
206    /// Todo checklist items (text, status) — refreshed from the live provider.
207    pub todo_items: Vec<(String, TodoStatus)>,
208    /// Live todo state provider — the same source the `todo` agent tool
209    /// writes to. `None` when todos are disabled; the pane stays hidden.
210    pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
211    /// Vim editing state (enabled by /vim command).
212    pub vim_state: oxicode_vtui::vim::VimState,
213    /// Vim clipboard buffer.
214    pub vim_clipboard: String,
215    /// In-transcript search state — `None` when no search is active.
216    pub search: Option<SearchState>,
217    /// Per-block display override. An absent entry means the default
218    /// ([`BlockDisplayMode::Truncated]).
219    pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
220    /// Last Esc press timestamp (for double-Esc detection).
221    pub last_esc_at: Option<std::time::Instant>,
222    /// Multiline input mode — Enter inserts newline, Shift+Enter sends.
223    pub multiline_mode: bool,
224    /// Autonomy mode mirror for display. The authoritative value lives in
225    /// the shared `AskBridge` mode atomic (toggled by Shift+Tab); this field
226    /// is kept in lock-step so the render loop can draw a badge.
227    pub autonomy_mode: Mode,
228    /// Submitted prompt history (most-recent-first).
229    pub prompt_history: Vec<String>,
230    /// Current position in history navigation (None = not navigating).
231    pub history_pos: Option<usize>,
232    /// Next block ID to assign when appending transcript lines.
233    pub next_block_id: usize,
234    /// Cancel grace window — Esc pressed within this window after a cancel
235    /// is ignored (grok-build post-cancel grace, ~1s). Prevents mashing.
236    pub cancel_grace_until: Option<std::time::Instant>,
237    /// Active y/n/x confirmation dialog — `Some` while a modal confirmation
238    /// is open. The input thread resolves it; the render loop paints it
239    /// centered on top of everything else.
240    pub confirmation: Option<ModalConfirmation>,
241    /// Active ephemeral tip banner — `Some` for a bounded number of render
242    /// ticks, then auto-dismissed by expiry.
243    pub tip: Option<EphemeralTip>,
244    /// Workspace root — used by the @ file picker to walk + fuzzy-match.
245    pub cwd: PathBuf,
246    /// Active @-file-search dropdown — `Some` while the picker is open.
247    pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
248    /// Per-tip-key show counter — suppresses ambient tips after SEEN_CAP views.
249    pub seen_tips: std::collections::HashMap<&'static str, u32>,
250    /// User-defined slash commands loaded once at startup from
251    /// `.oxicode/commands/` and `~/.oxicode/commands/`.
252    pub file_commands: Vec<FileCommand>,
253    /// Provider name and origin for the currently open secure prompt.
254    /// Set before opening the prompt; cleared on `OverlaySubmission::SecureInput`
255    /// after the key is written. `None` outside the secure-prompt flows
256    /// (`/providers` row action, `/providers add`, programmatic rekey) so a
257    /// stray `SecureInput` cannot leak into a different provider.
258    ///
259    /// The `SecureInputOrigin` variant lets the consumer of the submitted
260    /// key know whether to greet the user ("just added a provider") or
261    /// simply acknowledge ("key replaced") — both write to the same auth
262    /// storage slot, but the surrounding UX differs.
263    pub secure_input_origin: Option<SecureInputOrigin>,
264    /// Live-session swapper. `None` until the TUI startup wires it.
265    /// The render loop and the agent worker both call `current()` per
266    /// dispatch; the resume `tokio::spawn` calls `swap(new_handle)`.
267    /// `Option` because `#[derive(Default)]` requires it.
268    pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
269    /// `Some(path)` when the slash command wants the event loop to
270    /// drain a resume job on the next `Submitted` arm. The
271    /// `Submitted` arm calls `state.pending_resume.take()` and
272    /// enqueues the resume.
273    pub pending_resume: Option<PathBuf>,
274    /// `Some(state)` once the TUI startup clones the `App`'s
275    /// `SessionState` into the render state. The resume spawn
276    /// closure captures it and passes it to
277    /// `AgentSession::resume_from_file`. `Option` because
278    /// `#[derive(Default)]` requires it.
279    pub session_state: Option<crate::SessionState>,
280}
281
282/// Where a secure prompt came from. The `SecureInput` overlay has just one
283/// payload (the API key text); the origin discriminates the post-commit
284/// follow-up so the user gets a contextual message instead of a generic
285/// "saved" line.
286#[derive(Clone, Debug, PartialEq, Eq)]
287pub enum SecureInputOrigin {
288    /// User picked a provider row and chose "Set API key" (or hit Enter
289    /// on a key-only provider with no key) — this is a *replace* or
290    /// first-time key entry for an existing provider.
291    SetKey { provider: String },
292    /// User just added a provider via `/providers add …` and we are
293    /// chaining straight into the key prompt so they can finish the
294    /// setup without another navigation step.
295    NewlyAdded { provider: String },
296}
297
298/// One rendered transcript line.
299#[derive(Debug, Clone)]
300pub struct TranscriptLine {
301    pub kind: InlineMessageKind,
302    pub segments: Vec<InlineSegment>,
303    /// Block group ID — consecutive lines of the same kind share a block.
304    /// Assigned incrementally when lines are appended.
305    pub block_id: usize,
306}
307
308/// Three-state display mode for a transcript block (grok-build parity).
309///
310/// The default is [`BlockDisplayMode::Truncated`] — finished long blocks
311/// show their head, an ellipsis gap, and a tail snippet rather than the
312/// full body, keeping the scrollback scannable.
313#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
314pub enum BlockDisplayMode {
315    /// Fully collapsed — only the first line shows (▸ marker).
316    Collapsed,
317    /// Default — first line + ellipsis gap + last N lines, body DIM.
318    #[default]
319    Truncated,
320    /// Fully expanded — every line shows at full weight.
321    Expanded,
322}
323
324/// In-transcript search state.
325#[derive(Clone, Debug)]
326pub struct SearchState {
327    pub query: String,
328    /// Transcript line indices that contain a match.
329    pub matches: Vec<usize>,
330    /// Current match cursor (index into `matches`).
331    pub current: usize,
332}
333
334/// One filtered entry in the `/`-command autocomplete popup.
335#[derive(Clone)]
336pub struct SlashPopupItem {
337    /// Display label, e.g. `"/quit, /exit, /q"`.
338    pub label: String,
339    /// Short human description.
340    pub description: String,
341    /// Canonical command name (no leading `/`), used for completion.
342    pub name: String,
343}
344
345/// Slash-command autocomplete popup state, managed by the input thread and
346/// read by the render loop. The popup is open when the input buffer starts
347/// with `/` and contains no space (i.e. the user is still typing the command
348/// token, not its arguments).
349#[derive(Default, Clone)]
350pub struct SlashPopup {
351    pub open: bool,
352    pub items: Vec<SlashPopupItem>,
353    pub selected: usize,
354}
355
356/// One item rendered inside a list overlay. Mirrors [`InlineListItem`] but
357/// is a value type owned by the TUI (the input thread reads/writes these
358/// fields directly via the `parking_lot::Mutex<RenderState>`).
359#[derive(Clone, Debug)]
360pub struct OverlayListItem {
361    pub title: String,
362    pub subtitle: Option<String>,
363    pub badge: Option<String>,
364    pub indent: u8,
365    pub search_value: Option<String>,
366    /// Original `InlineListSelection` echoed back to the harness on submit.
367    pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
368}
369
370/// Overlay modal/list state — materialised by `apply_command` when an
371/// `InlineCommand::ShowOverlay` arrives. The input thread mutates
372/// `selected` / `search` while the overlay is open and reads the same
373/// fields when forwarding `OverlayEvent`s.
374#[derive(Clone, Debug)]
375pub struct OverlayState {
376    pub title: String,
377    pub lines: Vec<String>,
378    pub items: Vec<OverlayListItem>,
379    pub selected: usize,
380    pub search: Option<OverlaySearchState>,
381    pub secure_input: Option<OverlaySecureInput>,
382}
383
384/// Secure (masked) single-line input state carried by an overlay.
385/// Only present when the original `OverlayRequest::Modal` carried a
386/// `secure_prompt`. The input thread mutates `value` and `cursor` while
387/// the overlay is open; on `Enter` it submits `OverlaySubmission::SecureInput`.
388#[derive(Clone, Debug)]
389pub struct OverlaySecureInput {
390    pub config: SecurePromptConfig,
391    pub value: String,
392    pub cursor: usize,
393}
394
395/// A y/n/x confirmation dialog (grok-build `ModalConfirmation` parity).
396/// Rendered centered on top of everything else; the input thread routes
397/// `y` → confirm, `n` → decline (when offered), `x`/`Esc` → cancel.
398#[derive(Clone, Debug)]
399pub struct ModalConfirmation {
400    pub title: String,
401    pub message: String,
402    /// What happens when the user confirms (`y`). Cancel (`n`/`x`/`Esc`)
403    /// always just closes the dialog.
404    pub action: ConfirmationAction,
405}
406
407/// The action bound to a [`ModalConfirmation`] — dispatched on `y`/Enter.
408#[derive(Clone, Debug, PartialEq, Eq)]
409pub enum ConfirmationAction {
410    /// Exit the application.
411    Quit,
412    /// Clear the conversation transcript + reset the agent session.
413    ClearConversation,
414    /// Remove the stored API key for a provider (`/providers` → confirm).
415    RemoveProviderKey(String),
416}
417
418/// A short-lived contextual tip banner (grok-build ephemeral tips parity).
419/// Shown as one line above the composer for a bounded number of render
420/// ticks, then auto-dismissed.
421#[derive(Clone, Debug)]
422pub struct EphemeralTip {
423    pub text: String,
424    /// Render tick the tip was born at (`FRAME_TICK` snapshot).
425    pub born_tick: u64,
426    /// How many ticks the tip stays visible before auto-dismissing.
427    pub ttl_ticks: u64,
428    /// Stable identifier for per-session seen-cap tracking. Tips with the
429    /// same key are suppressed after `SEEN_CAP` showings.
430    pub key: &'static str,
431    /// Ambient tips (background suggestions) are occluded — their TTL pauses
432    /// while an overlay/confirmation/dropdown is open. Non-ambient tips
433    /// (direct user-action feedback) always count down.
434    pub ambient: bool,
435}
436
437/// Search-bar state for an overlay. `None` value means search is disabled.
438#[derive(Clone, Debug)]
439pub struct OverlaySearchState {
440    pub label: String,
441    pub placeholder: Option<String>,
442    pub value: String,
443}
444
445impl RenderState {
446    fn new_with_header(header: InlineHeaderContext) -> Self {
447        let mut s = Self::default();
448        s.header_context = header;
449        s.prompt_prefix = "> ".to_string();
450        s.input_enabled = true;
451        s
452    }
453
454    /// Get a clone of the live `SessionSwapper`. Panics if the TUI
455    /// wasn't initialized properly (the `run_tui` startup wires it
456    /// before any user input is processed, so the panic is
457    /// unreachable in normal use).
458    pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
459        self.session_swapper
460            .clone()
461            .expect("RenderState::session_swapper must be initialized at TUI startup")
462    }
463
464    /// Append a brand-new line to the transcript.
465    fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
466        let block_id = self.block_id_for_kind(kind);
467        self.transcript.push(TranscriptLine {
468            kind,
469            segments,
470            block_id,
471        });
472    }
473
474    /// Append a segment to the most recent transcript line, or create a new
475    /// line if the transcript is empty. Used for `Inline { kind, segment }`
476    /// where the segment is a streaming delta.
477    fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
478        if let Some(last) = self.transcript.last_mut()
479            && last.kind == kind
480        {
481            last.segments.push(segment);
482            return;
483        }
484        let block_id = self.block_id_for_kind(kind);
485        self.transcript.push(TranscriptLine {
486            kind,
487            segments: vec![segment],
488            block_id,
489        });
490    }
491
492    /// Determine the block_id for a new line: reuse the last line's block
493    /// if the kind matches, otherwise allocate a new block.
494    fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
495        if let Some(last) = self.transcript.last()
496            && last.kind == kind
497        {
498            return last.block_id;
499        }
500        let id = self.next_block_id;
501        self.next_block_id += 1;
502        id
503    }
504
505    // ── Search ──
506
507    /// Start a new transcript search, collecting all matching line indices.
508    pub fn start_search(&mut self, query: &str) {
509        let needle = query.to_lowercase();
510        let matches: Vec<usize> = self
511            .transcript
512            .iter()
513            .enumerate()
514            .filter(|(_, line)| {
515                line.segments
516                    .iter()
517                    .any(|s| s.text.to_lowercase().contains(&needle))
518            })
519            .map(|(i, _)| i)
520            .collect();
521        self.search = Some(SearchState {
522            query: query.to_string(),
523            matches,
524            current: 0,
525        });
526        // Jump to the first match if any.
527        if let Some(s) = &self.search
528            && let Some(&first) = s.matches.first()
529        {
530            self.scroll_offset = first;
531        }
532    }
533
534    /// Advance to the next search match (wraps around).
535    pub fn search_next(&mut self) {
536        if let Some(s) = &mut self.search
537            && !s.matches.is_empty()
538        {
539            s.current = (s.current + 1) % s.matches.len();
540            let line = s.matches[s.current];
541            self.scroll_offset = line;
542        }
543    }
544
545    /// Go to the previous search match (wraps around).
546    pub fn search_prev(&mut self) {
547        if let Some(s) = &mut self.search
548            && !s.matches.is_empty()
549        {
550            if s.current == 0 {
551                s.current = s.matches.len() - 1;
552            } else {
553                s.current -= 1;
554            }
555            let line = s.matches[s.current];
556            self.scroll_offset = line;
557        }
558    }
559
560    // ── Block display modes (Collapsed / Truncated / Expanded) ──
561
562    /// The display mode for a block — explicit override or the Truncated default.
563    pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
564        self.block_display
565            .get(&block_id)
566            .copied()
567            .unwrap_or_default()
568    }
569
570    /// Cycle the display mode of the block at (or nearest above) the current
571    /// scroll offset: Collapsed → Truncated → Expanded → Collapsed.
572    pub fn cycle_block_at_view(&mut self) {
573        let offset = self.effective_offset();
574        if let Some(line) = self.transcript.get(offset) {
575            let bid = line.block_id;
576            let next = match self.block_mode(bid) {
577                BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
578                BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
579                BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
580            };
581            // Truncated is the default — represent it by absence so the map
582            // only carries real overrides.
583            if next == BlockDisplayMode::Truncated {
584                self.block_display.remove(&bid);
585            } else {
586                self.block_display.insert(bid, next);
587            }
588        }
589    }
590
591    /// Expand every block (show every line at full weight).
592    pub fn expand_all(&mut self) {
593        for bid in self.all_block_ids() {
594            self.block_display.insert(bid, BlockDisplayMode::Expanded);
595        }
596    }
597
598    /// Collapse every block (first line only).
599    pub fn fold_all(&mut self) {
600        for bid in self.all_block_ids() {
601            self.block_display.insert(bid, BlockDisplayMode::Collapsed);
602        }
603    }
604
605    /// Reset every block to the default Truncated mode.
606    pub fn truncate_all(&mut self) {
607        self.block_display.clear();
608    }
609
610    /// Distinct block ids in transcript order.
611    fn all_block_ids(&self) -> Vec<usize> {
612        let mut ids = Vec::new();
613        let mut prev: Option<usize> = None;
614        for l in &self.transcript {
615            if prev != Some(l.block_id) {
616                ids.push(l.block_id);
617                prev = Some(l.block_id);
618            }
619        }
620        ids
621    }
622
623    // ── Turn navigation ──
624
625    /// Jump the scroll to the start of the next assistant (Agent) block.
626    pub fn jump_next_turn(&mut self) {
627        let offset = self.effective_offset();
628        let search_after = self
629            .transcript
630            .iter()
631            .enumerate()
632            .skip(offset + 1)
633            .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
634        if let Some((idx, _)) = search_after {
635            self.scroll_offset = idx;
636        }
637    }
638
639    /// Jump the scroll to the start of the previous user block.
640    pub fn jump_prev_turn(&mut self) {
641        let offset = self.effective_offset();
642        let search_before = self
643            .transcript
644            .iter()
645            .enumerate()
646            .take(offset)
647            .rev()
648            .find(|(_, l)| l.kind == InlineMessageKind::User);
649        if let Some((idx, _)) = search_before {
650            self.scroll_offset = idx;
651        }
652    }
653
654    /// Effective scroll offset (resolves `usize::MAX` follow-tail to a real index).
655    fn effective_offset(&self) -> usize {
656        if self.scroll_offset == usize::MAX {
657            self.transcript.len().saturating_sub(1)
658        } else {
659            self.scroll_offset
660        }
661    }
662
663    /// Drop the head of the queued-input list. Called when a turn ends so
664    /// the queue pane stops showing the prompt that is now running.
665    pub fn drain_queue_head(&mut self) {
666        if !self.queued_inputs.is_empty() {
667            self.queued_inputs.remove(0);
668        }
669    }
670
671    /// Show an ephemeral tip if the per-session seen-cap hasn't been reached.
672    /// Each unique `key` can show at most `SEEN_CAP` times per session.
673    pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
674        let count = self.seen_tips.entry(key).or_insert(0);
675        if *count >= SEEN_CAP {
676            return;
677        }
678        *count += 1;
679        self.tip = Some(EphemeralTip {
680            text: text.to_string(),
681            born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
682            ttl_ticks: ttl,
683            key,
684            ambient,
685        });
686    }
687}
688
689/// Max times an ambient tip key is shown per session before suppression.
690const SEEN_CAP: u32 = 3;
691
692// ─────────────────────────────────────────────────────────────────────────
693// Main entry: `pub async fn run_tui(app: App) -> Result<()>`
694// ─────────────────────────────────────────────────────────────────────────
695
696/// Run the new oxicode-vtui powered TUI. Returns once the user exits or the
697/// session is shut down.
698pub async fn run_tui(app: App) -> Result<()> {
699    // Resolve shared session-level context up-front so it can outlive the
700    // TUI RAII guard via the worker thread.
701    let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
702    let git_branch = crate::util::git_utils::get_current_branch(&cwd);
703    super::host::activate_theme(app.settings());
704    // Validate active theme contrast and log any warnings.
705    let theme_id = oxicode_vtui::theme::active_theme_id();
706    let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
707    if validation.warnings.is_empty() {
708        tracing::debug!("theme '{theme_id}' passed contrast validation");
709    } else {
710        for w in &validation.warnings {
711            tracing::warn!("theme contrast: {w}");
712        }
713    }
714
715    // Wire the inline-protocol channels. `cmd_tx` becomes the
716    // `InlineHandle`; `evt_tx` is the input-thread → main-loop channel.
717    let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
718    let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
719    let handle = InlineHandle::new_for_tests(cmd_tx);
720
721    // Build the AgentSession from the App. The helper wraps
722    // `create_agent_session_from_services` so we can construct the session
723    // without duplicating the runtime plumbing here.
724    let session = build_agent_session(&app).await?;
725    // No install_runtime_hooks call: session queues and stop flag are
726    // wired into the agent hook chain at agent-build time via
727    // App::from_oxicode → with_session_hooks.
728    let session_handle = session.clone_handle();
729
730    // Wrap the initial handle in a SessionSwapper. The render loop
731    // and the agent worker both read through `current()`; the
732    // resume `tokio::spawn` (below) calls `swap(new_handle)`.
733    let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
734        session_handle.clone(),
735    ));
736
737    // Forward session events to a tokio mpsc so the main loop can
738    // `tokio::select!` on them. We do this in two stages:
739    //  1. Subscribe to AgentSession — CompactionStart/End, Advisor,
740    //     QueueUpdate, etc.
741    //  2. A forwarder thread that drives `agent.run_with_channel` and
742    //     calls `forward_event_to_extensions` so per-agent events also
743    //     flow through the same listener.
744    let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
745    let _sub_guard = session.subscribe(Box::new(move |event| {
746        let _ = session_tx.send(event.clone());
747    }));
748
749    // Header context — built once at startup with workspace + branch.
750    let header = build_header_context(&app, &cwd, git_branch.as_deref());
751    handle.set_header_context(header.clone());
752
753    // Enter the terminal (RAII). Every setup step is fallible, but a
754    // successful `Tui::enter` is required to draw anything.
755    let mut tui = Tui::enter()?;
756
757    // Initial composer + placeholder — the harness receives these as
758    // `SetPrompt` / `SetPlaceholder` commands once it spins up its own
759    // consumer; we set them eagerly so the very first frame is correct.
760    handle.set_prompt("> ".to_string(), InlineTextStyle::default());
761    handle.set_placeholder(Some("Describe what you want to build\u{2026}".to_string()));
762
763    // Render state — shared between the input thread (which edits the
764    // buffer) and the main loop (which reads it for drawing).
765    let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
766        header,
767    )));
768    state.lock().cwd = cwd.clone();
769    state.lock().catalog = Some(app.catalog());
770    state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
771    state.lock().todo_provider = session_handle.todo_provider();
772    state.lock().session_swapper = Some(session_swapper.clone());
773    state.lock().session_state = Some(app.session_state().clone());
774    // Onboarding tip: surfaces the cheatsheet and help command on first run,
775    // auto-dismisses after ~30s of rendering.
776    state.lock().tip = Some(EphemeralTip {
777        text: "Press ? for shortcuts  \u{00b7}  /help for commands".to_string(),
778        born_tick: 0,
779        ttl_ticks: 900,
780        key: "onboarding",
781        ambient: true,
782    });
783    // SSH tip: suggest tmux when running over SSH (1-time).
784    if std::env::var("SSH_CONNECTION").is_ok() {
785        state.lock().show_tip(
786            "ssh_wrap",
787            "Over SSH? Consider tmux to keep sessions alive",
788            600,
789            true,
790        );
791    }
792    // Shared autonomy-mode handle — Shift+Tab toggles it at runtime. The
793    // AskBridge atomic is the authority; the render state mirrors it so the
794    // composer can draw a mode badge.
795    let mode_handle = app.ask_bridge().map(|b| {
796        let handle = b.mode_handle();
797        state.lock().autonomy_mode = Mode::load(&handle);
798        handle
799    });
800    spawn_input_thread(state.clone(), evt_tx.clone(), mode_handle);
801
802    // Worker thread that owns the agent loop. Receives prompts over a
803    // tokio mpsc and dispatches them through `run_with_channel`. The
804    // returned `AgentEvent`s flow through a `std::sync::mpsc`; a paired
805    // forwarder thread funnels them into the session's listener bus so
806    // our subscriber above picks them up.
807    let prompt_tx = spawn_agent_worker(session_swapper.clone());
808
809    let result = run_event_loop(
810        &mut tui.terminal,
811        &mut cmd_rx,
812        &mut evt_rx,
813        &mut session_rx,
814        &handle,
815        &state,
816        &session_swapper,
817        prompt_tx.clone(),
818    )
819    .await;
820
821    // Drain the harness before tearing down the terminal. Even if the
822    // loop exited early we want to release the worker.
823    drop(prompt_tx);
824    handle.shutdown();
825    // Dropping `tui` restores the terminal. Drop is at function return.
826    drop(tui);
827
828    result
829}
830
831// ─────────────────────────────────────────────────────────────────────────
832// Event loop
833// ─────────────────────────────────────────────────────────────────────────
834
835#[allow(clippy::too_many_arguments)]
836async fn run_event_loop(
837    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
838    cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
839    evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
840    session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
841    handle: &InlineHandle,
842    state: &Arc<parking_lot::Mutex<RenderState>>,
843    session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
844    prompt_tx: tokio::sync::mpsc::UnboundedSender<String>,
845) -> Result<()> {
846    // Drain any pending InlineCommands so the harness's initial set_header_context
847    // (and similar) is observed before the first frame.
848    while let Ok(cmd) = cmd_rx.try_recv() {
849        apply_command(&mut state.lock(), cmd);
850    }
851
852    // Draw the initial frame *before* blocking on the first event. The
853    // `select!` below parks until an event arrives, and the per-iteration
854    // redraw only runs after it resolves — so without this eager draw the
855    // screen stays black until the user presses a key.
856    {
857        let snapshot = state.lock();
858        let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
859        if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
860            tracing::warn!(?err, "initial tui draw failed");
861        }
862        let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
863    }
864
865    // Render tick. The input thread edits shared state (typing, cursor
866    // movement, backspace, …) *without* sending an event, so without a
867    // periodic wake the composer would never repaint what the user types.
868    // The ratatui diff backend coalesces unchanged frames, so a steady tick
869    // is cheap and also drives future spinner animation.
870    let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
871    render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
872
873    loop {
874        tokio::select! {
875            // biased: agent events take priority so streaming output is
876            // never starved by Ctrl+C noise or sticky key repeats.
877            biased;
878
879            // 1. Agent → TUI commands (transcript updates).
880            Some(cmd) = cmd_rx.recv() => {
881                let shutdown = {
882                    let mut s = state.lock();
883                    apply_command(&mut s, cmd)
884                };
885                if shutdown {
886                    break;
887                }
888            }
889
890            // 2. Agent → TUI events (token deltas, tool calls, …).
891            Some(event) = session_rx.recv() => {
892                // Intercept handoff-completion to clear transcript + auto-submit.
893                if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
894                    let mut s = state.lock();
895                    s.transcript.clear();
896                    s.message_buffer.clear();
897                    s.scroll_offset = usize::MAX;
898                    s.append_line(
899                        InlineMessageKind::Info,
900                        vec![plain_segment(format!(
901                            "Handoff written to {}. New session started.",
902                            doc_path
903                        ))],
904                    );
905                    let user_typed = !s.input_buffer.trim().is_empty();
906                    s.input_buffer.clear();
907                    s.input_cursor = 0;
908                    if *auto_continue && !user_typed {
909                        drop(s);
910                        let _ = prompt_tx.send(format!(
911                            "Read the handoff document at {} and continue \
912                             from where the previous session left off.",
913                            doc_path
914                        ));
915                    } else if user_typed {
916                        drop(s);
917                        handle.append_line(
918                            InlineMessageKind::Info,
919                            vec![plain_segment(
920                                "Handoff complete. Auto-continue skipped \
921                                 because input was non-empty \u{2014} press \
922                                 Enter to submit your message in the new \
923                                 session."
924                                    .to_string(),
925                            )],
926                        );
927                    }
928                    handle_session_event(&mut state.lock(), handle, &event);
929                }
930            }
931
932            // 3. Keyboard / paste / TUI events from the input thread.
933            Some(evt) = evt_rx.recv() => {
934                let outcome = handle_inline_event(
935                    &mut state.lock(),
936                    handle,
937                    &session_swapper.current(),
938                    &prompt_tx,
939                    evt,
940                );
941                if outcome == LoopOutcome::Exit {
942                    break;
943                }
944            }
945
946            // 4. External SIGINT — route through the same idle-vs-streaming
947            //    policy as the key path (some terminals deliver Ctrl+C both
948            //    as a key event AND raise SIGINT; `kill -INT` also lands here).
949            _ = tokio::signal::ctrl_c() => {
950                let outcome = {
951                    let mut s = state.lock();
952                    handle_interrupt(&mut s, &session_swapper.current(), handle)
953                };
954                if outcome == LoopOutcome::Exit {
955                    break;
956                }
957            }
958
959            // 5. Periodic repaint — echoes typed input and drives animation
960            //    even when no other event is ready.
961            _ = render_tick.tick() => {}
962        }
963
964        // small_screen tip: warn when terminal is too narrow for full UI.
965        if let Ok(size) = terminal.size()
966            && size.width < 40
967        {
968            let mut s = state.lock();
969            if s.tip.is_none() {
970                s.show_tip(
971                    "small_screen",
972                    "Terminal too narrow \u{2014} resize for full UI",
973                    300,
974                    true,
975                );
976            }
977        }
978        // Redraw every iteration. The harness's redraw is idempotent —
979        // the ratatui backend coalesces unchanged frames.
980        let mut snapshot = state.lock();
981        // Refresh the todo checklist from the live provider so the sticky
982        // pane reflects phase changes written by the `todo` agent tool.
983        if let Some(provider) = snapshot.todo_provider.as_ref() {
984            snapshot.todo_items = flatten_todo_items(&provider.get_phases());
985        }
986        let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
987        let draw_err = terminal
988            .draw(|frame| render_frame(frame, &snapshot, handle))
989            .err();
990        let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
991        if let Some(err) = draw_err {
992            tracing::warn!(?err, "tui draw failed");
993            break;
994        }
995    }
996
997    Ok(())
998}
999
1000#[derive(PartialEq, Eq)]
1001enum LoopOutcome {
1002    Continue,
1003    Exit,
1004}
1005
1006/// Whether an Esc-driven cancel should abort the running stream (via the
1007/// interrupt path, which sets the footer + abort) or exit the app outright
1008/// (idle one-press quit). Extracted as a pure function so the routing can
1009/// be unit-tested without a live `AgentSessionHandle`.
1010#[derive(PartialEq, Eq, Debug)]
1011enum CancelRoute {
1012    /// A stream is running: abort it. The input thread's ~1s post-cancel
1013    /// grace then prevents mashing Esc from firing repeated cancels.
1014    Interrupt,
1015    /// Idle: instant one-press quit — no quit-arming footer, no grace.
1016    Exit,
1017}
1018
1019/// Pure routing decision for `InlineEvent::Cancel`. While a stream is
1020/// running, Esc aborts it (matching Ctrl+C). When idle, Esc quits at once.
1021fn route_cancel(is_streaming: bool) -> CancelRoute {
1022    if is_streaming {
1023        CancelRoute::Interrupt
1024    } else {
1025        CancelRoute::Exit
1026    }
1027}
1028
1029// ─────────────────────────────────────────────────────────────────────────
1030// Command / event handlers
1031// ─────────────────────────────────────────────────────────────────────────
1032
1033/// Apply a single `InlineCommand` to the render state. Returns `true`
1034/// when the harness has requested a shutdown.
1035fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1036    match cmd {
1037        InlineCommand::AppendLine { kind, segments } => {
1038            state.append_line(kind, segments);
1039        }
1040        InlineCommand::Inline { kind, segment } => {
1041            state.inline_segment(kind, segment);
1042        }
1043        InlineCommand::ReplaceLast {
1044            count, kind, lines, ..
1045        } => {
1046            // Drop the last `count` lines and replace with the new ones.
1047            let drop = count.min(state.transcript.len());
1048            for _ in 0..drop {
1049                state.transcript.pop();
1050            }
1051            for line in lines {
1052                state.append_line(kind, line);
1053            }
1054        }
1055        InlineCommand::AppendPastedMessage { kind, text, .. } => {
1056            state.append_line(kind, vec![plain_segment(text)]);
1057        }
1058        InlineCommand::SetPrompt { prefix, .. } => {
1059            state.prompt_prefix = prefix;
1060        }
1061        InlineCommand::SetPlaceholder { hint, .. } => {
1062            state.placeholder = hint;
1063        }
1064        InlineCommand::SetHeaderContext { context } => {
1065            state.header_context = *context;
1066        }
1067        InlineCommand::SetInputStatus { left, right } => {
1068            state.footer_left = left;
1069            state.footer_right = right;
1070        }
1071        InlineCommand::SetInputEnabled(enabled) => {
1072            state.input_enabled = enabled;
1073        }
1074        InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1075        InlineCommand::SetReasoningStage(stage) => {
1076            state.reasoning_stage = stage;
1077        }
1078        InlineCommand::SetVimModeEnabled(enabled) => {
1079            state.vim_state.set_enabled(enabled);
1080        }
1081        InlineCommand::SetQueuedInputs { entries } => {
1082            state.queued_inputs = entries;
1083        }
1084        InlineCommand::ShowOverlay { request } => {
1085            state.overlay = Some(materialize_overlay(*request));
1086        }
1087        InlineCommand::CloseOverlay => {
1088            state.overlay = None;
1089        }
1090        InlineCommand::Shutdown => {
1091            state.shutdown_requested = true;
1092            return true;
1093        }
1094        _ => {
1095            // Surface unknown commands as info so they are visible
1096            // during development.
1097            tracing::trace!("unhandled InlineCommand (not rendered)");
1098        }
1099    }
1100    false
1101}
1102
1103/// Convert an `OverlayRequest` into the render-state representation used by
1104/// the TUI. The input thread mutates `selected` / `search` while the overlay
1105/// is open, and `handle_inline_event` projects the user's selection back to
1106/// the harness as `InlineEvent::Overlay`.
1107fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1108    match request {
1109        OverlayRequest::Modal(req) => {
1110            let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1111                config: cfg,
1112                value: String::new(),
1113                cursor: 0,
1114            });
1115            OverlayState {
1116                title: req.title,
1117                lines: req.lines,
1118                items: Vec::new(),
1119                selected: 0,
1120                search: None,
1121                secure_input,
1122            }
1123        }
1124        OverlayRequest::List(req) => {
1125            let search = req.search.map(|cfg| OverlaySearchState {
1126                label: cfg.label,
1127                placeholder: cfg.placeholder,
1128                value: String::new(),
1129            });
1130            OverlayState {
1131                title: req.title,
1132                lines: req.lines,
1133                items: req.items.into_iter().map(overlay_item_from).collect(),
1134                selected: 0,
1135                search,
1136                secure_input: None,
1137            }
1138        }
1139        OverlayRequest::Wizard(req) => {
1140            // Wizard overlays are multi-step flows that this TUI does not yet
1141            // render natively; surface the first step's title/items so the
1142            // user still sees something instead of a blank panel.
1143            let step_items = req
1144                .steps
1145                .first()
1146                .map(|s| {
1147                    s.items
1148                        .iter()
1149                        .map(|it| overlay_item_from(it.clone()))
1150                        .collect()
1151                })
1152                .unwrap_or_default();
1153            let search = req.search.map(|cfg| OverlaySearchState {
1154                label: cfg.label,
1155                placeholder: cfg.placeholder,
1156                value: String::new(),
1157            });
1158            OverlayState {
1159                title: req.title,
1160                lines: Vec::new(),
1161                items: step_items,
1162                selected: 0,
1163                search,
1164                secure_input: None,
1165            }
1166        }
1167    }
1168}
1169fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1170    OverlayListItem {
1171        title: item.title,
1172        subtitle: item.subtitle,
1173        badge: item.badge,
1174        indent: item.indent,
1175        search_value: item.search_value,
1176        selection: item.selection,
1177    }
1178}
1179
1180/// Map a `SessionEvent` to the matching `InlineHandle` calls. This is the
1181/// single place where the agent's event vocabulary meets the harness's
1182/// transcript vocabulary.
1183fn handle_session_event(state: &mut RenderState, handle: &InlineHandle, event: &SessionEvent) {
1184    match event {
1185        SessionEvent::Agent(boxed) => {
1186            map_agent_event(handle, *boxed.clone(), state);
1187        }
1188        SessionEvent::CompactionStart { .. } => {
1189            handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
1190        }
1191        SessionEvent::CompactionEnd { error_message, .. } => {
1192            handle.set_reasoning_stage(None);
1193            if let Some(msg) = error_message {
1194                handle.append_line(
1195                    InlineMessageKind::Error,
1196                    vec![plain_segment(format!("Compaction failed: {msg}"))],
1197                );
1198            }
1199        }
1200        SessionEvent::ThinkingLevelChanged { .. } => {
1201            // No rendering — the footer reflects this implicitly via the
1202            // header context.
1203        }
1204        SessionEvent::QueueUpdate { .. } => {
1205            // Surface the queue length as a footer status update.
1206            // The exact count is computed lazily by the agent session;
1207            // we approximate it via the snapshot we hold.
1208            let pending = state.transcript.len();
1209            handle.set_input_status(
1210                None,
1211                Some(if pending == 0 {
1212                    "ready".to_string()
1213                } else {
1214                    "queued".to_string()
1215                }),
1216            );
1217        }
1218        SessionEvent::Advisor { body, .. } => {
1219            handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
1220        }
1221        SessionEvent::SessionInfoChanged => {
1222            // The session name is reflected via header context on next
1223            // `set_header_context`. Nothing to do here.
1224        }
1225        SessionEvent::HandoffComplete { .. } => {
1226            // Intercepted in the event loop's session_rx arm before
1227            // reaching this function — transcript clearing and prompt
1228            // submission happen there. This arm exists for exhaustiveness.
1229        }
1230        SessionEvent::HandoffFailed { error } => {
1231            handle.append_line(
1232                InlineMessageKind::Error,
1233                vec![plain_segment(format!("Handoff failed: {}", error))],
1234            );
1235        }
1236    }
1237}
1238
1239/// Project the agent-level event variants onto the harness transcript.
1240fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
1241    match event {
1242        AgentEvent::TextChunk { text } => {
1243            state.message_buffer.push_str(&text);
1244            handle.inline(InlineMessageKind::Agent, plain_segment(text));
1245        }
1246        AgentEvent::MessageStart { .. } => {
1247            state.message_buffer.clear();
1248        }
1249        AgentEvent::MessageUpdate { delta, .. } => match &delta {
1250            oxicode_sdk::StreamDelta::Text(text) => {
1251                state.message_buffer.push_str(text);
1252                handle.inline(InlineMessageKind::Agent, plain_segment(text.clone()));
1253            }
1254            oxicode_sdk::StreamDelta::Thinking(text) => {
1255                // Show thinking blocks as dimmed Info lines with a ✻ marker,
1256                // visually distinct from the actual response text.
1257                let mut style = InlineTextStyle::default();
1258                style.effects |= anstyle::Effects::DIMMED;
1259                let seg = InlineSegment {
1260                    text: format!("\u{2733} {text}"),
1261                    style: Arc::new(style),
1262                };
1263                handle.inline(InlineMessageKind::Info, seg);
1264            }
1265            oxicode_sdk::StreamDelta::Sync => {
1266                // Re-render the complete message as markdown
1267                if !state.message_buffer.is_empty() {
1268                    let lines =
1269                        oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1270                    let count = lines.len();
1271                    if count > 0 {
1272                        handle.replace_last(count, InlineMessageKind::Agent, lines);
1273                    }
1274                    state.message_buffer.clear();
1275                }
1276            }
1277        },
1278        AgentEvent::MessageEnd { .. } => {
1279            // Final rendering (same as delta:None for completeness)
1280            if !state.message_buffer.is_empty() {
1281                let lines = oxicode_vtui::tui::ui::markdown::render_markdown(&state.message_buffer);
1282                let count = lines.len();
1283                if count > 0 {
1284                    handle.replace_last(count, InlineMessageKind::Agent, lines);
1285                }
1286                state.message_buffer.clear();
1287            }
1288        }
1289        AgentEvent::ToolStart { tool_name, .. } => {
1290            handle.append_line(
1291                InlineMessageKind::Tool,
1292                vec![plain_segment(format!("\u{2699} {tool_name}"))],
1293            );
1294            handle.set_reasoning_stage(Some(format!("tool: {tool_name}")));
1295        }
1296        AgentEvent::ToolComplete { result } => {
1297            // If the result looks like a diff, render with green/red coloring.
1298            if !try_render_diff(&result.content, handle) {
1299                let preview = preview_tool_result(&result.content);
1300                let mut style = InlineTextStyle::default();
1301                style.effects |= anstyle::Effects::DIMMED;
1302                handle.append_line(
1303                    InlineMessageKind::Tool,
1304                    vec![InlineSegment {
1305                        text: format!("\u{2713} {preview}"),
1306                        style: Arc::new(style),
1307                    }],
1308                );
1309            }
1310            handle.set_reasoning_stage(None);
1311            handle.set_input_enabled(true);
1312        }
1313        AgentEvent::ToolError { error, .. } => {
1314            handle.append_line(
1315                InlineMessageKind::Error,
1316                vec![plain_segment(format!("\u{2717} {error}"))],
1317            );
1318            handle.set_reasoning_stage(None);
1319            handle.set_input_enabled(true);
1320        }
1321        AgentEvent::Error { message, .. } => {
1322            handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
1323            handle.set_input_enabled(true);
1324            handle.set_input_status(None, None);
1325        }
1326        AgentEvent::Compaction { .. } => {
1327            // Detailed lifecycle is handled by the AgentSession layer
1328            // (CompactionStart/End SessionEvents).
1329        }
1330        AgentEvent::Cancelled => {
1331            handle.set_input_enabled(true);
1332            handle.set_input_status(None, Some("cancelled".to_string()));
1333        }
1334        AgentEvent::AutoRetryStart {
1335            attempt,
1336            max_attempts,
1337            ..
1338        } => {
1339            handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
1340        }
1341        AgentEvent::TurnEnd { .. } => {
1342            // Notify via the terminal's best-supported desktop-notification
1343            // protocol (OSC 9/99/777, falling back to BEL) so the user
1344            // notices a finished turn even when the window is unfocused.
1345            crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
1346            // The next queued prompt (if any) now starts running — drop it
1347            // from the visible queue pane so the pane only shows still-pending
1348            // inputs.
1349            state.drain_queue_head();
1350            handle.set_reasoning_stage(None);
1351        }
1352        _ => {
1353            // Other variants (TurnStart/End, AgentStart/End, Usage, …) are
1354            // logged but not rendered — they're either metadata or covered
1355            // by the dedicated SessionEvent variants above.
1356            tracing::debug!(?event, "ignored AgentEvent variant");
1357        }
1358    }
1359}
1360
1361/// Decide which `/providers` actions apply for a provider, given whether
1362/// the user already has a stored credential and whether the provider
1363/// supports the OAuth `authorization_code` flow.
1364///
1365/// Single-action branches skip the menu entirely and drive directly
1366/// (no user-visible "Pick an action" list for the obvious cases).
1367pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
1368    match (has_key, oauth_capable) {
1369        (true, true) => vec![
1370            AuthAction::SetApiKey,
1371            AuthAction::StartOAuth,
1372            AuthAction::RemoveKey,
1373        ],
1374        (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
1375        (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
1376        (false, false) => vec![AuthAction::SetApiKey],
1377    }
1378}
1379
1380/// Open a masked secure prompt and stash the `origin` so the
1381/// `OverlaySubmission::SecureInput` consumer can route the key to the
1382/// right provider slot and emit a contextual follow-up message.
1383///
1384/// Shared by:
1385/// - `handle_auth_action::SetApiKey` (replace or first-time key entry)
1386/// - `add_custom_provider` (chain immediately after persisting a new
1387///   custom provider so the user does not have to navigate back)
1388///
1389/// The caller must consume the boolean return value the same way it does
1390/// for `handle_auth_action`: `true` means a new overlay was opened, so
1391/// the previously-open overlay must NOT be closed in the same submit
1392/// pass (the cmd channel processes `ShowOverlay` and `CloseOverlay` in
1393/// submit order).
1394pub(crate) fn open_secure_prompt(
1395    state: &mut RenderState,
1396    handle: &InlineHandle,
1397    origin: SecureInputOrigin,
1398) {
1399    let provider = match &origin {
1400        SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
1401            provider.clone()
1402        }
1403    };
1404    state.secure_input_origin = Some(origin);
1405    handle.show_modal(
1406        format!("Set API key for {provider}"),
1407        vec![
1408            "Paste the API key. Press Enter to save, Esc to cancel.".into(),
1409            "The key is masked on screen; nothing is logged.".into(),
1410        ],
1411        Some(SecurePromptConfig {
1412            label: format!("{provider} key"),
1413            placeholder: Some("sk-...".into()),
1414            mask_input: true,
1415        }),
1416    );
1417}
1418
1419/// Dispatch a single `AuthAction` for `provider`.
1420///
1421/// `SetApiKey` opens the secure (masked) prompt via `open_secure_prompt`
1422/// (stashing `SecureInputOrigin::SetKey` so the consumer can route the
1423/// key to the right provider). `StartOAuth` spawns `run_oauth_flow` on a
1424/// dedicated tokio task (PKCE + loopback callback + token exchange +
1425/// persistence). `RemoveKey` reuses the existing confirmation modal —
1426/// its `ConfirmationAction::RemoveProviderKey` handler runs through
1427/// `/providers remove <name> --yes`.
1428pub(crate) fn handle_auth_action(
1429    provider: &str,
1430    action: &AuthAction,
1431    auth: &Arc<crate::store::auth_storage::AuthStorage>,
1432    handle: &InlineHandle,
1433    state: &mut RenderState,
1434) -> bool {
1435    // Returns true when the dispatched action opened a new overlay via
1436    // `handle.show_*` (currently only `SetApiKey` opens the secure prompt
1437    // modal). The caller — the `OverlayEvent::Submitted` arm in
1438    // `handle_inline_event` — uses this signal to decide whether the
1439    // previously-open overlay should be closed after dispatch. Closing
1440    // unconditionally would also clear the freshly-opened overlay because
1441    // the cmd channel processes `ShowOverlay` and `CloseOverlay` in submit
1442    // order, so a stale `CloseOverlay` enqueued right after the
1443    // `ShowOverlay` wins. Branches that do NOT open a new overlay
1444    // (`StartOAuth` spawns an async task, `RemoveKey` sets
1445    // `state.confirmation` rather than `state.overlay`) return false so
1446    // the caller is free to close the old overlay.
1447    match action {
1448        AuthAction::SetApiKey => {
1449            open_secure_prompt(
1450                state,
1451                handle,
1452                SecureInputOrigin::SetKey {
1453                    provider: provider.to_string(),
1454                },
1455            );
1456            true
1457        }
1458        AuthAction::StartOAuth => {
1459            // PKCE + loopback-callback glue lives in `run_oauth_flow`
1460            // (defined just below `handle_auth_action`). Spawn it on a
1461            // dedicated tokio task so the main loop can continue
1462            // rendering; the spawned task posts status updates back to
1463            // the transcript via the cloned `InlineHandle`.
1464            //
1465            // First, gate on the provider actually having an OAuth
1466            // spec in `product-meta.toml` — the action is only offered
1467            // when `next_provider_actions` includes it, so this branch
1468            // is purely defensive against a stale UI state.
1469            let spec = match crate::provider_oauth::spec_for(provider) {
1470                Some(s) => s,
1471                None => {
1472                    handle.append_line(
1473                        InlineMessageKind::Error,
1474                        vec![plain_segment(format!(
1475                            "OAuth: no OAuth config for '{provider}'."
1476                        ))],
1477                    );
1478                    return false;
1479                }
1480            };
1481            // `provider_owned` and `tx` are cloned Strings/`InlineHandle`s
1482            // owned by the task; `auth_clone` is the shared storage
1483            // singleton (cheap to clone — it is already `Arc`-backed).
1484            // `spec` is moved into the task.
1485            let provider_owned = provider.to_string();
1486            let tx = handle.clone();
1487            let auth_clone = Arc::clone(auth);
1488            tokio::spawn(async move {
1489                run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
1490            });
1491            false
1492        }
1493        AuthAction::RemoveKey => {
1494            state.confirmation = Some(ModalConfirmation {
1495                title: format!("Remove key for {provider}?"),
1496                message: "  y \u{2014} remove key     n / x \u{2014} cancel".into(),
1497                action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
1498            });
1499            false
1500        }
1501    }
1502}
1503
1504/// Drive the OAuth `authorization_code` flow for `provider` end to end:
1505///
1506/// 1. Bind an ephemeral loopback TCP listener and capture its port.
1507/// 2. Generate PKCE verifier + S256 challenge (`provider_oauth::pkce_pair`).
1508/// 3. Build the authorization URL (`provider_oauth::build_auth_url`) and
1509///    open it in the user's browser (`provider_oauth::open_browser`).
1510/// 4. Wait on the listener for the redirect carrying the `code` + `state`
1511///    (`oauth_listener::await_callback`); bind a timeout so a stuck
1512///    listener cannot leak.
1513/// 5. Exchange the code for tokens at the provider's token URL
1514///    (`provider_oauth::exchange_code`).
1515/// 6. Persist the OAuth credential via `AuthStorage::set_oauth_full` so
1516///    subsequent requests can use the access token (and `refresh_token`
1517///    if granted) without re-prompting the user.
1518///
1519/// Steps that hard-fail (callback timeout, state mismatch, missing
1520/// `code`, exchange error, persist error) post an `InlineMessageKind::Error`
1521/// line to the transcript and return; the bound listener is dropped on
1522/// every return path, satisfying the single-shot invariant.
1523///
1524/// Headless fallback (plan §3 / design §3): if `open_browser` returns
1525/// `Err`, we do NOT abort. We post an `Info` line printing the auth URL
1526/// and lengthen the callback timeout to 5 minutes so the user can paste
1527/// the URL into a browser on another machine and complete the flow.
1528/// Masking: every user-facing line that mentions the access token
1529/// surfaces only the token length (`access_token.chars().count()`), never
1530/// the value. Tokens are never logged via `tracing`.
1531pub(crate) async fn run_oauth_flow(
1532    provider: String,
1533    spec: crate::provider_oauth::ProviderOAuthSpec,
1534    handle: InlineHandle,
1535    auth: Arc<crate::store::auth_storage::AuthStorage>,
1536) {
1537    use std::time::Duration;
1538    // Timeout is selected AFTER the browser attempt: 2 minutes when the
1539    // browser opened (the user is right in front of it), 5 minutes when
1540    // it didn't (headless box — user has to copy the URL to another
1541    // machine, sign in there, and the redirect has to traverse NAT).
1542    // The variable is declared once as `mut` and then frozen below.
1543    // 1. Bind the loopback listener BEFORE opening the browser so the
1544    //    `redirect_uri` we hand to the provider already points at a live
1545    //    port. `TcpListener::bind("127.0.0.1:0")` picks an ephemeral port.
1546    let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
1547        Ok(l) => l,
1548        Err(e) => {
1549            handle.append_line(
1550                InlineMessageKind::Error,
1551                vec![plain_segment(format!(
1552                    "OAuth: could not bind loopback listener for '{provider}': {e}"
1553                ))],
1554            );
1555            return;
1556        }
1557    };
1558    let port = match listener.local_addr() {
1559        Ok(addr) => addr.port(),
1560        Err(e) => {
1561            handle.append_line(
1562                InlineMessageKind::Error,
1563                vec![plain_segment(format!(
1564                    "OAuth: could not read loopback port for '{provider}': {e}"
1565                ))],
1566            );
1567            return;
1568        }
1569    };
1570
1571    // 2. PKCE pair + per-flow `state`. The state must match what we send
1572    //    in the auth URL and what we accept on the callback — a single
1573    //    random base64-url string is enough since the flow is single-shot.
1574    let (verifier, challenge) = crate::provider_oauth::pkce_pair();
1575    let state_token = crate::provider_oauth::pkce_pair().0; // 43-char url-safe random
1576
1577    // 3. Build auth URL and open the browser. `open_browser` already
1578    //    validates the URL scheme so a malformed spec would have failed
1579    //    at `build_auth_url` time (it calls `Url::parse` internally).
1580    let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
1581    handle.append_line(
1582        InlineMessageKind::Info,
1583        vec![plain_segment(format!(
1584            "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
1585            spec.redirect_path
1586        ))],
1587    );
1588    // Pick the callback timeout based on whether the browser opened.
1589    // Headless fallback (plan §3 / design §3): when the OS refuses to
1590    // launch a browser, we surface the URL and KEEP listening so a user
1591    // on a different machine can paste it, sign in, and let the
1592    // redirect land back on our loopback port. A 5-minute window is
1593    // long enough for that round-trip; a 2-minute window is plenty
1594    // when the browser already opened in front of the user.
1595    let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
1596        Ok(()) => Duration::from_secs(120),
1597        Err(e) => {
1598            handle.append_line(
1599                InlineMessageKind::Info,
1600                vec![plain_segment(format!(
1601                    "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n  {auth_url}"
1602                ))],
1603            );
1604            Duration::from_secs(300)
1605        }
1606    };
1607
1608    // 4. Wait for the callback. The listener is single-shot by design:
1609    //    `await_callback` accepts exactly one connection.
1610    let callback = match crate::oauth_listener::await_callback(
1611        listener,
1612        state_token.clone(),
1613        spec.redirect_path.clone(),
1614        callback_timeout,
1615    )
1616    .await
1617    {
1618        Ok(c) => c,
1619        Err(crate::oauth_listener::CallbackError::Timeout) => {
1620            handle.append_line(
1621                InlineMessageKind::Error,
1622                vec![plain_segment(format!(
1623                    "OAuth: timed out waiting for '{provider}' callback (after {}s)",
1624                    callback_timeout.as_secs()
1625                ))],
1626            );
1627            return;
1628        }
1629        Err(e) => {
1630            handle.append_line(
1631                InlineMessageKind::Error,
1632                vec![plain_segment(format!(
1633                    "OAuth: callback failed for '{provider}': {e}"
1634                ))],
1635            );
1636            return;
1637        }
1638    };
1639
1640    // 5. Exchange code → tokens.
1641    let tokens =
1642        match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
1643            Ok(t) => t,
1644            Err(e) => {
1645                handle.append_line(
1646                    InlineMessageKind::Error,
1647                    vec![plain_segment(format!(
1648                        "OAuth: token exchange failed for '{provider}': {e}"
1649                    ))],
1650                );
1651                return;
1652            }
1653        };
1654
1655    // 6. Persist. `set_oauth_full` takes u64 `expires_at`; `OAuthTokens`
1656    //    exposes i64 (so callers can branch on `now < expires_at` in
1657    //    signed arithmetic). Saturate defensively — the value is always
1658    //    `now + expires_in` with `expires_in >= 0`, so negatives are
1659    //    impossible here, but a guard costs nothing.
1660    let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
1661    let access_token_len = tokens.access_token.chars().count();
1662    // `set_oauth_full` returns `()` and logs persistence failures via
1663    // `tracing::warn` — the in-memory credential is always updated.
1664    auth.set_oauth_full(
1665        &provider,
1666        tokens.access_token,
1667        tokens.refresh_token,
1668        new_expires_at,
1669        if tokens.scopes.is_empty() {
1670            None
1671        } else {
1672            Some(tokens.scopes.join(" "))
1673        },
1674        None,
1675    );
1676    handle.append_line(
1677        InlineMessageKind::Info,
1678        vec![plain_segment(format!(
1679            "OAuth: '{provider}' logged in. Token stored ({} chars).",
1680            access_token_len
1681        ))],
1682    );
1683}
1684
1685/// Map an input-thread `InlineEvent` to agent actions / state edits.
1686fn handle_inline_event(
1687    state: &mut RenderState,
1688    handle: &InlineHandle,
1689    session: &crate::app::agent_session::AgentSessionHandle,
1690    prompt_tx: &tokio::sync::mpsc::UnboundedSender<String>,
1691    evt: InlineEvent,
1692) -> LoopOutcome {
1693    match evt {
1694        InlineEvent::Submit(text) => {
1695            // ── Drain pending resume (set by /sessions <id> or the picker). ──
1696            if let Some(path) = state.pending_resume.take() {
1697                let swapper = state.swapper();
1698                let agent_arc = Arc::clone(&session.agent_arc());
1699                let settings = session.settings_clone();
1700                let session_state = state
1701                    .session_state
1702                    .clone()
1703                    .expect("RenderState::session_state must be initialized at TUI startup");
1704                let path_for_log = path.clone();
1705                let handle = handle.clone();
1706                let swapper_for_swap = swapper.clone();
1707                tokio::spawn(async move {
1708                    match crate::app::agent_session::resume_from_file(
1709                        agent_arc,
1710                        settings,
1711                        session_state,
1712                        &path,
1713                        None,
1714                    )
1715                    .await
1716                    {
1717                        Ok(new_session) => {
1718                            swapper_for_swap.swap(new_session.clone_handle());
1719                            let n = new_session.messages().len();
1720                            let id = new_session.session_id();
1721                            handle.append_line(
1722                                InlineMessageKind::Info,
1723                                vec![plain_segment(format!(
1724                                    "Resumed session {id} ({n} messages)"
1725                                ))],
1726                            );
1727                        }
1728                        Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
1729                            handle.append_line(
1730                                InlineMessageKind::Error,
1731                                vec![plain_segment(format!("No session file: {}", p.display()))],
1732                            );
1733                        }
1734                        Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
1735                            handle.append_line(
1736                                InlineMessageKind::Error,
1737                                vec![plain_segment(format!(
1738                                    "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
1739                                     Use /export to save its content, then /clear.",
1740                                    path_for_log.display()
1741                                ))],
1742                            );
1743                        }
1744                    }
1745                });
1746                return LoopOutcome::Continue;
1747            }
1748            // Drain the composer — the input thread already cleared its
1749            // local copy once Submit fired, but we keep the canonical
1750            // buffer here in sync.
1751            let prompt = text.to_string();
1752            state.input_buffer.clear();
1753            state.input_cursor = 0;
1754            if prompt.is_empty() {
1755                return LoopOutcome::Continue;
1756            }
1757            state.pending_quit = false;
1758            // Slash commands: dispatch locally instead of forwarding to
1759            // the agent. The echoed line is appended before dispatch so
1760            // every command output appears after the prompt.
1761            if prompt.trim_start().starts_with('/') {
1762                state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1763                let mut ctx = SlashCtx {
1764                    session,
1765                    handle,
1766                    state,
1767                };
1768                return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
1769                    SlashOutcome::Quit => LoopOutcome::Exit,
1770                    SlashOutcome::Handled => LoopOutcome::Continue,
1771                    SlashOutcome::NotHandled => {
1772                        // File-based commands: try before erroring.
1773                        if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
1774                            &ctx.state.file_commands,
1775                            &prompt,
1776                        ) {
1777                            // Send expanded text directly to the agent worker.
1778                            // The original `/cmd args` is already echoed above.
1779                            let _ = prompt_tx.send(expanded);
1780                            LoopOutcome::Continue
1781                        } else {
1782                            ctx.reply(
1783                                InlineMessageKind::Error,
1784                                format!("Unknown command: {}", prompt.trim()),
1785                            );
1786                            LoopOutcome::Continue
1787                        }
1788                    }
1789                };
1790            }
1791            state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
1792            // While a run is active, mirror the prompt into the queue pane so
1793            // the user sees their input is queued (the worker channel already
1794            // serialises execution; this is the visible counterpart).
1795            if session.is_streaming() {
1796                state.queued_inputs.push(prompt.clone());
1797                state.show_tip(
1798                    "send_now",
1799                    "Ctrl+Enter sends now  \u{00b7}  Ctrl+; manages queue",
1800                    240,
1801                    true,
1802                );
1803            }
1804            // Hand the prompt to the worker thread. If the worker has
1805            // already exited (e.g. shutdown), drop it on the floor.
1806            let _ = prompt_tx.send(prompt);
1807        }
1808        InlineEvent::Cancel => {
1809            // Esc-driven cancel. While a stream is running, abort it (the
1810            // input thread's ~1s post-cancel grace then prevents mashing).
1811            // When idle, Esc is an instant one-press quit — no grace, no
1812            // quit-arming footer that would invite a re-press the grace
1813            // swallows.
1814            return match route_cancel(session.is_streaming()) {
1815                CancelRoute::Interrupt => handle_interrupt(state, session, handle),
1816                CancelRoute::Exit => LoopOutcome::Exit,
1817            };
1818        }
1819        InlineEvent::Exit => {
1820            return LoopOutcome::Exit;
1821        }
1822        InlineEvent::Interrupt => {
1823            return handle_interrupt(state, session, handle);
1824        }
1825        InlineEvent::ScrollLineUp => {
1826            state.scroll_offset = state.scroll_offset.saturating_add(1);
1827        }
1828        InlineEvent::ScrollLineDown => {
1829            state.scroll_offset = state.scroll_offset.saturating_sub(1);
1830        }
1831        InlineEvent::ScrollPageUp => {
1832            state.scroll_offset = state.scroll_offset.saturating_add(10);
1833        }
1834        InlineEvent::ScrollPageDown => {
1835            state.scroll_offset = state.scroll_offset.saturating_sub(10);
1836        }
1837        InlineEvent::CyclePrimaryAgent => {
1838            let _ = session.cycle_model();
1839        }
1840        InlineEvent::CyclePrimaryAgentPrevious => {
1841            // No dedicated reverse-cycling API in AgentSession yet;
1842            // forward-cycle is the closest match.
1843            let _ = session.cycle_model();
1844        }
1845        InlineEvent::Overlay(overlay_evt) => {
1846            use oxicode_vtui::tui::core::OverlayEvent;
1847            match overlay_evt {
1848                OverlayEvent::Submitted(sub) => {
1849                    // Tracks whether this submission chained into a new overlay
1850                    // (the action menu after `/providers` row selection, or the
1851                    // secure prompt after `SetApiKey`). When set, the
1852                    // unconditional `close_overlay()` at the end of the arm
1853                    // would clear the freshly-opened overlay because the
1854                    // `cmd` channel processes `ShowOverlay` and
1855                    // `CloseOverlay` in submit order. Stale-state cleanup
1856                    // (clearing `overlay_providers` etc.) still runs — only
1857                    // the close is gated.
1858                    let mut opened_new_overlay = false;
1859                    // If this was a /model picker, set the selected model.
1860                    if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
1861                        && idx < &state.overlay_model_ids.len()
1862                    {
1863                        let model_id = state.overlay_model_ids[*idx].clone();
1864                        match session.set_model(&model_id) {
1865                            Ok(()) => handle.append_line(
1866                                InlineMessageKind::Info,
1867                                vec![plain_segment(format!("Switched to {model_id}"))],
1868                            ),
1869                            Err(e) => handle.append_line(
1870                                InlineMessageKind::Error,
1871                                vec![plain_segment(format!("Failed to set model: {e}"))],
1872                            ),
1873                        }
1874                    }
1875                    // If this was a /theme picker, apply the selected theme.
1876                    if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
1877                    {
1878                        match oxicode_vtui::theme::set_active_theme(theme_id) {
1879                            Ok(()) => {
1880                                let label = oxicode_vtui::theme::theme_label(theme_id)
1881                                    .unwrap_or(theme_id.as_ref())
1882                                    .to_string();
1883                                handle.append_line(
1884                                    InlineMessageKind::Info,
1885                                    vec![plain_segment(format!("Theme: {label}"))],
1886                                );
1887                            }
1888                            Err(e) => handle.append_line(
1889                                InlineMessageKind::Error,
1890                                vec![plain_segment(format!("Unknown theme: {e}"))],
1891                            ),
1892                        }
1893                    }
1894                    // If this was a command palette selection, fill the prompt.
1895                    if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
1896                        &sub
1897                    {
1898                        state.input_buffer = format!("/{name} ");
1899                        state.input_cursor = state.input_buffer.len();
1900                    }
1901                    // Settings overlay: toggle/cycle the selected setting.
1902                    if let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
1903                        &sub
1904                    {
1905                        match key.as_str() {
1906                            "thinking_level" => {
1907                                if let Some(level) = session.cycle_thinking_level() {
1908                                    handle.append_line(
1909                                        InlineMessageKind::Info,
1910                                        vec![plain_segment(format!("Thinking: {level:?}"))],
1911                                    );
1912                                }
1913                            }
1914                            "auto_compaction" => {
1915                                let enabled = !session.auto_compaction_enabled();
1916                                session.set_auto_compaction(enabled);
1917                                handle.append_line(
1918                                    InlineMessageKind::Info,
1919                                    vec![plain_segment(format!(
1920                                        "Auto-compaction: {}",
1921                                        if enabled { "on" } else { "off" }
1922                                    ))],
1923                                );
1924                            }
1925                            "auto_retry" => {
1926                                let enabled = !session.auto_retry_enabled();
1927                                session.set_auto_retry(enabled);
1928                                handle.append_line(
1929                                    InlineMessageKind::Info,
1930                                    vec![plain_segment(format!(
1931                                        "Auto-retry: {}",
1932                                        if enabled { "on" } else { "off" }
1933                                    ))],
1934                                );
1935                            }
1936                            "advisor" => match session.toggle_advisor() {
1937                                Ok(enabled) => handle.append_line(
1938                                    InlineMessageKind::Info,
1939                                    vec![plain_segment(format!(
1940                                        "Advisor: {}",
1941                                        if enabled { "on" } else { "off" }
1942                                    ))],
1943                                ),
1944                                Err(e) => handle.append_line(
1945                                    InlineMessageKind::Error,
1946                                    vec![plain_segment(format!("Failed to toggle advisor: {e}"))],
1947                                ),
1948                            },
1949                            _ => {}
1950                        }
1951                    }
1952                    // Session picker: enqueue the selected session. The next
1953                    // Submit event drains it before normal composer dispatch.
1954                    if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
1955                        // Gate: refuse to queue a resume while the agent is
1956                        // running — the pending_resume drain would clobber
1957                        // the in-flight conversation's message history on
1958                        // the shared Arc<Agent> (same wording as the direct
1959                        // /sessions <id> path and /handoff).
1960                        if session.is_streaming() {
1961                            handle.append_line(
1962                                InlineMessageKind::Error,
1963                                vec![plain_segment(
1964                                    "Cannot resume while agent is running. Use /cancel first.",
1965                                )],
1966                            );
1967                        } else {
1968                            let path = crate::tui_vt::slash::registry::sessions_dir()
1969                                .join(format!("{id}.jsonl"));
1970                            if !path.is_file() {
1971                                handle.append_line(
1972                                    InlineMessageKind::Error,
1973                                    vec![plain_segment(format!(
1974                                        "No session file: {}",
1975                                        path.display()
1976                                    ))],
1977                                );
1978                            } else {
1979                                state.pending_resume = Some(path);
1980                            }
1981                        }
1982                    }
1983                    // `/models` catalog browser: switch to the selected model.
1984                    if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
1985                        &sub
1986                        && idx < &state.overlay_catalog_models.len()
1987                    {
1988                        let (provider, model_id) = &state.overlay_catalog_models[*idx];
1989                        let full = format!("{provider}/{model_id}");
1990                        match session.set_model(&full) {
1991                            Ok(()) => handle.append_line(
1992                                InlineMessageKind::Info,
1993                                vec![plain_segment(format!("Switched to {full}"))],
1994                            ),
1995                            Err(e) => handle.append_line(
1996                                InlineMessageKind::Error,
1997                                vec![plain_segment(format!("Failed to set model: {e}"))],
1998                            ),
1999                        }
2000                    }
2001                    // `/providers` list: pick a provider, then drive the
2002                    // `next_provider_actions(has_key, oauth_capable)` matrix.
2003                    // Single-action cases fire straight into
2004                    // `handle_auth_action`; multi-action cases open a
2005                    // one-shot action list whose selections are
2006                    // `ProviderAction { provider, action }`.
2007                    if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
2008                        &sub
2009                        && idx < &state.overlay_providers.len()
2010                    {
2011                        let name = state.overlay_providers[*idx].clone();
2012                        let auth = crate::store::auth_storage::shared_auth_storage();
2013                        let has_key = auth.has(&name);
2014                        let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
2015                        let actions = next_provider_actions(has_key, oauth_capable);
2016                        if actions.len() == 1 {
2017                            // Single action — drive directly with no menu.
2018                            opened_new_overlay |=
2019                                handle_auth_action(&name, &actions[0], &auth, handle, state);
2020                        } else {
2021                            // Show action menu.
2022                            let items: Vec<InlineListItem> = actions
2023                                .iter()
2024                                .map(|a| InlineListItem {
2025                                    title: match a {
2026                                        AuthAction::SetApiKey => "Set API key".into(),
2027                                        AuthAction::StartOAuth => "Login with OAuth".into(),
2028                                        AuthAction::RemoveKey => "Remove key".into(),
2029                                    },
2030                                    subtitle: None,
2031                                    badge: None,
2032                                    indent: 0,
2033                                    selection: Some(InlineListSelection::ProviderAction {
2034                                        provider: name.clone(),
2035                                        action: a.clone(),
2036                                    }),
2037                                    search_value: None,
2038                                })
2039                                .collect();
2040                            handle.show_list_modal(
2041                                name.clone(),
2042                                vec!["Pick an action".into()],
2043                                items,
2044                                None,
2045                                None,
2046                            );
2047                            opened_new_overlay = true;
2048                        }
2049                    }
2050                    // `/providers` action menu: forward the chosen
2051                    // `AuthAction` to the host dispatcher. Selecting
2052                    // "Remove key" reuses the existing y/n confirmation
2053                    // modal; "Set API key" opens the secure prompt;
2054                    // "Login with OAuth" prints the Task 8 stub.
2055                    if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
2056                        provider,
2057                        action,
2058                    }) = &sub
2059                    {
2060                        let auth = crate::store::auth_storage::shared_auth_storage();
2061                        opened_new_overlay |=
2062                            handle_auth_action(provider, action, &auth, handle, state);
2063                    }
2064                    // Secure (masked) prompt committed by the user. The
2065                    // matching open prompt must have stashed
2066                    // `state.secure_input_origin`; we trust that field
2067                    // here because every prompt path goes through
2068                    // `open_secure_prompt` (SetApiKey, add_custom_provider)
2069                    // which sets it before opening the modal.
2070                    if let OverlaySubmission::SecureInput(text) = &sub
2071                        && let Some(origin) = state.secure_input_origin.take()
2072                    {
2073                        let provider = match &origin {
2074                            SecureInputOrigin::SetKey { provider }
2075                            | SecureInputOrigin::NewlyAdded { provider } => provider.clone(),
2076                        };
2077                        let auth = crate::store::auth_storage::shared_auth_storage();
2078                        auth.set_api_key(&provider, text.clone());
2079                        let msg = match origin {
2080                            SecureInputOrigin::SetKey { .. } => format!(
2081                                "Saved API key for '{provider}' ({} chars).",
2082                                text.chars().count()
2083                            ),
2084                            SecureInputOrigin::NewlyAdded { .. } => format!(
2085                                "Added and configured '{provider}' ({} chars).\n\
2086                                 Run /models to browse available models, or /providers to manage.",
2087                                text.chars().count()
2088                            ),
2089                        };
2090                        handle.append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
2091                    }
2092                    state.overlay_catalog_models.clear();
2093                    state.overlay_providers.clear();
2094                    state.overlay_model_ids.clear();
2095                    if !opened_new_overlay {
2096                        handle.close_overlay();
2097                    }
2098                }
2099                OverlayEvent::Cancelled => {
2100                    handle.close_overlay();
2101                }
2102                OverlayEvent::SelectionChanged(_) => {}
2103            }
2104        }
2105        _ => {
2106            // Other events (overlay, list-selection, etc.) are no-ops in
2107            // this harness — they are handled by the harness overlay
2108            // component, not by the inline protocol.
2109        }
2110    }
2111    LoopOutcome::Continue
2112}
2113
2114// ─────────────────────────────────────────────────────────────────────────
2115// Ctrl+C policy / streaming guard
2116// ─────────────────────────────────────────────────────────────────────────
2117
2118/// RAII guard that clears the streaming flag on drop (normal exit, error,
2119/// or panic cancellation). Wired in [`run_one_prompt`] around each run.
2120struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
2121
2122impl Drop for StreamingGuard<'_> {
2123    fn drop(&mut self) {
2124        use std::sync::atomic::Ordering;
2125        self.0.store(false, Ordering::SeqCst);
2126    }
2127}
2128
2129/// Central Ctrl+C policy.
2130///
2131/// - **Agent streaming** → abort the current run and tell the user to press
2132///   again to quit. The abort is effective because the session hooks installed
2133///   via `App::from_oxicode` → `with_session_hooks` wire the session's
2134///   `should_stop` flag into the agent loop.
2135/// - **Agent idle** → exit the application.
2136///
2137/// Both the input-thread key event (`InlineEvent::Interrupt`) and the OS
2138/// signal handler (`tokio::signal::ctrl_c()`) route through here so
2139/// behavior is identical regardless of how the interrupt arrives.
2140///
2141fn handle_interrupt(
2142    state: &mut RenderState,
2143    session: &crate::app::agent_session::AgentSessionHandle,
2144    _handle: &InlineHandle,
2145) -> LoopOutcome {
2146    // If a confirmation is already open, Ctrl+C acts as confirm (quit).
2147    if state.confirmation.is_some() {
2148        return LoopOutcome::Exit;
2149    }
2150    // A second Ctrl+C (after the first armed a quit during a stream) opens
2151    // the quit confirmation modal instead of exiting outright.
2152    if state.pending_quit {
2153        state.confirmation = Some(quit_confirmation());
2154        state.pending_quit = false;
2155        return LoopOutcome::Continue;
2156    }
2157    // First Ctrl+C. While streaming, abort the run and arm a quit (the next
2158    // press opens the confirmation). When idle, open the confirmation at
2159    // once — no separate quit-arming step needed.
2160    if session.is_streaming() {
2161        let s = session.clone();
2162        tokio::spawn(async move {
2163            s.abort().await;
2164        });
2165        state.footer_left = Some("Stopping\u{2026} press Ctrl+C again to confirm quit".to_string());
2166        state.pending_quit = true;
2167    } else {
2168        state.footer_left = None;
2169        state.confirmation = Some(quit_confirmation());
2170    }
2171    LoopOutcome::Continue
2172}
2173
2174/// Build the standard quit-confirmation dialog.
2175fn quit_confirmation() -> ModalConfirmation {
2176    ModalConfirmation {
2177        title: "Quit oxicode?".into(),
2178        message: "  y \u{2014} quit now     n / x \u{2014} stay".into(),
2179        action: ConfirmationAction::Quit,
2180    }
2181}
2182
2183/// Build a clear-conversation confirmation dialog.
2184pub(super) fn clear_confirmation() -> ModalConfirmation {
2185    ModalConfirmation {
2186        title: "Clear conversation?".into(),
2187        message: "  y \u{2014} clear all     n / x \u{2014} cancel".into(),
2188        action: ConfirmationAction::ClearConversation,
2189    }
2190}
2191
2192// ─────────────────────────────────────────────────────────────────────────
2193// Input thread — polls crossterm, edits the shared buffer, and forwards
2194// lifecycle events (Submit, Cancel, …) over a tokio channel.
2195// ─────────────────────────────────────────────────────────────────────────
2196
2197fn spawn_input_thread(
2198    state: Arc<parking_lot::Mutex<RenderState>>,
2199    evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2200    mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
2201) -> std::thread::JoinHandle<()> {
2202    std::thread::spawn(move || {
2203        // Poll stdin in a tight loop. `event::poll` returns `Ok(false)` on
2204        // timeout (no key within the window) — that is NOT a reason to exit,
2205        // only to poll again. The previous `while let Ok(true) = poll(...)`
2206        // treated the first timeout as loop termination, killing this thread
2207        // ~50ms after launch, dropping `evt_tx`, and leaving the TUI unable
2208        // to receive keyboard input — a black screen that only redrew on
2209        // Ctrl+C. Exit only on a genuine read error (stdin closed).
2210        loop {
2211            match event::poll(std::time::Duration::from_millis(50)) {
2212                Ok(true) => {}
2213                Ok(false) => continue,
2214                Err(_) => break,
2215            }
2216            let event = match event::read() {
2217                Ok(ev) => ev,
2218                Err(_) => continue,
2219            };
2220
2221            // Bracketed paste arrives as its own event; flatten into a
2222            // string of `Submit` text.
2223            let mut pasted = String::new();
2224            let mut key_event = None;
2225            match event {
2226                Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
2227                Event::Paste(p) => pasted = p,
2228                _ => {}
2229            }
2230
2231            if !pasted.is_empty() {
2232                // When an overlay with a secure prompt is open, the paste
2233                // targets the masked input field instead of the main
2234                // composer buffer. Single-line filter (drops non-graphic
2235                // bytes, strips trailing newline) keeps secrets clean.
2236                let routed_to_secure = {
2237                    let mut s = state.lock();
2238                    if let Some(overlay) = s.overlay.as_mut() {
2239                        if let Some(secure) = overlay.secure_input.as_mut() {
2240                            let (v, c) = insert_paste_into_secure_input(
2241                                &secure.value,
2242                                secure.cursor,
2243                                &pasted,
2244                            );
2245                            secure.value = v;
2246                            secure.cursor = c;
2247                            true
2248                        } else {
2249                            false
2250                        }
2251                    } else {
2252                        false
2253                    }
2254                };
2255                if routed_to_secure {
2256                    continue;
2257                }
2258                let mut s = state.lock();
2259                let cursor = s.input_cursor;
2260                s.input_buffer.insert_str(cursor, &pasted);
2261                s.input_cursor = cursor + pasted.len();
2262                // Refresh popups so e.g. a paste that turns the buffer
2263                // into `/sessions <id>` closes the slash autocomplete
2264                // (it deactivates when `buf[1..].contains(' ')`). Without
2265                // this, the popup stays open with stale items and the
2266                // next Enter would replace the buffer with the bare
2267                // command name, dropping the pasted args.
2268                refresh_input_popups(&mut s);
2269                continue;
2270            }
2271
2272            let Some(key) = key_event else { continue };
2273
2274            // Ctrl+C: even with raw mode enabled some terminals / shells
2275            // fall back to delivering it as a SIGINT. Handle it as an
2276            // explicit interrupt so we don't depend on the OS signal.
2277            if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
2278                let _ = evt_tx.send(InlineEvent::Interrupt);
2279                continue;
2280            }
2281
2282            // Ctrl+M: toggle multiline input mode.
2283            if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
2284                let mut s = state.lock();
2285                s.multiline_mode = !s.multiline_mode;
2286                continue;
2287            }
2288
2289            // Ctrl+P: open the command palette.
2290            if key.code == KeyCode::Char('p') && key.modifiers.contains(KeyModifiers::CONTROL) {
2291                let mut s = state.lock();
2292                s.overlay = Some(build_command_palette());
2293                continue;
2294            }
2295
2296            // Ctrl+;: toggle the interactive queue panel.
2297            if key.code == KeyCode::Char(';') && key.modifiers.contains(KeyModifiers::CONTROL) {
2298                let mut s = state.lock();
2299                s.queue_panel_open = !s.queue_panel_open;
2300                if s.queue_panel_open {
2301                    s.queue_selected = 0;
2302                }
2303                continue;
2304            }
2305
2306            // Ctrl+E: fold all blocks (Shift+E expands all).
2307            if key.code == KeyCode::Char('e') && key.modifiers.contains(KeyModifiers::CONTROL) {
2308                let mut s = state.lock();
2309                s.fold_all();
2310                continue;
2311            }
2312
2313            // Ctrl+Enter: send-now — abort the current run (if any) and submit
2314            // the composed input immediately, bypassing the queue pane.
2315            if key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL) {
2316                let submitted = {
2317                    let mut s = state.lock();
2318                    let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2319                        format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
2320                    } else {
2321                        std::mem::take(&mut s.input_buffer)
2322                    };
2323                    s.input_cursor = 0;
2324                    s.slash_popup = SlashPopup::default();
2325                    s.history_pos = None;
2326                    if !buf.is_empty() && !buf.starts_with('/') {
2327                        s.prompt_history.insert(0, buf.clone());
2328                        s.prompt_history.truncate(100);
2329                    }
2330                    buf
2331                };
2332                if !submitted.is_empty() {
2333                    let _ = evt_tx.send(InlineEvent::Interrupt);
2334                    let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2335                }
2336                continue;
2337            }
2338
2339            // Confirmation modal takes priority over everything except
2340            // Ctrl+C (handled above): y/Enter confirms, n/x/Esc cancels.
2341            {
2342                let s = state.lock();
2343                if s.confirmation.is_some() {
2344                    drop(s);
2345                    handle_confirmation_key(&state, &evt_tx, key.code);
2346                    continue;
2347                }
2348            }
2349
2350            // Overlay key handling takes priority — when an overlay is
2351            // open, Up/Down navigate, Enter submits, Esc cancels, and any
2352            // printable char is captured for the search bar (if any).
2353            // All other keys are swallowed so the composer buffer stays
2354            // frozen while the user is interacting with the overlay.
2355            {
2356                let s = state.lock();
2357                if s.overlay.is_some() {
2358                    drop(s);
2359                    if handle_overlay_key(&state, &evt_tx, key.code) {
2360                        continue;
2361                    }
2362                }
2363            }
2364
2365            // @-file-search dropdown — when the picker is open, intercept
2366            // navigation and accept keys. Regular chars fall through to
2367            // normal buffer insertion so the user can keep typing.
2368            {
2369                let s = state.lock();
2370                if s.file_search.is_some() {
2371                    drop(s);
2372                    if handle_file_search_key(&state, &evt_tx, key.code) {
2373                        continue;
2374                    }
2375                }
2376            }
2377
2378            match key.code {
2379                // Shift+Tab — cycle autonomy mode Default <-> Auto.
2380                KeyCode::BackTab => {
2381                    if let Some(h) = &mode_handle {
2382                        let new_mode = Mode::load(h).toggle();
2383                        h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
2384                        let label = new_mode.label();
2385                        let detail = if new_mode.is_auto() {
2386                            "autonomous — no questions, runs to completion"
2387                        } else {
2388                            "interactive — may ask questions"
2389                        };
2390                        let mut s = state.lock();
2391                        s.autonomy_mode = new_mode;
2392                        s.tip = Some(EphemeralTip {
2393                            text: format!("Mode: {label} — {detail}"),
2394                            born_tick: 0,
2395                            ttl_ticks: 240,
2396                            key: "mode_toggle",
2397                            ambient: false,
2398                        });
2399                    }
2400                    continue;
2401                }
2402                KeyCode::Enter => {
2403                    // Multiline mode: plain Enter inserts a newline.
2404                    // Shift+Enter (or Enter in non-multiline mode) sends.
2405                    let send = !state.lock().multiline_mode
2406                        || key
2407                            .modifiers
2408                            .contains(crossterm::event::KeyModifiers::SHIFT);
2409
2410                    if !send {
2411                        let mut s = state.lock();
2412                        let cursor = s.input_cursor;
2413                        s.input_buffer.insert(cursor, '\n');
2414                        s.input_cursor = cursor + 1;
2415                        continue;
2416                    }
2417
2418                    // Shell mode: submit the buffer as a bash command request.
2419                    let shell_cmd = state.lock().shell_mode;
2420                    if shell_cmd {
2421                        let submitted = {
2422                            let mut s = state.lock();
2423                            let buf = std::mem::take(&mut s.input_buffer);
2424                            s.input_cursor = 0;
2425                            s.shell_mode = false;
2426                            s.history_pos = None;
2427                            if !buf.is_empty() {
2428                                s.prompt_history.insert(0, buf.clone());
2429                                s.prompt_history.truncate(100);
2430                            }
2431                            buf
2432                        };
2433                        if !submitted.is_empty() {
2434                            let prompt = format!("Run this shell command: `{submitted}`");
2435                            let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
2436                        }
2437                        continue;
2438                    }
2439
2440                    let submitted = {
2441                        let mut s = state.lock();
2442                        let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2443                            let item = &s.slash_popup.items[s.slash_popup.selected];
2444                            format!("/{}", item.name)
2445                        } else {
2446                            std::mem::take(&mut s.input_buffer)
2447                        };
2448                        s.input_cursor = 0;
2449                        s.slash_popup = SlashPopup::default();
2450                        s.history_pos = None;
2451                        // Record non-empty, non-command prompts in history.
2452                        if !buf.is_empty() && !buf.starts_with('/') {
2453                            s.prompt_history.insert(0, buf.clone());
2454                            s.prompt_history.truncate(100);
2455                        }
2456                        buf
2457                    };
2458                    let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
2459                }
2460                KeyCode::Esc => {
2461                    // Esc ladder (grok-build-style):
2462                    // 1. Slash popup open → close popup
2463                    // 2. Input non-empty + 2nd Esc within 800ms → clear buffer
2464                    // 3. Input non-empty + 1st Esc → arm "press again to clear"
2465                    // 4. Empty input → cancel the run (with ~1s post-cancel
2466                    //    grace so mashing Esc doesn't fire repeated cancels)
2467                    let mut s = state.lock();
2468                    if s.shell_mode {
2469                        s.shell_mode = false;
2470                        s.input_buffer.clear();
2471                        s.input_cursor = 0;
2472                    } else if s.slash_popup.open {
2473                        s.slash_popup = SlashPopup::default();
2474                    } else if !s.input_buffer.is_empty() {
2475                        let now = std::time::Instant::now();
2476                        let is_double = s
2477                            .last_esc_at
2478                            .map(|t| now.duration_since(t).as_millis() < 800)
2479                            .unwrap_or(false);
2480                        if is_double {
2481                            s.input_buffer.clear();
2482                            s.input_cursor = 0;
2483                            s.last_esc_at = None;
2484                        } else {
2485                            s.last_esc_at = Some(now);
2486                            // Ephemeral hint so the user learns the
2487                            // double-Esc-to-clear gesture.
2488                            s.tip = Some(EphemeralTip {
2489                                text: "Press Esc again to clear input".to_string(),
2490                                born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
2491                                ttl_ticks: 120,
2492                                key: "esc_clear",
2493                                ambient: false,
2494                            });
2495                        }
2496                    } else {
2497                        let now = std::time::Instant::now();
2498                        let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
2499                        if in_grace {
2500                            // Swallow — already cancelling.
2501                        } else {
2502                            s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
2503                            s.last_esc_at = None;
2504                            drop(s);
2505                            let _ = evt_tx.send(InlineEvent::Cancel);
2506                        }
2507                    }
2508                }
2509                KeyCode::Tab => {
2510                    // Complete the selected slash command into the buffer
2511                    // (without submitting) so the user can type arguments.
2512                    let mut s = state.lock();
2513                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2514                        let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
2515                        s.input_buffer = format!("/{} ", name);
2516                        s.input_cursor = s.input_buffer.len();
2517                        refresh_input_popups(&mut s);
2518                    }
2519                }
2520                KeyCode::Backspace => {
2521                    let mut s = state.lock();
2522                    if s.input_cursor > 0 {
2523                        let cursor = s.input_cursor;
2524                        // Walk back one UTF-8 char (not necessarily one
2525                        // byte, but chars are 1+ bytes).
2526                        let prev = s
2527                            .input_buffer
2528                            .char_indices()
2529                            .take_while(|(i, _)| *i < cursor)
2530                            .last()
2531                            .map(|(i, _)| i)
2532                            .unwrap_or(0);
2533                        s.input_buffer.replace_range(prev..cursor, "");
2534                        s.input_cursor = prev;
2535                    }
2536                    refresh_input_popups(&mut s);
2537                }
2538                KeyCode::Delete => {
2539                    let mut s = state.lock();
2540                    if s.input_cursor < s.input_buffer.len() {
2541                        let cursor = s.input_cursor;
2542                        let next = s.input_buffer[cursor..]
2543                            .char_indices()
2544                            .nth(1)
2545                            .map(|(i, _)| cursor + i)
2546                            .unwrap_or(s.input_buffer.len());
2547                        s.input_buffer.replace_range(cursor..next, "");
2548                    }
2549                    refresh_input_popups(&mut s);
2550                }
2551                KeyCode::Left => {
2552                    let mut s = state.lock();
2553                    s.input_cursor = s.input_cursor.saturating_sub(1);
2554                }
2555                KeyCode::Right => {
2556                    let mut s = state.lock();
2557                    let len = s.input_buffer.len();
2558                    s.input_cursor = (s.input_cursor + 1).min(len);
2559                }
2560                KeyCode::Up => {
2561                    let mut s = state.lock();
2562                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2563                        let len = s.slash_popup.items.len();
2564                        s.slash_popup.selected = if s.slash_popup.selected == 0 {
2565                            len - 1
2566                        } else {
2567                            s.slash_popup.selected - 1
2568                        };
2569                    } else if s.queue_panel_open
2570                        && !s.queued_inputs.is_empty()
2571                        && s.input_buffer.is_empty()
2572                    {
2573                        s.queue_selected = if s.queue_selected == 0 {
2574                            s.queued_inputs.len() - 1
2575                        } else {
2576                            s.queue_selected - 1
2577                        };
2578                    } else if s.input_buffer.is_empty() && !s.prompt_history.is_empty() {
2579                        // History recall: fill the prompt with the previous entry.
2580                        let pos = s.history_pos.unwrap_or(0);
2581                        let next = (pos + 1).min(s.prompt_history.len() - 1);
2582                        s.history_pos = Some(next);
2583                        s.input_buffer = s.prompt_history[next].clone();
2584                        s.input_cursor = s.input_buffer.len();
2585                    } else {
2586                        drop(s);
2587                        let _ = evt_tx.send(InlineEvent::ScrollLineUp);
2588                    }
2589                }
2590                KeyCode::Down => {
2591                    let mut s = state.lock();
2592                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
2593                        let len = s.slash_popup.items.len();
2594                        s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
2595                            0
2596                        } else {
2597                            s.slash_popup.selected + 1
2598                        };
2599                    } else if s.queue_panel_open
2600                        && !s.queued_inputs.is_empty()
2601                        && s.input_buffer.is_empty()
2602                    {
2603                        s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
2604                            0
2605                        } else {
2606                            s.queue_selected + 1
2607                        };
2608                    } else {
2609                        drop(s);
2610                        let _ = evt_tx.send(InlineEvent::ScrollLineDown);
2611                    }
2612                }
2613                KeyCode::PageUp => {
2614                    let _ = evt_tx.send(InlineEvent::ScrollPageUp);
2615                }
2616                KeyCode::PageDown => {
2617                    let _ = evt_tx.send(InlineEvent::ScrollPageDown);
2618                }
2619                KeyCode::Char(ch) => {
2620                    let mut s = state.lock();
2621                    // @! hidden-file toggle: when the picker is open and '!'
2622                    // is typed immediately after '@', toggle hidden mode
2623                    // instead of inserting '!'.
2624                    if s.file_search.is_some()
2625                        && ch == '!'
2626                        && s.input_buffer[..s.input_cursor].ends_with('@')
2627                    {
2628                        let cwd = s.cwd.clone();
2629                        if let Some(fs) = s.file_search.as_mut() {
2630                            fs.toggle_hidden(&cwd);
2631                        }
2632                        continue;
2633                    }
2634                    if s.agent_hub_open && ch == 'q' {
2635                        s.agent_hub_open = false;
2636                    } else if s.vim_state.enabled() && !s.slash_popup.open {
2637                        // Route through the vim engine. Deref the guard so
2638                        // we can borrow multiple fields simultaneously.
2639                        let s = &mut *s;
2640                        let vkey =
2641                            crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
2642                        let mut editor = InputEditor {
2643                            buffer: &mut s.input_buffer,
2644                            cursor: &mut s.input_cursor,
2645                        };
2646                        let outcome = oxicode_vtui::vim::handle_key(
2647                            &mut s.vim_state,
2648                            &mut editor,
2649                            &mut s.vim_clipboard,
2650                            &vkey,
2651                        );
2652                        if outcome.handled {
2653                            refresh_input_popups(s);
2654                        } else {
2655                            let cursor = s.input_cursor;
2656                            s.input_buffer.insert(cursor, ch);
2657                            s.input_cursor = cursor + ch.len_utf8();
2658                            refresh_input_popups(s);
2659                        }
2660                    } else if s.input_buffer.is_empty() && !s.slash_popup.open {
2661                        // Shell mode: `!` on empty buffer enters bash mode.
2662                        if ch == '!' && !s.shell_mode {
2663                            s.shell_mode = true;
2664                            continue;
2665                        }
2666                        // Queue panel interactive mode takes priority when
2667                        // open and the buffer is empty. Keys that don't
2668                        // match fall through to scrollback nav below.
2669                        if s.queue_panel_open && !s.queued_inputs.is_empty() {
2670                            let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
2671                            match ch {
2672                                'x' | 'X' => {
2673                                    s.queued_inputs.remove(idx);
2674                                    if s.queue_selected >= s.queued_inputs.len()
2675                                        && !s.queued_inputs.is_empty()
2676                                    {
2677                                        s.queue_selected = s.queued_inputs.len() - 1;
2678                                    }
2679                                    continue;
2680                                }
2681                                'e' => {
2682                                    let entry = s.queued_inputs.remove(idx);
2683                                    s.input_buffer = entry;
2684                                    s.input_cursor = s.input_buffer.len();
2685                                    s.queue_panel_open = false;
2686                                    continue;
2687                                }
2688                                'J' => {
2689                                    if idx + 1 < s.queued_inputs.len() {
2690                                        s.queued_inputs.swap(idx, idx + 1);
2691                                        s.queue_selected = idx + 1;
2692                                    }
2693                                    continue;
2694                                }
2695                                'K' => {
2696                                    if idx > 0 {
2697                                        s.queued_inputs.swap(idx, idx - 1);
2698                                        s.queue_selected = idx - 1;
2699                                    }
2700                                    continue;
2701                                }
2702                                _ => {} // fall through to scrollback nav
2703                            }
2704                        }
2705                        // When the prompt is empty, intercept scrollback
2706                        // navigation keys (matching grok-build's scrollback-
2707                        // focus semantics). Any other char falls through to
2708                        // normal insertion so the user can start typing.
2709                        match ch {
2710                            '?' => {
2711                                s.overlay = Some(OverlayState {
2712                                    title: "Keyboard Shortcuts".into(),
2713                                    lines: cheatsheet_lines(),
2714                                    items: vec![],
2715                                    selected: 0,
2716                                    search: None,
2717                                    secure_input: None,
2718                                });
2719                            }
2720                            'e' => s.cycle_block_at_view(),
2721                            'E' => s.expand_all(),
2722                            'J' => s.jump_next_turn(),
2723                            'K' => s.jump_prev_turn(),
2724                            'n' if s.search.is_some() => s.search_next(),
2725                            'N' if s.search.is_some() => s.search_prev(),
2726                            _ => {
2727                                let cursor = s.input_cursor;
2728                                s.input_buffer.insert(cursor, ch);
2729                                s.input_cursor = cursor + ch.len_utf8();
2730                                refresh_input_popups(&mut s);
2731                            }
2732                        }
2733                    } else {
2734                        let cursor = s.input_cursor;
2735                        s.input_buffer.insert(cursor, ch);
2736                        s.input_cursor = cursor + ch.len_utf8();
2737                        refresh_input_popups(&mut s);
2738                    }
2739                    // plan_nudge: surface /compact when user mentions "plan".
2740                    if s.tip.is_none() && s.input_buffer.to_lowercase().contains("plan") {
2741                        s.show_tip(
2742                            "plan_nudge",
2743                            "Try /compact to summarize and plan ahead",
2744                            180,
2745                            true,
2746                        );
2747                    }
2748                }
2749                _ => {}
2750            }
2751        }
2752    })
2753}
2754
2755/// Resolve a keystroke against the active confirmation modal. `y`/Enter
2756/// confirms — dispatches the bound [`ConfirmationAction`]; `n`/`x`/Esc
2757/// cancels. Always consumes the key while a confirmation is open.
2758fn handle_confirmation_key(
2759    state: &Arc<parking_lot::Mutex<RenderState>>,
2760    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2761    code: KeyCode,
2762) {
2763    let mut s = state.lock();
2764    let Some(confirm) = s.confirmation.clone() else {
2765        return;
2766    };
2767    match code {
2768        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
2769            s.confirmation = None;
2770            drop(s);
2771            match confirm.action {
2772                ConfirmationAction::Quit => {
2773                    let _ = evt_tx.send(InlineEvent::Exit);
2774                }
2775                ConfirmationAction::ClearConversation => {
2776                    // Re-dispatch /clear with --yes so it flows through the
2777                    // normal command pipeline (where `session.reset()` is
2778                    // accessible). The sentinel arg bypasses the dialog.
2779                    let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
2780                }
2781                ConfirmationAction::RemoveProviderKey(name) => {
2782                    // Re-dispatch /providers remove <name> --yes so it flows
2783                    // through the normal command pipeline. The sentinel arg
2784                    // bypasses the confirm dialog.
2785                    let _ = evt_tx.send(InlineEvent::Submit(
2786                        format!("/providers remove {name} --yes").into(),
2787                    ));
2788                }
2789            }
2790        }
2791        KeyCode::Char('n')
2792        | KeyCode::Char('N')
2793        | KeyCode::Char('x')
2794        | KeyCode::Char('X')
2795        | KeyCode::Esc => {
2796            s.confirmation = None;
2797        }
2798        _ => {}
2799    }
2800}
2801
2802/// Handle a single keystroke while an overlay is open. Returns `true` if the
2803/// key was consumed (whether it changed state or not). Always returns `false`
2804/// when no overlay is open so the caller can fall through to the regular
2805/// input-thread key dispatch.
2806fn handle_overlay_key(
2807    state: &Arc<parking_lot::Mutex<RenderState>>,
2808    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2809    code: KeyCode,
2810) -> bool {
2811    use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
2812
2813    let mut s = state.lock();
2814    let Some(overlay) = s.overlay.as_mut() else {
2815        return false;
2816    };
2817    // Secure (masked) single-line prompt: takes precedence over list
2818    // navigation. Char / Backspace / Left / Right / Enter / Esc route
2819    if let Some(secure) = overlay.secure_input.as_mut() {
2820        match code {
2821            KeyCode::Backspace => {
2822                let (v, c) = backspace_secure_input(&secure.value, secure.cursor);
2823                secure.value = v;
2824                secure.cursor = c;
2825            }
2826            KeyCode::Left => {
2827                if secure.cursor > 0 {
2828                    let mut p = secure.cursor - 1;
2829                    while !secure.value.is_char_boundary(p) {
2830                        p -= 1;
2831                    }
2832                    secure.cursor = p;
2833                }
2834            }
2835            KeyCode::Right => {
2836                if secure.cursor < secure.value.len() {
2837                    let mut n = secure.cursor + 1;
2838                    while n < secure.value.len() && !secure.value.is_char_boundary(n) {
2839                        n += 1;
2840                    }
2841                    secure.cursor = n;
2842                }
2843            }
2844            KeyCode::Enter => {
2845                let submission = OverlaySubmission::SecureInput(secure.value.clone());
2846                drop(s);
2847                state.lock().overlay = None;
2848                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
2849            }
2850            KeyCode::Esc => {
2851                drop(s);
2852                state.lock().overlay = None;
2853                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2854            }
2855            KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
2856                let (v, n) = insert_char_into_secure_input(&secure.value, secure.cursor, ch);
2857                secure.value = v;
2858                secure.cursor = n;
2859            }
2860            _ => {} // ignore other keys while the secure prompt is open
2861        }
2862        return true;
2863    }
2864
2865    match code {
2866        KeyCode::Esc => {
2867            // Cancel the overlay and notify the harness.
2868            drop(s);
2869            state.lock().overlay = None;
2870            let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2871        }
2872        KeyCode::Enter => {
2873            // Submit the currently selected item. If no item is selected
2874            // (empty list), we still close the overlay with a cancel.
2875            let submission = if let Some(item) = overlay.items.get(overlay.selected) {
2876                match item.selection.clone() {
2877                    Some(sel) => sel,
2878                    None => {
2879                        // Read-only / informational item (no InlineListSelection,
2880                        // e.g. /tools, /mcp, the /settings Model row): Enter is
2881                        // a no-op — keep the overlay open so the user can keep
2882                        // browsing (Esc closes). Avoids polluting the prompt
2883                        // with a synthetic "/overlay:N" command.
2884                        return true;
2885                    }
2886                }
2887            } else {
2888                drop(s);
2889                state.lock().overlay = None;
2890                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
2891                return true;
2892            };
2893            let title = overlay.title.clone();
2894            let selected = overlay.selected;
2895            drop(s);
2896            state.lock().overlay = None;
2897            tracing::debug!(
2898                overlay = %title,
2899                selected,
2900                "overlay submitted"
2901            );
2902            let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
2903                OverlaySubmission::Selection(submission),
2904            )));
2905        }
2906        KeyCode::Up => {
2907            let len = overlay_filtered_indices(overlay).len();
2908            if len == 0 {
2909                return true;
2910            }
2911            let pos = overlay_filtered_indices(overlay)
2912                .iter()
2913                .position(|&i| i == overlay.selected)
2914                .unwrap_or(0);
2915            let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
2916            overlay.selected = overlay_filtered_indices(overlay)[new_pos];
2917        }
2918        KeyCode::Down => {
2919            let filtered = overlay_filtered_indices(overlay);
2920            let len = filtered.len();
2921            if len == 0 {
2922                return true;
2923            }
2924            let pos = filtered
2925                .iter()
2926                .position(|&i| i == overlay.selected)
2927                .unwrap_or(0);
2928            let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
2929            overlay.selected = filtered[new_pos];
2930        }
2931        KeyCode::Backspace => {
2932            if let Some(search) = overlay.search.as_mut() {
2933                search.value.pop();
2934                overlay.selected = 0;
2935            }
2936        }
2937        KeyCode::Char(ch) => {
2938            if let Some(search) = overlay.search.as_mut() {
2939                search.value.push(ch);
2940                overlay.selected = 0;
2941            }
2942        }
2943        _ => {
2944            // Swallow all other keys while an overlay is open.
2945        }
2946    }
2947    true
2948}
2949
2950/// Return the indices of `overlay.items` that match the current search filter.
2951/// When no search is configured (or the search field is empty), returns every
2952/// index. Used by both the renderer and the input thread so they agree on
2953/// which item is "selected" after navigation or filter changes.
2954fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
2955    let needle = overlay
2956        .search
2957        .as_ref()
2958        .map(|s| s.value.to_lowercase())
2959        .unwrap_or_default();
2960    if needle.is_empty() {
2961        return (0..overlay.items.len()).collect();
2962    }
2963    overlay
2964        .items
2965        .iter()
2966        .enumerate()
2967        .filter_map(|(idx, item)| {
2968            let title_hit = item.title.to_lowercase().contains(&needle);
2969            let sv_hit = item
2970                .search_value
2971                .as_deref()
2972                .map(|v| v.to_lowercase().contains(&needle))
2973                .unwrap_or(false);
2974            if title_hit || sv_hit { Some(idx) } else { None }
2975        })
2976        .collect()
2977}
2978
2979/// Handle a single keystroke while the @-file-search dropdown is open.
2980/// Returns `true` if the key was consumed. Up/Down navigate, Tab/Enter
2981/// accept the selection (inserting `@path ` without submitting), Esc
2982/// cancels. Regular chars fall through (`false`) so they enter the buffer
2983/// and trigger `refresh_file_search` to re-filter.
2984fn handle_file_search_key(
2985    state: &Arc<parking_lot::Mutex<RenderState>>,
2986    _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
2987    code: KeyCode,
2988) -> bool {
2989    match code {
2990        KeyCode::Up => {
2991            let mut s = state.lock();
2992            if let Some(fs) = s.file_search.as_mut() {
2993                fs.up();
2994                true
2995            } else {
2996                false
2997            }
2998        }
2999        KeyCode::Down => {
3000            let mut s = state.lock();
3001            if let Some(fs) = s.file_search.as_mut() {
3002                fs.down();
3003                true
3004            } else {
3005                false
3006            }
3007        }
3008        KeyCode::Tab | KeyCode::Enter => {
3009            let mut s = state.lock();
3010            if s.file_search
3011                .as_ref()
3012                .and_then(|fs| fs.selected_result())
3013                .is_some()
3014            {
3015                accept_file_search(&mut s, false);
3016                true
3017            } else {
3018                // No results: close the picker, let Enter fall through.
3019                s.file_search = None;
3020                false
3021            }
3022        }
3023        KeyCode::Esc => {
3024            let mut s = state.lock();
3025            s.file_search = None;
3026            true
3027        }
3028        _ => false,
3029    }
3030}
3031
3032// ─────────────────────────────────────────────────────────────────────────
3033// Agent worker thread — owns the agent run loop, forwards events to the
3034// session bus, and accepts new prompts from a tokio channel.
3035// ─────────────────────────────────────────────────────────────────────────
3036
3037fn spawn_agent_worker(
3038    session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
3039) -> tokio::sync::mpsc::UnboundedSender<String> {
3040    let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
3041
3042    std::thread::spawn(move || {
3043        let runtime = match tokio::runtime::Builder::new_current_thread()
3044            .enable_all()
3045            .build()
3046        {
3047            Ok(rt) => rt,
3048            Err(err) => {
3049                tracing::error!(?err, "failed to build agent worker runtime");
3050                return;
3051            }
3052        };
3053
3054        runtime.block_on(async move {
3055            let local = tokio::task::LocalSet::new();
3056            local
3057                .run_until(async move {
3058                    while let Some(prompt) = prompt_rx.recv().await {
3059                        run_one_prompt(&session_swapper.current(), prompt).await;
3060                    }
3061                })
3062                .await;
3063        });
3064    });
3065
3066    prompt_tx
3067}
3068
3069async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
3070    let session_for_forward = session.clone();
3071    let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
3072
3073    // Forwarder thread — runs `forward_event_to_extensions` on each event
3074    // so the AgentSession's subscribers (and therefore the main loop)
3075    // observe it.
3076    let forwarder = std::thread::spawn(move || {
3077        while let Ok(event) = event_rx.recv() {
3078            session_for_forward.forward_event_to_extensions(&event);
3079        }
3080    });
3081
3082    // Reset the stop flag (a previous Ctrl+C may have left it set) and
3083    // mark streaming so the Ctrl+C policy can distinguish "interrupt"
3084    // from "quit". The guard clears the flag on any exit path.
3085    use std::sync::atomic::Ordering;
3086    session.reset_should_stop();
3087    let streaming = session.streaming_flag();
3088    streaming.store(true, Ordering::SeqCst);
3089    let _stream_guard = StreamingGuard(&streaming);
3090
3091    let agent = session.agent_ref();
3092    let local = tokio::task::LocalSet::new();
3093    let result = local
3094        .run_until(agent.run_with_channel(prompt, event_tx))
3095        .await;
3096
3097    // Wait for the forwarder to drain the channel (sender dropped when
3098    // `run_with_channel` returns).
3099    let _ = forwarder.join();
3100    if let Err(err) = result {
3101        tracing::warn!(?err, "agent run failed");
3102    }
3103}
3104
3105// ─────────────────────────────────────────────────────────────────────────
3106// Header / AgentSession construction
3107// ─────────────────────────────────────────────────────────────────────────
3108
3109// ─────────────────────────────────────────────────────────────────────────
3110// Header / AgentSession construction
3111// ─────────────────────────────────────────────────────────────────────────
3112
3113fn build_header_context(
3114    app: &App,
3115    cwd: &std::path::Path,
3116    git_branch: Option<&str>,
3117) -> InlineHeaderContext {
3118    let workspace_name = cwd
3119        .file_name()
3120        .map(|n| n.to_string_lossy().into_owned())
3121        .unwrap_or_else(|| "oxicode".to_string());
3122    let model_id = app.model_id();
3123    let provider = model_id
3124        .split_once('/')
3125        .map(|(p, _)| p.to_string())
3126        .unwrap_or_else(|| "Provider".to_string());
3127    let branch = git_branch.unwrap_or("\u{2014}").to_string();
3128    let mut ctx = InlineHeaderContext::default();
3129    ctx.app_name = "oxicode".to_string();
3130    ctx.provider = provider;
3131    ctx.model = model_id.clone();
3132    ctx.git = format!("git: {workspace_name}@{branch}");
3133    ctx.tools = "Tools: ready".to_string();
3134    ctx.search_tools = Some(InlineHeaderStatusBadge {
3135        text: workspace_name,
3136        tone: InlineHeaderStatusTone::Ready,
3137    });
3138    ctx.persistent_memory = Some(InlineHeaderStatusBadge {
3139        text: branch,
3140        tone: InlineHeaderStatusTone::Ready,
3141    });
3142    ctx.editor_context = Some(model_id);
3143    ctx
3144}
3145
3146/// Construct an `AgentSession` for the TUI using the runtime helpers from
3147/// `agent_session_runtime`. Mirrors the wiring in the legacy `tui/` harness.
3148async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
3149    use crate::app::agent_session_runtime::{
3150        CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
3151        create_agent_session_from_services, create_agent_session_services,
3152    };
3153    use crate::store::session::SessionManager;
3154
3155    let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
3156    let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
3157    let services = create_agent_session_services(
3158        CreateAgentSessionServicesOptions::new(cwd.clone()),
3159        Some(hook_runner),
3160    )?;
3161    let services = Arc::new(services);
3162
3163    let model_id = app.model_id();
3164    let tools = app.agent_tools();
3165
3166    let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
3167
3168    let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
3169        services,
3170        session_manager,
3171        model_id: if model_id.is_empty() {
3172            None
3173        } else {
3174            Some(model_id)
3175        },
3176        thinking_level: None,
3177        scoped_models: Vec::new(),
3178        tool_registry: Some(tools),
3179        // TUI runtime: share the App's session state so /steer, /follow_up,
3180        // and Ctrl+C continue to take effect across the session.
3181        session_state: Some(app.session_state().clone()),
3182    })
3183    .await?;
3184
3185    if let Some(msg) = result.model_fallback_message {
3186        tracing::warn!(message = %msg, "agent session model fallback");
3187    }
3188    Ok(result.session)
3189}
3190
3191// ─────────────────────────────────────────────────────────────────────────
3192// Rendering
3193// ─────────────────────────────────────────────────────────────────────────
3194
3195/// Lines for the keyboard shortcuts cheatsheet overlay.
3196fn cheatsheet_lines() -> Vec<String> {
3197    vec![
3198        "".into(),
3199        "  Navigation".into(),
3200        "  j / ↓        Scroll down".into(),
3201        "  k / ↑        Scroll up".into(),
3202        "  J (Shift+j)  Next turn".into(),
3203        "  K (Shift+k)  Previous turn".into(),
3204        "  PgDn / PgUp  Page scroll".into(),
3205        "  g / G        Top / bottom".into(),
3206        "".into(),
3207        "  Blocks".into(),
3208        "  e            Cycle block (collapse/truncate/expand)".into(),
3209        "  E            Expand all blocks".into(),
3210        "  Ctrl+E       Collapse all blocks".into(),
3211        "".into(),
3212        "  Search".into(),
3213        "  /find <q>    Search transcript".into(),
3214        "  n / N        Next / previous match".into(),
3215        "".into(),
3216        "  Commands".into(),
3217        "  /theme       Cycle color theme".into(),
3218        "  /model       Pick a model".into(),
3219        "  /vim         Toggle vim mode".into(),
3220        "  /compact     Compact context".into(),
3221        "  /clear       Clear conversation".into(),
3222        "  Ctrl+C       Cancel run (then y to quit)".into(),
3223        "  Ctrl+Enter   Send now (abort + submit)".into(),
3224        "  Ctrl+M       Toggle multiline input".into(),
3225        "  Shift+Tab    Toggle Auto mode (no questions, runs to end)".into(),
3226        "  Ctrl+;       Toggle queue panel".into(),
3227        "".into(),
3228        "  Special Input".into(),
3229        "  @           File picker (fuzzy search)".into(),
3230        "  @!          Toggle hidden files in picker".into(),
3231        "  !           Shell mode (bash command)".into(),
3232    ]
3233}
3234
3235/// Build the command palette overlay — a searchable list of all slash
3236/// commands plus quick actions. Triggered by Ctrl+P.
3237fn build_command_palette() -> OverlayState {
3238    use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
3239
3240    let catalog = SlashRegistry::builtin_commands();
3241    let mut items: Vec<InlineListItem> = catalog
3242        .iter()
3243        .map(|(name, desc, aliases)| {
3244            let title = if aliases.is_empty() {
3245                format!("/{name}")
3246            } else {
3247                format!(
3248                    "/{name} ({})",
3249                    aliases
3250                        .iter()
3251                        .map(|a| format!("/{a}"))
3252                        .collect::<Vec<_>>()
3253                        .join(", ")
3254                )
3255            };
3256            InlineListItem {
3257                title,
3258                subtitle: Some(desc.to_string()),
3259                badge: None,
3260                indent: 0,
3261                selection: Some(InlineListSelection::SlashCommand(name.to_string())),
3262                search_value: Some(format!("{name} {desc}")),
3263            }
3264        })
3265        .collect();
3266    items.sort_by(|a, b| a.title.cmp(&b.title));
3267
3268    OverlayState {
3269        title: "Command Palette".into(),
3270        lines: vec!["Type to filter, Enter to select".into()],
3271        items: items
3272            .into_iter()
3273            .map(|item| OverlayListItem {
3274                title: item.title,
3275                subtitle: item.subtitle,
3276                badge: item.badge,
3277                indent: item.indent,
3278                search_value: item.search_value,
3279                selection: item.selection,
3280            })
3281            .collect(),
3282        selected: 0,
3283        search: Some(OverlaySearchState {
3284            label: "search".into(),
3285            placeholder: Some("filter commands\u{2026}".into()),
3286            value: String::new(),
3287        }),
3288        secure_input: None,
3289    }
3290}
3291
3292/// Global frame tick counter for animations (incremented per render).
3293static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3294/// Tracks whether the terminal title currently shows a running state.
3295static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
3296/// Braille spinner frames for the tab title.
3297const TITLE_SPINNER: &[&str] = &[
3298    "\u{2807}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{2827}",
3299];
3300
3301/// Wave brightness for accent rail animation: sin²(tick·speed + row/rows·2π).
3302/// Returns [0.0, 1.0] — 1.0 = full color, 0.0 = dimmed toward background.
3303fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f64) -> f64 {
3304    let phase =
3305        (tick as f64 * speed) + (row as f64 / wave_rows.max(1) as f64) * std::f64::consts::TAU;
3306    let s = phase.sin();
3307    s * s
3308}
3309
3310/// Linear-interpolate between two RGB colors. `ratio` 0 = base, 1 = target.
3311fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
3312    match (base, target) {
3313        (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
3314            let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
3315            let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
3316            let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
3317            Color::Rgb(r, g, b)
3318        }
3319        _ => base,
3320    }
3321}
3322
3323/// Accent rail color for a transcript line kind.
3324fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
3325    match kind {
3326        InlineMessageKind::User => color_from_anstyle(styles.primary.get_fg_color()),
3327        InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
3328        InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
3329        InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
3330        InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
3331        InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
3332        InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
3333        InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
3334    }
3335}
3336
3337/// Compose one frame using the agent view layout (grok-build-style):
3338/// StatusBar (top) → Scrollback (dominant) → Prompt → ShortcutsBar (bottom).
3339/// Chrome geometry and the status/shortcuts bars are rendered by
3340/// [`render_chrome`](crate::tui_vt::frame_layout::render_chrome); the transcript and composer are placed
3341/// into the returned layout rects.
3342fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
3343    let area = frame.area();
3344    // Paint the theme background across the whole frame first. Without this
3345    // every span renders against the host terminal's transparent default bg,
3346    // so fg-only text can read as invisible when it clashes with that default
3347    // — the user only saw it after drag-selecting (which inverts colors).
3348    let bg = active_styles().background;
3349    frame
3350        .buffer_mut()
3351        .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
3352    let layout = super::frame_layout::render_chrome(frame, area, state);
3353    let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3354    // Update terminal tab title: spinner while running, plain when idle.
3355    {
3356        let running = state.reasoning_stage.is_some();
3357        let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
3358        if running || was_running {
3359            let title = if running {
3360                let spin = TITLE_SPINNER[(tick as usize) % TITLE_SPINNER.len()];
3361                let model = state
3362                    .header_context
3363                    .editor_context
3364                    .as_deref()
3365                    .unwrap_or("oxicode");
3366                format!("{spin} oxicode \u{2014} {model}")
3367            } else {
3368                "oxicode".to_string()
3369            };
3370            use std::io::Write;
3371            let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
3372            let _ = std::io::stderr().flush();
3373        }
3374    }
3375    render_transcript(frame, layout.scrollback, state, tick);
3376    if !state.queued_inputs.is_empty() {
3377        render_queue_pane(frame, layout.scrollback, state);
3378    }
3379    if !state.todo_items.is_empty() {
3380        render_todo_pane(frame, layout.scrollback, &state.todo_items);
3381    }
3382    if !state.follow_ups.is_empty() {
3383        render_follow_ups(frame, layout.prompt, &state.follow_ups);
3384    }
3385    if let Some(stage) = &state.reasoning_stage {
3386        render_reasoning_indicator(frame, layout.prompt, stage);
3387    }
3388    render_composer(frame, layout.prompt, state);
3389    // Ephemeral tip banner above the composer (auto-dismissed by tick TTL).
3390    let occluded = state.overlay.is_some() || state.confirmation.is_some();
3391    if let Some(tip) = &state.tip
3392        && tip_is_visible(tip, tick)
3393        && !(tip.ambient && occluded)
3394    {
3395        render_tip(frame, layout.prompt, &tip.text);
3396    }
3397    if state.slash_popup.open {
3398        render_slash_popup(frame, layout.prompt, state);
3399    }
3400    if state.file_search.is_some() {
3401        render_file_search_dropdown(frame, layout.prompt, state);
3402    }
3403    if state.agent_hub_open {
3404        render_agent_hub(frame, area, state);
3405    }
3406    if let Some(overlay) = &state.overlay {
3407        render_overlay(frame, area, overlay);
3408    }
3409    if let Some(confirm) = &state.confirmation {
3410        render_confirmation(frame, area, confirm);
3411    }
3412}
3413
3414/// Render the y/n/x confirmation modal centered on top of everything else.
3415fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
3416    let styles = active_styles();
3417    let accent = color_from_anstyle(styles.error.get_fg_color());
3418    let inner_w = confirm
3419        .title
3420        .chars()
3421        .count()
3422        .max(confirm.message.chars().count())
3423        .max(36) as u16;
3424    let width = inner_w + 4;
3425    let height = 5;
3426    let x = area.x + area.width.saturating_sub(width) / 2;
3427    let y = area.y + area.height.saturating_sub(height) / 2;
3428    let popup_area = Rect {
3429        x,
3430        y,
3431        width,
3432        height,
3433    };
3434    let block = Block::default()
3435        .borders(Borders::ALL)
3436        .border_type(BorderType::Rounded)
3437        .title(Span::styled(
3438            format!(" {} ", confirm.title),
3439            Style::default().fg(accent).bold(),
3440        ))
3441        .border_style(Style::default().fg(accent));
3442    let msg = Line::styled(
3443        confirm.message.clone(),
3444        Style::default().fg(color_from_anstyle(Some(styles.foreground))),
3445    );
3446    frame.render_widget(
3447        Paragraph::new(vec![Line::default(), msg]).block(block),
3448        popup_area,
3449    );
3450}
3451
3452/// Render the Agent Hub overlay — a centered panel listing every registered
3453/// agent (kind, name, status). Populated from `RenderState::hub_entries`,
3454/// snapshotted when `/agents` fired. `q` (input thread Char arm) closes it.
3455fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
3456    let rows = state.hub_entries.len() as u16;
3457    let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
3458    let width = area.width.clamp(30, 80);
3459    let rect = Rect {
3460        x: area.x + (area.width.saturating_sub(width)) / 2,
3461        y: area.y + (area.height.saturating_sub(height)) / 2,
3462        width,
3463        height,
3464    };
3465    frame.render_widget(Clear, rect);
3466
3467    let title = Line::from(Span::styled(
3468        " Agent Hub ",
3469        Style::default().add_modifier(Modifier::BOLD),
3470    ));
3471    let block = Block::default().borders(Borders::ALL).title(title);
3472
3473    let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
3474        vec![ListItem::new(Line::from(Span::raw(
3475            "No agents registered.",
3476        )))]
3477    } else {
3478        state
3479            .hub_entries
3480            .iter()
3481            .map(|(id, e)| {
3482                ListItem::new(Line::from(vec![
3483                    Span::raw(format!("{:?} ", e.kind)),
3484                    Span::raw(e.display_name.clone()),
3485                    Span::raw(format!("  — {:?} ({})", e.status, id)),
3486                ]))
3487            })
3488            .collect()
3489    };
3490    frame.render_widget(List::new(items).block(block), rect);
3491}
3492
3493/// Render an overlay (Modal / List) as a centered, bordered panel. Modals
3494/// show only their title + descriptive lines; lists also render a search bar
3495/// (when configured) and a scrollable item list with the selected item
3496/// marked by ▸.
3497fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
3498    let styles = active_styles();
3499    // Secure-input overlays draw a compact frame: title + lines + a single
3500    // masked input box. List overlays take the longer path below.
3501    if let Some(secure) = &overlay.secure_input {
3502        // Reserve the line just below `overlay.lines` for the input box.
3503        let lines_count = overlay.lines.len();
3504        let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); // input row + borders
3505        let height = desired_h.min(area.height.saturating_sub(2));
3506        let width = area.width.clamp(30, 80);
3507        let rect = Rect {
3508            x: area.x + (area.width.saturating_sub(width)) / 2,
3509            y: area.y + (area.height.saturating_sub(height)) / 2,
3510            width,
3511            height,
3512        };
3513        frame.render_widget(Clear, rect);
3514
3515        let title = Line::from(Span::styled(
3516            format!(" {} ", overlay.title),
3517            Style::default()
3518                .fg(color_from_anstyle(styles.primary.get_fg_color()))
3519                .add_modifier(Modifier::BOLD),
3520        ));
3521        let block = Block::default()
3522            .borders(Borders::ALL)
3523            .border_type(BorderType::Rounded)
3524            .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3525            .title(title);
3526        let inner = block.inner(rect);
3527        frame.render_widget(&block, rect);
3528
3529        let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3530        let fg = color_from_anstyle(Some(styles.foreground));
3531
3532        let mut row = inner.top();
3533        for line_text in &overlay.lines {
3534            let row_area = Rect {
3535                x: inner.left(),
3536                y: row,
3537                width: inner.width,
3538                height: 1,
3539            };
3540            let line = Line::from(Span::styled(
3541                line_text.clone(),
3542                Style::default().fg(secondary),
3543            ));
3544            frame.render_widget(Paragraph::new(line), row_area);
3545            row = row.saturating_add(1);
3546        }
3547
3548        // Secure input box — mask the value when `mask_input` is on; fall back
3549        // to the configured placeholder when the buffer is empty.
3550        let label = &secure.config.label;
3551        let display: String = if secure.value.is_empty() {
3552            secure
3553                .config
3554                .placeholder
3555                .clone()
3556                .unwrap_or_else(|| "(empty)".to_string())
3557        } else if secure.config.mask_input {
3558            // One asterisk per character so the length is visible without
3559            // leaking the value. The render path must never reveal
3560            // `secure.value` when `mask_input` is on.
3561            "*".repeat(secure.value.chars().count())
3562        } else {
3563            secure.value.clone()
3564        };
3565        let row_area = Rect {
3566            x: inner.left(),
3567            y: row,
3568            width: inner.width,
3569            height: 1,
3570        };
3571        let line = Line::from(vec![
3572            Span::styled(format!("{label}: "), Style::default().fg(secondary)),
3573            Span::styled(display, Style::default().fg(fg)),
3574        ]);
3575        frame.render_widget(Paragraph::new(line), row_area);
3576        return;
3577    }
3578    let visible_max = (area.height as usize).saturating_sub(6).max(3);
3579
3580    // Filter items by the search value when search is enabled.
3581    let filtered: Vec<usize> = match &overlay.search {
3582        Some(search) if !search.value.is_empty() => {
3583            let needle = search.value.to_lowercase();
3584            overlay
3585                .items
3586                .iter()
3587                .enumerate()
3588                .filter_map(|(idx, item)| {
3589                    let title_match = item.title.to_lowercase().contains(&needle);
3590                    let sv_match = item
3591                        .search_value
3592                        .as_deref()
3593                        .map(|v| v.to_lowercase().contains(&needle))
3594                        .unwrap_or(false);
3595                    if title_match || sv_match {
3596                        Some(idx)
3597                    } else {
3598                        None
3599                    }
3600                })
3601                .collect()
3602        }
3603        _ => (0..overlay.items.len()).collect(),
3604    };
3605
3606    let has_search = overlay.search.is_some();
3607    let lines_count = overlay.lines.len();
3608    let items_count = filtered.len().min(visible_max);
3609    let height_inner = (lines_count + items_count + if has_search { 1 } else { 0 }) as u16;
3610    let desired_h = height_inner.saturating_add(2); // borders
3611    let height = desired_h.min(area.height.saturating_sub(2));
3612    let width = area.width.clamp(30, 80);
3613    let rect = Rect {
3614        x: area.x + (area.width.saturating_sub(width)) / 2,
3615        y: area.y + (area.height.saturating_sub(height)) / 2,
3616        width,
3617        height,
3618    };
3619    frame.render_widget(Clear, rect);
3620
3621    let title = Line::from(Span::styled(
3622        format!(" {} ", overlay.title),
3623        Style::default()
3624            .fg(color_from_anstyle(styles.primary.get_fg_color()))
3625            .add_modifier(Modifier::BOLD),
3626    ));
3627    let block = Block::default()
3628        .borders(Borders::ALL)
3629        .border_type(BorderType::Rounded)
3630        .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
3631        .title(title);
3632    let inner = block.inner(rect);
3633    frame.render_widget(&block, rect);
3634
3635    let primary = color_from_anstyle(styles.primary.get_fg_color());
3636    let fg = color_from_anstyle(Some(styles.foreground));
3637    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
3638
3639    // Compute where the selected item is in the filtered list.
3640    let selected_filtered_pos = filtered
3641        .iter()
3642        .position(|&idx| idx == overlay.selected)
3643        .unwrap_or(0);
3644
3645    let mut row = inner.top();
3646    // Search bar (if present).
3647    if let Some(search) = &overlay.search {
3648        let prompt = format!("{}: {}", search.label, search.value);
3649        let line = Line::from(vec![
3650            Span::styled(
3651                format!("{}: ", search.label),
3652                Style::default().fg(secondary),
3653            ),
3654            Span::styled(
3655                if search.value.is_empty() {
3656                    search
3657                        .placeholder
3658                        .clone()
3659                        .unwrap_or_else(|| "type to filter\u{2026}".to_string())
3660                } else {
3661                    search.value.clone()
3662                },
3663                if search.value.is_empty() {
3664                    Style::default().fg(secondary).add_modifier(Modifier::DIM)
3665                } else {
3666                    Style::default().fg(fg)
3667                },
3668            ),
3669        ]);
3670        let _ = prompt; // suppress unused warning
3671        let row_area = Rect {
3672            x: inner.left(),
3673            y: row,
3674            width: inner.width,
3675            height: 1,
3676        };
3677        frame.render_widget(Paragraph::new(line), row_area);
3678        row = row.saturating_add(1);
3679    }
3680
3681    // Descriptive lines.
3682    for line_text in &overlay.lines {
3683        let row_area = Rect {
3684            x: inner.left(),
3685            y: row,
3686            width: inner.width,
3687            height: 1,
3688        };
3689        let line = Line::from(Span::styled(
3690            line_text.clone(),
3691            Style::default().fg(secondary),
3692        ));
3693        frame.render_widget(Paragraph::new(line), row_area);
3694        row = row.saturating_add(1);
3695    }
3696
3697    // Items.
3698    if filtered.is_empty() {
3699        let row_area = Rect {
3700            x: inner.left(),
3701            y: row,
3702            width: inner.width,
3703            height: 1,
3704        };
3705        let empty_text = if overlay.search.is_some() {
3706            "  (no matches)"
3707        } else {
3708            "  (no items)"
3709        };
3710        frame.render_widget(
3711            Paragraph::new(Line::from(Span::styled(
3712                empty_text,
3713                Style::default().fg(secondary).add_modifier(Modifier::DIM),
3714            ))),
3715            row_area,
3716        );
3717    } else {
3718        for (display_idx, &item_idx) in filtered.iter().take(visible_max).enumerate() {
3719            let item = &overlay.items[item_idx];
3720            let is_selected = display_idx == selected_filtered_pos;
3721            let marker = if is_selected { "\u{25b8} " } else { "  " };
3722            let indent = "  ".repeat(item.indent as usize);
3723            let item_style = if is_selected {
3724                Style::default().fg(primary).add_modifier(Modifier::BOLD)
3725            } else {
3726                Style::default().fg(fg)
3727            };
3728            let mut spans = vec![
3729                Span::styled(marker, item_style),
3730                Span::styled(indent, item_style),
3731                Span::styled(item.title.clone(), item_style),
3732            ];
3733            if let Some(badge) = &item.badge {
3734                spans.push(Span::raw("  "));
3735                spans.push(Span::styled(
3736                    badge.clone(),
3737                    Style::default().fg(secondary).add_modifier(Modifier::DIM),
3738                ));
3739            }
3740            if let Some(subtitle) = &item.subtitle {
3741                spans.push(Span::raw("  "));
3742                spans.push(Span::styled(
3743                    subtitle.clone(),
3744                    Style::default().fg(secondary),
3745                ));
3746            }
3747            let line = Line::from(spans);
3748            let row_area = Rect {
3749                x: inner.left(),
3750                y: row,
3751                width: inner.width,
3752                height: 1,
3753            };
3754            frame.render_widget(Paragraph::new(line), row_area);
3755            row = row.saturating_add(1);
3756        }
3757    }
3758}
3759
3760fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState, tick: u64) {
3761    if state.transcript.is_empty() {
3762        render_welcome(frame, area);
3763        return;
3764    }
3765    let styles = active_styles();
3766    let bg_color = color_from_anstyle(Some(styles.background));
3767
3768    // Split area: [1-col accent rail | content | 1-col scrollbar].
3769    let accent_w: u16 = 1;
3770    let scrollbar_w: u16 = 1;
3771    let content_area = Rect {
3772        x: area.x + accent_w,
3773        y: area.y,
3774        width: area.width.saturating_sub(accent_w + scrollbar_w),
3775        height: area.height,
3776    };
3777
3778    // Build the visible-line list, respecting block folding. Track the kind
3779    // alongside each line so we can paint the accent rail in the role color.
3780    let search_set: std::collections::HashSet<usize> = state
3781        .search
3782        .as_ref()
3783        .map(|s| s.matches.iter().copied().collect())
3784        .unwrap_or_default();
3785    let current_match = state
3786        .search
3787        .as_ref()
3788        .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
3789
3790    let mut display: Vec<(usize, InlineMessageKind, Line<'_>)> =
3791        Vec::with_capacity(state.transcript.len());
3792    // Group consecutive lines into blocks, then render each block according
3793    // to its display mode (Collapsed / Truncated / Expanded). Absent
3794    // overrides fall back to Truncated — the grok-build default that keeps
3795    // long finished blocks scannable (head + ellipsis gap + tail).
3796    const TRUNC_TAIL: usize = 3;
3797    let dim_style = Style::default()
3798        .fg(color_from_anstyle(styles.secondary.get_fg_color()))
3799        .add_modifier(Modifier::DIM);
3800
3801    let mut blocks: Vec<(usize, Vec<(usize, &TranscriptLine)>)> = Vec::new();
3802    for (idx, tl) in state.transcript.iter().enumerate() {
3803        if blocks.last().is_some_and(|(id, _)| *id == tl.block_id) {
3804            blocks.last_mut().unwrap().1.push((idx, tl));
3805        } else {
3806            blocks.push((tl.block_id, vec![(idx, tl)]));
3807        }
3808    }
3809
3810    for (block_id, lines) in &blocks {
3811        let mode = state.block_mode(*block_id);
3812        let len = lines.len();
3813        match mode {
3814            BlockDisplayMode::Collapsed => {
3815                let &(idx, tl) = &lines[0];
3816                let is_match = search_set.contains(&idx);
3817                let line =
3818                    transcript_line_marked(tl, &styles, true, is_match, current_match == Some(idx));
3819                display.push((idx, tl.kind, line));
3820            }
3821            BlockDisplayMode::Expanded => {
3822                for &(idx, tl) in lines {
3823                    let is_match = search_set.contains(&idx);
3824                    let line = transcript_line_marked(
3825                        tl,
3826                        &styles,
3827                        false,
3828                        is_match,
3829                        current_match == Some(idx),
3830                    );
3831                    display.push((idx, tl.kind, line));
3832                }
3833            }
3834            BlockDisplayMode::Truncated => {
3835                if len <= TRUNC_TAIL + 1 {
3836                    // Short enough — show every line at full weight.
3837                    for &(idx, tl) in lines {
3838                        let is_match = search_set.contains(&idx);
3839                        let line = transcript_line_marked(
3840                            tl,
3841                            &styles,
3842                            false,
3843                            is_match,
3844                            current_match == Some(idx),
3845                        );
3846                        display.push((idx, tl.kind, line));
3847                    }
3848                } else {
3849                    // Head (first line, full weight).
3850                    let &(hidx, htl) = &lines[0];
3851                    let is_match = search_set.contains(&hidx);
3852                    let line = transcript_line_marked(
3853                        htl,
3854                        &styles,
3855                        false,
3856                        is_match,
3857                        current_match == Some(hidx),
3858                    );
3859                    display.push((hidx, htl.kind, line));
3860                    // Ellipsis gap summarising the hidden middle.
3861                    let hidden = len - 1 - TRUNC_TAIL;
3862                    let gap = Line::styled(format!("  \u{2026} +{hidden} lines"), dim_style);
3863                    display.push((hidx, htl.kind, gap));
3864                    // Tail (last N lines, in order).
3865                    for &(idx, tl) in lines.iter().rev().take(TRUNC_TAIL).rev() {
3866                        let is_match = search_set.contains(&idx);
3867                        let line = transcript_line_marked(
3868                            tl,
3869                            &styles,
3870                            false,
3871                            is_match,
3872                            current_match == Some(idx),
3873                        );
3874                        display.push((idx, tl.kind, line));
3875                    }
3876                }
3877            }
3878        }
3879    }
3880
3881    // Resolve scroll offset into the display list.
3882    let total = display.len();
3883    let raw_start = if state.scroll_offset == usize::MAX {
3884        total.saturating_sub(content_area.height as usize)
3885    } else {
3886        display
3887            .iter()
3888            .position(|(orig_idx, _, _)| *orig_idx >= state.scroll_offset)
3889            .unwrap_or(total.saturating_sub(1))
3890    };
3891    let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
3892
3893    // Sticky header (grok-build parity): when the viewport top sits inside a
3894    // block's body (not on its head), pin the block's first line at the top
3895    // so the user can tell which block they are scrolling through.
3896    let sticky_first: Option<usize> = display.get(start).and_then(|(orig_idx, _, _)| {
3897        let bid = state.transcript.get(*orig_idx)?.block_id;
3898        let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
3899        (first_idx != *orig_idx).then_some(first_idx)
3900    });
3901    let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
3902    let body_top = content_area.top() + sticky_h;
3903
3904    // Determine animation state.
3905    let running = state.reasoning_stage.is_some();
3906    const WAVE_ROWS: u16 = 32;
3907    const WAVE_SPEED: f64 = 0.15;
3908
3909    // Push/fade (grok-build iOS-style 1D): detect the next block boundary
3910    // within the viewport. As it approaches the sticky row, fade the current
3911    // sticky header toward the background — a smooth handoff to the next
3912    // block's header. FADE_ROWS controls the transition width.
3913    const FADE_ROWS: usize = 5;
3914    let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
3915        let sticky_bid = state.transcript[sidx].block_id;
3916        // Walk display from `start` to find the first visual row belonging to
3917        // a different block.
3918        let next_offset = display.iter().skip(start).position(|(orig_idx, _, _)| {
3919            state
3920                .transcript
3921                .get(*orig_idx)
3922                .map(|l| l.block_id != sticky_bid)
3923                .unwrap_or(false)
3924        });
3925        match next_offset {
3926            Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
3927            _ => 1.0,
3928        }
3929    } else {
3930        1.0
3931    };
3932
3933    // Sticky header row: accent rail + head line + faint bg highlight.
3934    // Opacity fades as the next block pushes in.
3935    if let Some(sidx) = sticky_first {
3936        let tl = &state.transcript[sidx];
3937        let accent_base = accent_color_for_kind(tl.kind, &styles);
3938        let rail_blend = 0.7 * sticky_opacity;
3939        let bg_blend = 0.1 * sticky_opacity;
3940        if sticky_opacity > 0.05
3941            && let Some(cell) = frame.buffer_mut().cell_mut((area.x, content_area.top()))
3942        {
3943            cell.set_char('\u{2503}');
3944            cell.set_style(Style::default().fg(blend_rgb(bg_color, accent_base, rail_blend)));
3945        }
3946        let line = transcript_line_marked(tl, &styles, false, false, false);
3947        let row = Rect {
3948            x: content_area.x,
3949            y: content_area.top(),
3950            width: content_area.width,
3951            height: 1,
3952        };
3953        if bg_blend > 0.01 {
3954            frame.buffer_mut().set_style(
3955                row,
3956                Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
3957            );
3958        }
3959        frame.render_widget(Paragraph::new(line), row);
3960    }
3961
3962    // Render top-down, wrapping each line into multiple visual rows.
3963    let mut y = body_top;
3964    let width = content_area.width.max(1) as usize;
3965    let mut visual_row: u16 = 0;
3966    for (_, kind, line) in display.into_iter().skip(start) {
3967        if y >= content_area.bottom() {
3968            break;
3969        }
3970        let text_w = line.width();
3971        let wrapped_h = if text_w == 0 {
3972            1
3973        } else {
3974            text_w.div_ceil(width).max(1) as u16
3975        };
3976
3977        // Paint accent rail for each visual row of this line.
3978        let accent_base = accent_color_for_kind(kind, &styles);
3979        for row_offset in 0..wrapped_h {
3980            let paint_y = y + row_offset;
3981            if paint_y >= content_area.bottom() {
3982                break;
3983            }
3984            let brightness = if running {
3985                0.4 + 0.6 * wave_brightness(tick, visual_row + row_offset, WAVE_ROWS, WAVE_SPEED)
3986            } else {
3987                0.7
3988            };
3989            let rail_color = blend_rgb(bg_color, accent_base, brightness);
3990            if let Some(cell) = frame.buffer_mut().cell_mut((area.x, paint_y)) {
3991                cell.set_char('\u{2503}'); // ┃ heavy vertical
3992                cell.set_style(Style::default().fg(rail_color));
3993            }
3994        }
3995
3996        let row = Rect {
3997            x: content_area.x,
3998            y,
3999            width: content_area.width,
4000            height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
4001        };
4002        frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
4003        y += wrapped_h;
4004        visual_row += wrapped_h;
4005    }
4006
4007    // Scrollbar (rightmost column): shown only when content overflows.
4008    // Follow-tail dims the thumb; explicit scroll brightens it.
4009    let body_viewport = (content_area.height as usize).saturating_sub(sticky_h as usize);
4010    if total > body_viewport {
4011        let follow = state.scroll_offset == usize::MAX;
4012        render_scrollbar(
4013            frame,
4014            area.right().saturating_sub(1),
4015            area.top(),
4016            area.height,
4017            total,
4018            body_viewport,
4019            start,
4020            follow,
4021            &styles,
4022            bg_color,
4023        );
4024    }
4025}
4026
4027/// Render a 1-column scrollbar in the rightmost cell column. The thumb
4028/// represents the viewport's position within the full content; the rail is
4029/// a faint track. Follow-tail (auto-scroll) dims the thumb toward the
4030/// background; an explicit scroll offset paints it in the accent color.
4031#[allow(clippy::too_many_arguments)]
4032fn render_scrollbar(
4033    frame: &mut Frame,
4034    x: u16,
4035    top: u16,
4036    height: u16,
4037    total: usize,
4038    viewport: usize,
4039    start: usize,
4040    follow: bool,
4041    styles: &ThemeStyles,
4042    bg: Color,
4043) {
4044    if height == 0 {
4045        return;
4046    }
4047    let ratio = (start as f64 / total.max(1) as f64).clamp(0.0, 1.0);
4048    let thumb_h = (((viewport as f64 / total.max(1) as f64) * height as f64).ceil() as u16)
4049        .max(1)
4050        .min(height);
4051    let track_h = height.saturating_sub(thumb_h);
4052    let thumb_y = (ratio * track_h as f64).round() as u16;
4053
4054    let accent = color_from_anstyle(styles.primary.get_fg_color());
4055    // Follow-tail: dim thumb so it recedes. Explicit scroll: bright accent.
4056    let thumb_color = if follow {
4057        blend_rgb(bg, accent, 0.35)
4058    } else {
4059        accent
4060    };
4061    let rail_color = blend_rgb(bg, accent, 0.1);
4062
4063    for row in 0..height {
4064        let y = top + row;
4065        let is_thumb = row >= thumb_y && row < thumb_y + thumb_h;
4066        let (ch, color) = if is_thumb {
4067            ('\u{2588}', thumb_color) // █
4068        } else {
4069            ('\u{2502}', rail_color) // │
4070        };
4071        if let Some(cell) = frame.buffer_mut().cell_mut((x, y)) {
4072            cell.set_char(ch);
4073            cell.set_style(Style::default().fg(color));
4074        }
4075    }
4076}
4077
4078/// Build a ratatui `Line` from a transcript line, with optional fold marker
4079/// and search-match highlighting.
4080fn transcript_line_marked<'a>(
4081    line: &'a TranscriptLine,
4082    styles: &'a ThemeStyles,
4083    folded: bool,
4084    is_match: bool,
4085    is_current: bool,
4086) -> Line<'a> {
4087    let (kind_style, marker) = match line.kind {
4088        InlineMessageKind::Agent => (
4089            Style::default().fg(color_from_anstyle(styles.response.get_fg_color())),
4090            "\u{25cf}", // ●
4091        ),
4092        InlineMessageKind::User => (
4093            Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4094            "\u{276f}", // ❯
4095        ),
4096        InlineMessageKind::Tool => (
4097            Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
4098            "\u{2699}", // ⚙
4099        ),
4100        InlineMessageKind::Error => (
4101            Style::default().fg(color_from_anstyle(styles.error.get_fg_color())),
4102            "\u{2717}", // ✗
4103        ),
4104        InlineMessageKind::Warning => (
4105            Style::default().fg(color_from_anstyle(styles.status.get_fg_color())),
4106            "\u{26a0}", // ⚠
4107        ),
4108        InlineMessageKind::Info => (
4109            Style::default().fg(color_from_anstyle(styles.info.get_fg_color())),
4110            "\u{2139}", // ℹ
4111        ),
4112        InlineMessageKind::Policy => (
4113            Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color())),
4114            "\u{25c6}", // ◆
4115        ),
4116        InlineMessageKind::Pty => (
4117            Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color())),
4118            "\u{258c}", // ▌
4119        ),
4120    };
4121
4122    // Fold marker: ▸ for folded, ▾ for unfolded (shown on first line of block).
4123    let prefix = if folded {
4124        format!("\u{25b8} {} ", marker) // ▸
4125    } else {
4126        format!("{} ", marker)
4127    };
4128
4129    // Highlight background for search matches.
4130    let highlight = if is_current {
4131        Some(Style::default().reversed())
4132    } else if is_match {
4133        Some(Style::default().add_modifier(Modifier::UNDERLINED))
4134    } else {
4135        None
4136    };
4137
4138    let mut spans = Vec::with_capacity(line.segments.len() + 1);
4139    spans.push(Span::styled(prefix, kind_style));
4140    for segment in &line.segments {
4141        let mut style = segment_style(segment, kind_style, styles);
4142        if let Some(h) = highlight {
4143            style = style.patch(h);
4144        }
4145        spans.push(Span::styled(segment.text.clone(), style));
4146    }
4147    Line::from(spans)
4148}
4149
4150fn segment_style(segment: &InlineSegment, fallback: Style, styles: &ThemeStyles) -> Style {
4151    let mut style = fallback;
4152    let inline = segment.style.as_ref();
4153    if let Some(color) = inline.color {
4154        style = style.fg(color_from_anstyle(Some(color)));
4155    } else {
4156        // Fall back to the active palette's default for the kind. We
4157        // pick `response` for agent segments since the harness doesn't
4158        // carry its own theme.
4159        style = style.fg(color_from_anstyle(styles.response.get_fg_color()));
4160    }
4161    if inline.effects.contains(anstyle::Effects::BOLD) {
4162        style = style.add_modifier(Modifier::BOLD);
4163    }
4164    if inline.effects.contains(anstyle::Effects::ITALIC) {
4165        style = style.add_modifier(Modifier::ITALIC);
4166    }
4167    if inline.effects.contains(anstyle::Effects::UNDERLINE) {
4168        style = style.add_modifier(Modifier::UNDERLINED);
4169    }
4170    if inline.effects.contains(anstyle::Effects::DIMMED) {
4171        style = style.add_modifier(Modifier::DIM);
4172    }
4173    style
4174}
4175
4176fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
4177    let styles = active_styles();
4178    let prefix_style = Style::default()
4179        .fg(color_from_anstyle(styles.primary.get_fg_color()))
4180        .bold();
4181    let text_style = Style::default().fg(color_from_anstyle(Some(styles.foreground)));
4182
4183    let prefix = state.prompt_prefix.clone();
4184    let body = state.input_buffer.clone();
4185    let placeholder = state.placeholder.clone();
4186
4187    let mut line_spans = Vec::new();
4188    if let Some(label) = state.vim_state.status_label() {
4189        line_spans.push(Span::styled(
4190            format!("[{label}] "),
4191            Style::default()
4192                .fg(color_from_anstyle(styles.tool.get_fg_color()))
4193                .add_modifier(Modifier::BOLD),
4194        ));
4195    }
4196    if state.autonomy_mode.is_auto() {
4197        line_spans.push(Span::styled(
4198            "[auto] ",
4199            Style::default()
4200                .fg(Color::Yellow)
4201                .add_modifier(Modifier::BOLD),
4202        ));
4203    }
4204    line_spans.push(Span::styled(prefix, prefix_style));
4205    if state.shell_mode {
4206        line_spans.push(Span::styled(
4207            "! ",
4208            Style::default()
4209                .fg(Color::Yellow)
4210                .add_modifier(Modifier::BOLD),
4211        ));
4212    }
4213    if body.is_empty()
4214        && let Some(ph) = placeholder
4215    {
4216        line_spans.push(Span::styled(
4217            ph,
4218            Style::default()
4219                .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4220                .dim(),
4221        ));
4222    } else {
4223        line_spans.push(Span::styled(body, text_style));
4224    }
4225    let block = Block::default()
4226        .borders(Borders::ALL)
4227        .border_type(BorderType::Rounded)
4228        .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())));
4229    let paragraph = Paragraph::new(Line::from(line_spans))
4230        .block(block)
4231        .wrap(Wrap { trim: false });
4232    frame.render_widget(paragraph, area);
4233
4234    // Place the cursor inside the composer at the current edit position.
4235    // +1 on both axes to clear the rounded border.
4236    if state.input_enabled {
4237        let vim_off = state
4238            .vim_state
4239            .status_label()
4240            .map(|l| format!("[{l}] ").chars().count() as u16)
4241            .unwrap_or(0);
4242        let shell_off = if state.shell_mode { 2 } else { 0 };
4243        let mode_off = if state.autonomy_mode.is_auto() { 7 } else { 0 };
4244        let cursor_x = area.left()
4245            + 1
4246            + vim_off
4247            + mode_off
4248            + shell_off
4249            + state.prompt_prefix.chars().count() as u16
4250            + state.input_cursor as u16;
4251        let cursor_y = area.top() + 1;
4252        frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
4253    }
4254}
4255
4256/// Render a welcome banner when the transcript is empty, using the vtui
4257/// `WelcomeLayout` for proper geometry on wide terminals.
4258fn render_welcome(frame: &mut Frame<'_>, area: Rect) {
4259    let styles = active_styles();
4260    let primary = color_from_anstyle(styles.primary.get_fg_color());
4261    let fg = color_from_anstyle(Some(styles.foreground));
4262    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4263
4264    // For wide terminals, use the hero-box layout; otherwise a simple
4265    // centered paragraph is more reliable for narrow viewports.
4266    if area.width >= 90 {
4267        use oxicode_vtui::design::layout::WelcomeLayout;
4268        let layout = WelcomeLayout::compute(area, 3, 0, 0, 1, 0, false);
4269        let logo_area = if layout.has_hero_box() {
4270            layout.hero_logo
4271        } else {
4272            layout.logo
4273        };
4274        if logo_area.height > 0 {
4275            frame.render_widget(
4276                Paragraph::new(Line::from(Span::styled(
4277                    "\u{25cf} oxicode",
4278                    Style::default().fg(primary).add_modifier(Modifier::BOLD),
4279                )))
4280                .alignment(Alignment::Center),
4281                logo_area,
4282            );
4283        }
4284        if layout.tip.height > 0 {
4285            frame.render_widget(
4286                Paragraph::new(Line::from(Span::styled(
4287                    "Type a message to begin, or press / for commands.",
4288                    Style::default().fg(fg),
4289                )))
4290                .alignment(Alignment::Center),
4291                layout.tip,
4292            );
4293        }
4294        if layout.version.height > 0 {
4295            frame.render_widget(
4296                Paragraph::new(Line::from(Span::styled(
4297                    format!("v{}", env!("CARGO_PKG_VERSION")),
4298                    Style::default().fg(secondary).add_modifier(Modifier::DIM),
4299                )))
4300                .alignment(Alignment::Center),
4301                layout.version,
4302            );
4303        }
4304        return;
4305    }
4306
4307    // Narrow terminal fallback — simple centered paragraph.
4308    let version = env!("CARGO_PKG_VERSION");
4309    let text = vec![
4310        Line::from(""),
4311        Line::from(""),
4312        Line::from(Span::styled(
4313            "\u{25cf} oxicode",
4314            Style::default().fg(primary).add_modifier(Modifier::BOLD),
4315        )),
4316        Line::from(""),
4317        Line::from(Span::styled(
4318            "Type a message to begin, or press / for commands.",
4319            Style::default().fg(fg),
4320        )),
4321        Line::from(Span::styled(
4322            format!("v{version} \u{2014} /help for commands"),
4323            Style::default().fg(secondary),
4324        )),
4325    ];
4326    frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), area);
4327}
4328
4329/// Render a 1-row reasoning/tool-stage indicator just above the composer.
4330fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, stage: &str) {
4331    let styles = active_styles();
4332    let indicator_area = Rect {
4333        x: composer_area.x,
4334        y: composer_area.top().saturating_sub(1),
4335        width: composer_area.width,
4336        height: 1,
4337    };
4338    let spinner = "\u{25cc}"; // ◌
4339    let line = Line::from(vec![
4340        Span::styled(
4341            format!("{spinner} "),
4342            Style::default().fg(color_from_anstyle(styles.tool.get_fg_color())),
4343        ),
4344        Span::styled(
4345            stage.to_string(),
4346            Style::default()
4347                .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4348                .add_modifier(Modifier::DIM),
4349        ),
4350    ]);
4351    frame.render_widget(Paragraph::new(line), indicator_area);
4352}
4353
4354/// Render queued input prompts as a compact pane at the top of the scrollback.
4355fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) {
4356    let styles = active_styles();
4357    let entries = &state.queued_inputs;
4358    let interactive = state.queue_panel_open;
4359    let selected = state.queue_selected.min(entries.len().saturating_sub(1));
4360    let height = entries.len() as u16 + 1;
4361    let area = Rect {
4362        x: scrollback.x,
4363        y: scrollback.y,
4364        width: scrollback.width,
4365        height,
4366    };
4367    let info = color_from_anstyle(styles.info.get_fg_color());
4368    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4369    let primary = color_from_anstyle(styles.primary.get_fg_color());
4370    let items: Vec<Line<'_>> = entries
4371        .iter()
4372        .enumerate()
4373        .map(|(i, e)| {
4374            let prefix = if interactive {
4375                format!("#{} ", i + 1)
4376            } else {
4377                "\u{2261} ".to_string()
4378            };
4379            let prefix_style = if interactive && i == selected {
4380                Style::default().fg(primary).add_modifier(Modifier::BOLD)
4381            } else {
4382                Style::default().fg(info)
4383            };
4384            let text_style = if interactive && i == selected {
4385                Style::default().fg(primary).add_modifier(Modifier::BOLD)
4386            } else {
4387                Style::default().fg(secondary)
4388            };
4389            let marker = if interactive && i == selected {
4390                "\u{25b8} " // ▸
4391            } else {
4392                "  "
4393            };
4394            Line::from(vec![
4395                Span::styled(prefix, prefix_style),
4396                Span::styled(marker, prefix_style),
4397                Span::styled(e.clone(), text_style),
4398            ])
4399        })
4400        .collect();
4401    frame.render_widget(
4402        Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
4403            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4404        )),
4405        area,
4406    );
4407}
4408
4409/// Flatten todo phases into `(content, status)` pairs for the sticky pane.
4410fn flatten_todo_items(
4411    phases: &[oxicode_agent::tools::todo::TodoPhase],
4412) -> Vec<(String, TodoStatus)> {
4413    phases
4414        .iter()
4415        .flat_map(|p| p.tasks.iter().map(|t| (t.content.clone(), t.status)))
4416        .collect()
4417}
4418
4419/// Render a compact todo checklist at the top of the scrollback area.
4420fn render_todo_pane(frame: &mut Frame<'_>, scrollback: Rect, items: &[(String, TodoStatus)]) {
4421    let styles = active_styles();
4422    let height = items.len() as u16 + 1;
4423    let area = Rect {
4424        x: scrollback.x,
4425        y: scrollback.y,
4426        width: scrollback.width,
4427        height,
4428    };
4429    let lines: Vec<Line<'_>> = items
4430        .iter()
4431        .map(|(text, status)| {
4432            // Per-status glyph + color so the current task (▶), blocked
4433            // tasks (⏸), and abandoned tasks (✗) are distinguishable at a
4434            // glance, not just done (☑) vs. open (☐).
4435            let (marker, color) = match status {
4436                TodoStatus::Completed => ("\u{2611}", Some(styles.foreground)), // ☑
4437                TodoStatus::InProgress => ("\u{25B6}", styles.primary.get_fg_color()), // ▶
4438                TodoStatus::Blocked => ("\u{23F8}", styles.info.get_fg_color()), // ⏸
4439                TodoStatus::Abandoned => ("\u{2717}", styles.error.get_fg_color()), // ✗
4440                TodoStatus::Pending => ("\u{2610}", styles.secondary.get_fg_color()), // ☐
4441            };
4442            let text_style = if *status == TodoStatus::Completed {
4443                Style::default()
4444                    .fg(color_from_anstyle(Some(styles.foreground)))
4445                    .add_modifier(Modifier::CROSSED_OUT)
4446            } else {
4447                Style::default().fg(color_from_anstyle(Some(styles.foreground)))
4448            };
4449            Line::from(vec![
4450                Span::styled(
4451                    format!("{marker} "),
4452                    Style::default().fg(color_from_anstyle(color)),
4453                ),
4454                Span::styled(text.clone(), text_style),
4455            ])
4456        })
4457        .collect();
4458    frame.render_widget(Paragraph::new(lines), area);
4459}
4460
4461/// Render follow-up suggestion chips just above the composer.
4462fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
4463    let styles = active_styles();
4464    let area = Rect {
4465        x: composer_area.x,
4466        y: composer_area.top().saturating_sub(1),
4467        width: composer_area.width,
4468        height: 1,
4469    };
4470    let mut spans = vec![Span::styled(
4471        "Suggestions: ",
4472        Style::default()
4473            .fg(color_from_anstyle(styles.secondary.get_fg_color()))
4474            .add_modifier(Modifier::DIM),
4475    )];
4476    for (i, chip) in chips.iter().enumerate() {
4477        if i > 0 {
4478            spans.push(Span::raw("  "));
4479        }
4480        spans.push(Span::styled(
4481            format!("\u{25b8} {chip}"),
4482            Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
4483        ));
4484    }
4485    frame.render_widget(Paragraph::new(Line::from(spans)), area);
4486}
4487
4488/// Whether an ephemeral tip is still within its visible TTL window.
4489fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
4490    now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
4491}
4492
4493/// Render the ephemeral tip banner one row above the composer.
4494fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
4495    let styles = active_styles();
4496    let area = Rect {
4497        x: composer_area.x,
4498        y: composer_area.top().saturating_sub(1),
4499        width: composer_area.width,
4500        height: 1,
4501    };
4502    let line = Line::styled(
4503        format!(" \u{2139} {text}"),
4504        Style::default()
4505            .fg(color_from_anstyle(styles.info.get_fg_color()))
4506            .add_modifier(Modifier::DIM),
4507    );
4508    frame.render_widget(Paragraph::new(line), area);
4509}
4510
4511/// Render the slash-command autocomplete popup as a floating panel above the
4512/// composer. Anchored to the composer's left edge, grows upward.
4513fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4514    let styles = active_styles();
4515    let items = &state.slash_popup.items;
4516    if items.is_empty() {
4517        return;
4518    }
4519
4520    let max_visible = 8usize;
4521    let visible = items.len().min(max_visible);
4522    let popup_h = visible as u16 + 2; // +2 for top/bottom border
4523    let width = composer_area.width.min(64);
4524    let popup_area = Rect {
4525        x: composer_area.left(),
4526        y: composer_area.top().saturating_sub(popup_h),
4527        width,
4528        height: popup_h,
4529    };
4530    frame.render_widget(Clear, popup_area);
4531
4532    let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4533    let title = Line::from(Span::styled(
4534        " Commands ",
4535        Style::default()
4536            .fg(color_from_anstyle(styles.primary.get_fg_color()))
4537            .add_modifier(Modifier::BOLD),
4538    ));
4539    let block = Block::default()
4540        .borders(Borders::ALL)
4541        .border_type(BorderType::Rounded)
4542        .border_style(Style::default().fg(border_color))
4543        .title(title);
4544    let inner = block.inner(popup_area);
4545    frame.render_widget(&block, popup_area);
4546
4547    // Column-align labels by padding to the widest visible label.
4548    let max_label = items
4549        .iter()
4550        .take(visible)
4551        .map(|i| i.label.chars().count())
4552        .max()
4553        .unwrap_or(0);
4554
4555    let primary = color_from_anstyle(styles.primary.get_fg_color());
4556    let fg = color_from_anstyle(Some(styles.foreground));
4557    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4558
4559    for (i, item) in items.iter().take(visible).enumerate() {
4560        let is_selected = i == state.slash_popup.selected;
4561        let y = inner.top() + i as u16;
4562        let row_area = Rect {
4563            x: inner.left(),
4564            y,
4565            width: inner.width,
4566            height: 1,
4567        };
4568
4569        let marker = if is_selected { "\u{25b8} " } else { "  " }; // ▸ or space
4570        let label_style = if is_selected {
4571            Style::default().fg(primary).add_modifier(Modifier::BOLD)
4572        } else {
4573            Style::default().fg(fg)
4574        };
4575        let label_padded = format!("{:<width$}", item.label, width = max_label);
4576        let line = Line::from(vec![
4577            Span::styled(marker, label_style),
4578            Span::styled(label_padded, label_style),
4579            Span::raw("  "),
4580            Span::styled(&item.description, Style::default().fg(secondary)),
4581        ]);
4582        frame.render_widget(Paragraph::new(line), row_area);
4583    }
4584}
4585
4586/// Render the @-file-search dropdown as a floating panel above the
4587/// composer, mirroring `render_slash_popup`'s geometry. Shows up to 10
4588/// fuzzy-matched file paths with the selected one highlighted.
4589fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
4590    let styles = active_styles();
4591    let Some(fs) = &state.file_search else {
4592        return;
4593    };
4594    let items = &fs.results;
4595    if items.is_empty() {
4596        return;
4597    }
4598
4599    let max_visible = 10usize;
4600    let visible = items.len().min(max_visible);
4601    let popup_h = visible as u16 + 2; // +2 for top/bottom border
4602    let width = composer_area.width.min(72);
4603    let popup_area = Rect {
4604        x: composer_area.left(),
4605        y: composer_area.top().saturating_sub(popup_h),
4606        width,
4607        height: popup_h,
4608    };
4609    frame.render_widget(Clear, popup_area);
4610
4611    let border_color = color_from_anstyle(styles.secondary.get_fg_color());
4612    let title_str = if fs.hidden_mode {
4613        " Files (hidden) "
4614    } else {
4615        " Files "
4616    };
4617    let title = Line::from(Span::styled(
4618        title_str,
4619        Style::default()
4620            .fg(color_from_anstyle(styles.primary.get_fg_color()))
4621            .add_modifier(Modifier::BOLD),
4622    ));
4623    let block = Block::default()
4624        .borders(Borders::ALL)
4625        .border_type(BorderType::Rounded)
4626        .border_style(Style::default().fg(border_color))
4627        .title(title);
4628    let inner = block.inner(popup_area);
4629    frame.render_widget(&block, popup_area);
4630
4631    let primary = color_from_anstyle(styles.primary.get_fg_color());
4632    let fg = color_from_anstyle(Some(styles.foreground));
4633    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
4634
4635    for (i, result) in items.iter().take(visible).enumerate() {
4636        let is_selected = i == fs.selected;
4637        let y = inner.top() + i as u16;
4638        let row_area = Rect {
4639            x: inner.left(),
4640            y,
4641            width: inner.width,
4642            height: 1,
4643        };
4644
4645        let marker = if is_selected { "\u{25b8} " } else { "  " }; // ▸ or space
4646        let path_style = if is_selected {
4647            Style::default().fg(primary).add_modifier(Modifier::BOLD)
4648        } else {
4649            Style::default().fg(fg)
4650        };
4651        let line = Line::from(vec![
4652            Span::styled(marker, path_style),
4653            Span::styled(&result.path, path_style),
4654        ]);
4655        frame.render_widget(Paragraph::new(line), row_area);
4656    }
4657
4658    // Footer hint: show result count + key bindings.
4659    if popup_h >= 4 {
4660        let hint_y = inner.bottom();
4661        let hint_area = Rect {
4662            x: inner.left(),
4663            y: hint_y,
4664            width: inner.width,
4665            height: 1,
4666        };
4667        let count = items.len();
4668        let hint = format!("{count} files  \u{00b7}  Tab accept  Esc cancel");
4669        let _ = secondary; // suppress unused warning
4670        frame.render_widget(
4671            Paragraph::new(Line::from(Span::styled(
4672                hint,
4673                Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
4674            )))
4675            .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
4676            hint_area,
4677        );
4678    }
4679}
4680
4681// ─────────────────────────────────────────────────────────────────────────
4682// Vim mode — Editor adapter for the input buffer
4683// ─────────────────────────────────────────────────────────────────────────
4684
4685/// Adapter that lets the vim engine operate on `RenderState`'s input buffer.
4686struct InputEditor<'a> {
4687    buffer: &'a mut String,
4688    cursor: &'a mut usize,
4689}
4690
4691impl<'a> oxicode_vtui::vim::Editor for InputEditor<'a> {
4692    fn content(&self) -> &str {
4693        self.buffer
4694    }
4695    fn cursor(&self) -> usize {
4696        *self.cursor
4697    }
4698    fn set_cursor(&mut self, pos: usize) {
4699        *self.cursor = pos.min(self.buffer.len());
4700    }
4701    fn move_left(&mut self) {
4702        *self.cursor = self.cursor.saturating_sub(1);
4703    }
4704    fn move_right(&mut self) {
4705        let len = self.buffer.len();
4706        *self.cursor = (*self.cursor + 1).min(len);
4707    }
4708    fn delete_char_forward(&mut self) {
4709        let cursor = *self.cursor;
4710        if cursor < self.buffer.len() {
4711            let next = self.buffer[cursor..]
4712                .char_indices()
4713                .nth(1)
4714                .map(|(i, _)| cursor + i)
4715                .unwrap_or(self.buffer.len());
4716            self.buffer.replace_range(cursor..next, "");
4717        }
4718    }
4719    fn insert_text(&mut self, text: &str) {
4720        let cursor = *self.cursor;
4721        self.buffer.insert_str(cursor, text);
4722        *self.cursor = cursor + text.len();
4723    }
4724    fn replace(&mut self, content: String, cursor: usize) {
4725        *self.buffer = content;
4726        *self.cursor = cursor.min(self.buffer.len());
4727    }
4728}
4729
4730// ─────────────────────────────────────────────────────────────────────────
4731// Small helpers
4732// ─────────────────────────────────────────────────────────────────────────
4733
4734pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
4735    InlineSegment {
4736        text: text.into(),
4737        style: Arc::new(InlineTextStyle::default()),
4738    }
4739}
4740
4741pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
4742    if offset == usize::MAX {
4743        return total.saturating_sub(viewport);
4744    }
4745    // Clamp into [0, total.saturating_sub(viewport)].
4746    let max_start = total.saturating_sub(viewport);
4747    offset.min(max_start)
4748}
4749
4750// ─────────────────────────────────────────────────────────────────────────
4751// Secure-input helpers — pure string mutations used by the input thread
4752// while a secure prompt overlay is open. Kept as free functions so the
4753// key routing in `handle_overlay_key` / `spawn_input_thread` stays thin
4754// and the byte-boundary logic is unit-testable in isolation.
4755// ─────────────────────────────────────────────────────────────────────────
4756
4757/// Append `ch` to `value` at `cursor`, returning the new value and cursor.
4758/// `cursor` is a byte index into `value`; `ch` is inserted at that offset
4759/// and the cursor advances by `ch.len_utf8()` bytes.
4760fn insert_char_into_secure_input(value: &str, cursor: usize, ch: char) -> (String, usize) {
4761    let mut s = String::with_capacity(value.len() + ch.len_utf8());
4762    s.push_str(&value[..cursor]);
4763    s.push(ch);
4764    s.push_str(&value[cursor..]);
4765    (s, cursor + ch.len_utf8())
4766}
4767
4768/// Pop the byte before `cursor` from `value`, returning the new value and
4769/// cursor. Walks back to the nearest UTF-8 char boundary so multi-byte
4770/// characters are removed whole. A no-op when `cursor == 0`.
4771fn backspace_secure_input(value: &str, cursor: usize) -> (String, usize) {
4772    if cursor == 0 {
4773        return (value.to_string(), 0);
4774    }
4775    // Find the previous char boundary.
4776    let mut prev = cursor - 1;
4777    while !value.is_char_boundary(prev) {
4778        prev -= 1;
4779    }
4780    let mut s = String::with_capacity(value.len() - (cursor - prev));
4781    s.push_str(&value[..prev]);
4782    s.push_str(&value[cursor..]);
4783    (s, prev)
4784}
4785
4786/// Insert a pasted chunk at `cursor`. Strips a single trailing `\n`
4787/// (terminals commonly deliver one at the end of a bracketed paste) and
4788/// drops any byte that isn't printable ASCII — newlines, tabs, and other
4789/// control characters are filtered out so secrets stay on a single line.
4790fn insert_paste_into_secure_input(value: &str, cursor: usize, paste: &str) -> (String, usize) {
4791    let trimmed = paste.trim_end_matches('\n');
4792    let filtered: String = trimmed
4793        .chars()
4794        .filter(|c| c.is_ascii_graphic() || *c == ' ')
4795        .collect();
4796    let mut s = String::with_capacity(value.len() + filtered.len());
4797    s.push_str(&value[..cursor]);
4798    s.push_str(&filtered);
4799    s.push_str(&value[cursor..]);
4800    (s, cursor + filtered.len())
4801}
4802
4803// ─────────────────────────────────────────────────────────────────────────
4804// Slash-command autocomplete popup
4805// ─────────────────────────────────────────────────────────────────────────
4806
4807/// Filter slash commands by `token` (the text after `/`). An empty token
4808/// returns every command. Matching is prefix-based against the canonical
4809/// name and all aliases.
4810///
4811/// Built-in commands are listed first; user-defined file commands are
4812/// appended afterwards. Any file command whose name shadows a built-in is
4813/// dropped — built-ins always win, so file commands cannot redefine
4814/// `/quit`, `/clear`, etc.
4815fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
4816    let builtins = SlashRegistry::builtin_commands();
4817    let builtin_names: std::collections::HashSet<&str> =
4818        builtins.iter().map(|(n, _, _)| *n).collect();
4819
4820    let mut items: Vec<SlashPopupItem> = builtins
4821        .into_iter()
4822        .filter(|(name, _, aliases)| {
4823            token.is_empty()
4824                || name.starts_with(token)
4825                || aliases.iter().any(|a| a.starts_with(token))
4826        })
4827        .map(|(name, desc, aliases)| {
4828            let mut label = format!("/{name}");
4829            for a in &aliases {
4830                label.push_str(&format!(", /{a}"));
4831            }
4832            SlashPopupItem {
4833                label,
4834                description: desc.to_string(),
4835                name: name.to_string(),
4836            }
4837        })
4838        .collect();
4839
4840    // Append file commands (skip names shadowed by builtins).
4841    for fc in file_commands {
4842        if builtin_names.contains(fc.name.as_str())
4843            || fc
4844                .aliases
4845                .iter()
4846                .any(|alias| builtin_names.contains(alias.as_str()))
4847        {
4848            continue;
4849        }
4850        if token.is_empty()
4851            || fc.name.starts_with(token)
4852            || fc.aliases.iter().any(|a| a.starts_with(token))
4853        {
4854            let mut label = format!("/{}", fc.name);
4855            for a in &fc.aliases {
4856                label.push_str(&format!(", /{a}"));
4857            }
4858            items.push(SlashPopupItem {
4859                label,
4860                description: fc.description.clone(),
4861                name: fc.name.clone(),
4862            });
4863        }
4864    }
4865
4866    items
4867}
4868
4869/// Recompute the slash popup from the current input buffer. The popup is
4870/// active when the buffer starts with `/` and has no space yet (the user is
4871/// still composing the command token, not its arguments). Called after every
4872/// buffer mutation in the input thread.
4873fn refresh_slash_popup(state: &mut RenderState) {
4874    let buf = state.input_buffer.clone();
4875    let active = buf.starts_with('/') && !buf[1..].contains(' ');
4876    if !active {
4877        state.slash_popup.open = false;
4878        state.slash_popup.items.clear();
4879        state.slash_popup.selected = 0;
4880        return;
4881    }
4882    let token = &buf[1..];
4883    let items = slash_filter(token, &state.file_commands);
4884    state.slash_popup.open = !items.is_empty();
4885    if items.is_empty() {
4886        state.slash_popup.selected = 0;
4887    } else {
4888        state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
4889    }
4890    state.slash_popup.items = items;
4891}
4892/// Combined popup refresher — calls both the slash-command popup and the
4893/// @-file-search picker. Called after every input buffer mutation in the
4894/// input thread so both popups stay in sync with the cursor position.
4895fn refresh_input_popups(state: &mut RenderState) {
4896    refresh_slash_popup(state);
4897    refresh_file_search(state);
4898}
4899
4900/// Recompute the @-file-search dropdown from the current input buffer.
4901/// Called after every buffer mutation in the input thread. The filesystem
4902/// walk (building the index) happens only on the `None → Some` transition
4903/// (when `@` is first typed); subsequent keystrokes just re-filter the
4904/// cached index via [`FileSearchState::refresh`](crate::tui_vt::file_search::FileSearchState::refresh).
4905fn refresh_file_search(state: &mut RenderState) {
4906    use crate::tui_vt::file_search;
4907    // Never open the file picker while a slash command is being composed.
4908    if state.slash_popup.open {
4909        state.file_search = None;
4910        return;
4911    }
4912    match file_search::parse_at_cursor(&state.input_buffer, state.input_cursor) {
4913        Some(token) => match &mut state.file_search {
4914            None => {
4915                let cwd = state.cwd.clone();
4916                state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
4917            }
4918            Some(fs) => {
4919                if fs.query != token.path_query {
4920                    fs.refresh(&token.path_query);
4921                }
4922            }
4923        },
4924        None => state.file_search = None,
4925    }
4926}
4927
4928/// Accept the currently-selected file-search result: replace the `@query`
4929/// token in the buffer with the canonical `@path ` (or `@path:N-M ` in
4930/// line mode), advance the cursor past it, and close the picker.
4931/// Returns `true` if a result was accepted.
4932fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
4933    use crate::tui_vt::file_search;
4934    let Some(fs) = &state.file_search else {
4935        return false;
4936    };
4937    let Some(result) = fs.selected_result().cloned() else {
4938        return false;
4939    };
4940    let at_offset = fs.at_offset;
4941    let text = file_search::insertion_text(&result.path, None, line_mode);
4942    let cursor_end = state.input_cursor;
4943    // Replace everything from `@` to the current cursor with the insertion.
4944    state
4945        .input_buffer
4946        .replace_range(at_offset..cursor_end.min(state.input_buffer.len()), &text);
4947    state.input_cursor = at_offset + text.len();
4948    state.file_search = None;
4949    true
4950}
4951
4952fn preview_tool_result(content: &str) -> String {
4953    const MAX: usize = 500;
4954    if content.chars().count() <= MAX {
4955        return content.to_string();
4956    }
4957    let truncated: String = content.chars().take(MAX).collect();
4958    format!("{truncated}\u{2026}")
4959}
4960
4961/// Try to render tool result content as a colored diff. Returns `true` if the
4962/// content was recognized as a diff and rendered, `false` to fall back to the
4963/// plain preview.
4964fn try_render_diff(content: &str, handle: &InlineHandle) -> bool {
4965    let lines: Vec<&str> = content.lines().collect();
4966    // Require a unified-diff hunk header (`@@ … @@`) as a strong signal that
4967    // the content is actually a diff — prevents grep context lines, bullet
4968    // lists, and shell output from being mis-rendered as deletions.
4969    if !lines.iter().any(|l| l.starts_with("@@")) {
4970        return false;
4971    }
4972    let additions = lines
4973        .iter()
4974        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
4975        .count();
4976    let deletions = lines
4977        .iter()
4978        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
4979        .count();
4980    if additions + deletions < 2 {
4981        return false;
4982    }
4983
4984    let styles = active_styles();
4985    let green = styles.secondary.get_fg_color();
4986    let red = styles.error.get_fg_color();
4987    const MAX_DIFF_LINES: usize = 30;
4988
4989    // Header line with diffstat.
4990    let mut hdr_style = InlineTextStyle::default();
4991    hdr_style.effects |= anstyle::Effects::DIMMED;
4992    handle.append_line(
4993        InlineMessageKind::Tool,
4994        vec![InlineSegment {
4995            text: format!("\u{2713} diff (+{additions} \u{2212}{deletions})"),
4996            style: Arc::new(hdr_style),
4997        }],
4998    );
4999
5000    // Render diff lines with green/red coloring.
5001    for line in lines.iter().take(MAX_DIFF_LINES) {
5002        let mut style = InlineTextStyle::default();
5003        if line.starts_with('+') && !line.starts_with("+++") {
5004            style.color = green;
5005        } else if line.starts_with('-') && !line.starts_with("---") {
5006            style.color = red;
5007        } else {
5008            style.effects |= anstyle::Effects::DIMMED;
5009        }
5010        handle.append_line(
5011            InlineMessageKind::Tool,
5012            vec![InlineSegment {
5013                text: format!("  {line}"),
5014                style: Arc::new(style),
5015            }],
5016        );
5017    }
5018
5019    if lines.len() > MAX_DIFF_LINES {
5020        let mut more_style = InlineTextStyle::default();
5021        more_style.effects |= anstyle::Effects::DIMMED;
5022        handle.append_line(
5023            InlineMessageKind::Tool,
5024            vec![InlineSegment {
5025                text: format!("  \u{2026} {} more lines", lines.len() - MAX_DIFF_LINES),
5026                style: Arc::new(more_style),
5027            }],
5028        );
5029    }
5030
5031    true
5032}
5033
5034fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
5035    match color {
5036        Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
5037        Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
5038        Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
5039        None => Color::Reset,
5040    }
5041}
5042fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
5043    use anstyle::AnsiColor as A;
5044    match color {
5045        A::Black => Color::Black,
5046        A::Red => Color::Red,
5047        A::Green => Color::Green,
5048        A::Yellow => Color::Yellow,
5049        A::Blue => Color::Blue,
5050        A::Magenta => Color::Magenta,
5051        A::Cyan => Color::Cyan,
5052        A::White => Color::Gray,
5053        A::BrightBlack => Color::DarkGray,
5054        A::BrightRed => Color::LightRed,
5055        A::BrightGreen => Color::LightGreen,
5056        A::BrightYellow => Color::LightYellow,
5057        A::BrightBlue => Color::LightBlue,
5058        A::BrightMagenta => Color::LightMagenta,
5059        A::BrightCyan => Color::LightCyan,
5060        A::BrightWhite => Color::White,
5061    }
5062}
5063
5064// Suppress the unused-import warning while keeping the AtomicBool/Ordering
5065// available for future control flags (e.g. SIGINT safety net).
5066#[allow(dead_code, clippy::declare_interior_mutable_const)]
5067const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
5068
5069#[cfg(test)]
5070mod slash_popup_tests {
5071    use super::*;
5072
5073    #[test]
5074    fn empty_token_lists_all_commands() {
5075        let items = slash_filter("", &[]);
5076        // 7 built-in commands.
5077        assert!(items.len() >= 7);
5078        assert!(items.iter().any(|i| i.name == "quit"));
5079        assert!(items.iter().any(|i| i.name == "clear"));
5080        assert!(items.iter().any(|i| i.name == "model"));
5081    }
5082
5083    #[test]
5084    fn prefix_filter_matches_name() {
5085        let items = slash_filter("qu", &[]);
5086        assert_eq!(items.len(), 1);
5087        assert_eq!(items[0].name, "quit");
5088        assert!(items[0].label.contains("/quit"));
5089    }
5090
5091    #[test]
5092    fn prefix_filter_matches_alias() {
5093        // "cl" should match "clear" (alias "cls") and "compact".
5094        let items = slash_filter("cl", &[]);
5095        let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
5096        assert!(names.contains(&"clear"));
5097    }
5098
5099    #[test]
5100    fn file_commands_appear_in_filter() {
5101        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5102            "review",
5103            "---\ndescription: proj cmd\naliases: cr\n---\nbody",
5104        );
5105        let items = slash_filter("", &[fc]);
5106        assert!(items.iter().any(|i| i.name == "review"));
5107        assert!(items.iter().any(|i| i.name == "quit")); // builtins still present
5108    }
5109
5110    #[test]
5111    fn file_commands_filtered_by_prefix() {
5112        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5113            "review",
5114            "---\ndescription: x\n---\nbody",
5115        );
5116        let items = slash_filter("rev", &[fc]);
5117        assert!(items.iter().any(|i| i.name == "review"));
5118    }
5119
5120    #[test]
5121    fn file_commands_shadowed_by_builtins_are_dropped() {
5122        // A file command whose name collides with a built-in must be dropped —
5123        // built-ins always win. Without this guarantee the popup could surface
5124        // two items for the same prefix and the dispatch layer would pick the
5125        // wrong one.
5126        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5127            "quit",
5128            "---\ndescription: hijack\n---\nbody",
5129        );
5130        let items = slash_filter("", &[fc]);
5131        let quit_count = items.iter().filter(|i| i.name == "quit").count();
5132        assert_eq!(quit_count, 1, "shadowed file command must not appear");
5133        // And it must be the built-in description, not the file one.
5134        assert!(
5135            items
5136                .iter()
5137                .any(|i| i.name == "quit" && !i.description.contains("hijack"))
5138        );
5139    }
5140
5141    #[test]
5142    fn file_commands_with_builtin_aliases_are_dropped() {
5143        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
5144            "review",
5145            "---\ndescription: hijack\naliases: quit\n---\nbody",
5146        );
5147        let items = slash_filter("", &[fc]);
5148        assert!(!items.iter().any(|item| item.name == "review"));
5149    }
5150
5151    #[test]
5152    fn popup_opens_on_slash() {
5153        let mut state = RenderState::default();
5154        state.input_buffer = "/".to_string();
5155        refresh_input_popups(&mut state);
5156        assert!(state.slash_popup.open);
5157        assert!(!state.slash_popup.items.is_empty());
5158    }
5159
5160    #[test]
5161    fn popup_closes_on_space() {
5162        let mut state = RenderState::default();
5163        state.input_buffer = "/quit ".to_string();
5164        refresh_input_popups(&mut state);
5165        assert!(!state.slash_popup.open);
5166    }
5167
5168    #[test]
5169    fn popup_closes_on_non_slash() {
5170        let mut state = RenderState::default();
5171        state.input_buffer = "hello".to_string();
5172        refresh_input_popups(&mut state);
5173        assert!(!state.slash_popup.open);
5174    }
5175
5176    #[test]
5177    fn popup_filters_as_user_types() {
5178        let mut state = RenderState::default();
5179        state.input_buffer = "/m".to_string();
5180        refresh_input_popups(&mut state);
5181        assert!(state.slash_popup.open);
5182        // Every item's canonical name must start with 'm' (model is the
5183        // only command matching the "m" prefix).
5184        assert!(
5185            state
5186                .slash_popup
5187                .items
5188                .iter()
5189                .all(|i| i.name.starts_with('m'))
5190        );
5191    }
5192
5193    #[test]
5194    fn popup_selection_clamps_on_shrink() {
5195        let mut state = RenderState::default();
5196        state.input_buffer = "/".to_string();
5197        refresh_input_popups(&mut state);
5198        let full_count = state.slash_popup.items.len();
5199        state.slash_popup.selected = full_count - 1;
5200        // Narrow the filter so fewer items remain.
5201        state.input_buffer = "/qu".to_string();
5202        refresh_input_popups(&mut state);
5203        assert!(state.slash_popup.selected < state.slash_popup.items.len());
5204    }
5205}
5206
5207#[cfg(test)]
5208mod render_tests {
5209    use super::*;
5210    use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
5211    use ratatui::{Terminal, backend::TestBackend};
5212    use tokio::sync::mpsc;
5213
5214    /// Render `render_frame` into a TestBackend and return the concatenated
5215    /// cell text. This catches regressions like a missing render_composer
5216    /// call — `#![allow(dead_code)]` in lib.rs suppresses the unused-fn lint,
5217    /// so only a render assertion can prove the composer is painted.
5218    fn render_frame_to_string(state: &RenderState) -> String {
5219        let backend = TestBackend::new(80, 24);
5220        let mut terminal = Terminal::new(backend).expect("backend");
5221        let (tx, _rx) = mpsc::unbounded_channel();
5222        let handle = InlineHandle::new_for_tests(tx);
5223        terminal
5224            .draw(|f| render_frame(f, state, &handle))
5225            .expect("draw");
5226        let buf = terminal.backend().buffer();
5227        let area = buf.area();
5228        let mut out = String::new();
5229        for y in 0..area.height {
5230            for x in 0..area.width {
5231                if let Some(cell) = buf.cell((x, y)) {
5232                    out.push_str(cell.symbol());
5233                }
5234            }
5235            out.push('\n');
5236        }
5237        out
5238    }
5239
5240    #[test]
5241    fn welcome_screen_shown_when_transcript_empty() {
5242        let state = RenderState::default();
5243        let rendered = render_frame_to_string(&state);
5244        assert!(
5245            rendered.contains("oxicode"),
5246            "welcome banner must appear when transcript is empty"
5247        );
5248    }
5249
5250    #[test]
5251    fn composer_is_painted() {
5252        // Regression guard: the composer prompt prefix must appear in the
5253        // rendered output. This would have caught the missing
5254        // render_composer call (advisory 2026-08-04).
5255        let mut state = RenderState::default();
5256        state.input_enabled = true;
5257        state.prompt_prefix = "> ".to_string();
5258        let rendered = render_frame_to_string(&state);
5259        assert!(
5260            rendered.contains('>'),
5261            "composer prompt prefix must be painted"
5262        );
5263    }
5264
5265    #[test]
5266    fn slash_popup_renders_command_list() {
5267        let mut state = RenderState::default();
5268        state.slash_popup.open = true;
5269        state.slash_popup.items = slash_filter("", &[]);
5270        let rendered = render_frame_to_string(&state);
5271        assert!(rendered.contains("Commands"), "popup title must render");
5272        assert!(rendered.contains("/quit"), "popup must list /quit");
5273    }
5274
5275    #[test]
5276    fn composer_and_popup_render_together() {
5277        let mut state = RenderState::default();
5278        state.prompt_prefix = "> ".to_string();
5279        state.input_buffer = "/qu".to_string();
5280        state.slash_popup.open = true;
5281        state.slash_popup.items = slash_filter("qu", &[]);
5282        let rendered = render_frame_to_string(&state);
5283        assert!(rendered.contains("Commands"), "popup must render");
5284        assert!(rendered.contains("/quit"), "popup must list /quit");
5285        assert!(rendered.contains('>'), "composer must still render");
5286    }
5287
5288    #[test]
5289    fn transcript_wraps_long_lines() {
5290        // A line wider than the terminal must wrap, not clip.
5291        let mut state = RenderState::default();
5292        state.transcript.push(TranscriptLine {
5293            kind: InlineMessageKind::Agent,
5294            segments: vec![plain_segment(
5295                "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
5296            )],
5297            block_id: 0,
5298        });
5299        let backend = TestBackend::new(40, 24);
5300        let mut terminal = Terminal::new(backend).expect("backend");
5301        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5302        let handle = InlineHandle::new_for_tests(tx);
5303        terminal
5304            .draw(|f| render_frame(f, &state, &handle))
5305            .expect("draw");
5306        let buf = terminal.backend().buffer();
5307        // The word "wrap" must appear somewhere — it would be clipped if
5308        // the List widget was still used at 40 cols.
5309        let mut full = String::new();
5310        for y in 0..buf.area.height {
5311            for x in 0..buf.area.width {
5312                if let Some(cell) = buf.cell((x, y)) {
5313                    full.push_str(cell.symbol());
5314                }
5315            }
5316            full.push('\n');
5317        }
5318        assert!(
5319            full.contains("wrap"),
5320            "long line must wrap, not clip — text should be visible past col 40"
5321        );
5322    }
5323
5324    // ─── overlay tests ────────────────────────────────────────────────────
5325
5326    fn sample_overlay_items() -> Vec<OverlayListItem> {
5327        vec![
5328            OverlayListItem {
5329                title: "model-a".to_string(),
5330                subtitle: Some("first".to_string()),
5331                badge: Some("ready".to_string()),
5332                indent: 0,
5333                search_value: None,
5334                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
5335            },
5336            OverlayListItem {
5337                title: "model-b".to_string(),
5338                subtitle: None,
5339                badge: None,
5340                indent: 0,
5341                search_value: None,
5342                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
5343            },
5344            OverlayListItem {
5345                title: "model-c".to_string(),
5346                subtitle: None,
5347                badge: None,
5348                indent: 0,
5349                search_value: None,
5350                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
5351            },
5352        ]
5353    }
5354
5355    #[test]
5356    fn overlay_renders_title_and_items() {
5357        let mut state = RenderState::default();
5358        state.overlay = Some(OverlayState {
5359            title: "Select model".to_string(),
5360            lines: vec!["Pick one".to_string()],
5361            items: sample_overlay_items(),
5362            selected: 0,
5363            search: None,
5364            secure_input: None,
5365        });
5366        let rendered = render_frame_to_string(&state);
5367        assert!(
5368            rendered.contains("Select model"),
5369            "overlay title must render"
5370        );
5371        assert!(rendered.contains("model-a"), "first item must render");
5372        assert!(rendered.contains("model-b"), "second item must render");
5373        assert!(rendered.contains("model-c"), "third item must render");
5374        assert!(
5375            rendered.contains("Pick one"),
5376            "descriptive line must render"
5377        );
5378    }
5379
5380    #[test]
5381    fn overlay_search_filters_items() {
5382        let mut state = RenderState::default();
5383        state.overlay = Some(OverlayState {
5384            title: "Select".to_string(),
5385            lines: Vec::new(),
5386            items: sample_overlay_items(),
5387            selected: 0,
5388            search: Some(OverlaySearchState {
5389                label: "filter".to_string(),
5390                placeholder: Some("type".to_string()),
5391                value: "model-b".to_string(),
5392            }),
5393            secure_input: None,
5394        });
5395        let rendered = render_frame_to_string(&state);
5396        assert!(rendered.contains("model-b"), "matching item must render");
5397        assert!(
5398            !rendered.contains("model-a"),
5399            "non-matching item must not render (got: {})",
5400            rendered
5401        );
5402        assert!(
5403            !rendered.contains("model-c"),
5404            "non-matching item must not render"
5405        );
5406    }
5407
5408    #[test]
5409    fn overlay_keyboard_nav_moves_selection() {
5410        let mut state = RenderState::default();
5411        state.overlay = Some(OverlayState {
5412            title: "Select".to_string(),
5413            lines: Vec::new(),
5414            items: sample_overlay_items(),
5415            selected: 0,
5416            search: None,
5417            secure_input: None,
5418        });
5419        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5420        let (tx, mut _rx) = mpsc::unbounded_channel();
5421
5422        // Initial: index 0 selected.
5423        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5424
5425        // Down: index 1 selected.
5426        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5427        assert!(consumed, "Down must be consumed while overlay is open");
5428        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
5429
5430        // Down: index 2 selected.
5431        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5432        assert!(consumed);
5433        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5434
5435        // Down: wraps to index 0.
5436        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
5437        assert!(consumed);
5438        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
5439
5440        // Up: wraps to last (index 2).
5441        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
5442        assert!(consumed);
5443        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
5444
5445        // Enter: closes overlay and emits a Submission event.
5446        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5447        assert!(consumed);
5448        assert!(
5449            state_arc.lock().overlay.is_none(),
5450            "overlay must be cleared after Enter"
5451        );
5452        let evt = _rx.try_recv().expect("submit event must arrive");
5453        match evt {
5454            InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
5455            other => panic!("expected Submitted overlay event, got {other:?}"),
5456        }
5457    }
5458
5459    #[test]
5460    fn overlay_esc_closes_and_emits_cancelled() {
5461        let mut state = RenderState::default();
5462        state.overlay = Some(OverlayState {
5463            title: "Select".to_string(),
5464            lines: Vec::new(),
5465            items: sample_overlay_items(),
5466            selected: 0,
5467            search: None,
5468            secure_input: None,
5469        });
5470        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5471        let (tx, mut rx) = mpsc::unbounded_channel();
5472
5473        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
5474        assert!(consumed);
5475        assert!(
5476            state_arc.lock().overlay.is_none(),
5477            "overlay must be cleared after Esc"
5478        );
5479        let evt = rx.try_recv().expect("cancel event must arrive");
5480        assert!(
5481            matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
5482            "expected Cancelled overlay event"
5483        );
5484    }
5485
5486    #[test]
5487    fn overlay_enter_on_readonly_item_is_noop() {
5488        // A read-only item (selection: None — /tools, /mcp, the /settings
5489        // Model row) must NOT submit a synthetic selection or pollute the
5490        // prompt with "/overlay:N". Enter is a no-op: overlay stays open.
5491        let mut state = RenderState::default();
5492        state.overlay = Some(OverlayState {
5493            title: "Tools".to_string(),
5494            lines: Vec::new(),
5495            items: vec![OverlayListItem {
5496                title: "read".to_string(),
5497                subtitle: Some("Read a file".to_string()),
5498                badge: None,
5499                indent: 0,
5500                search_value: None,
5501                selection: None,
5502            }],
5503            selected: 0,
5504            search: None,
5505            secure_input: None,
5506        });
5507        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5508        let (tx, mut rx) = mpsc::unbounded_channel();
5509
5510        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5511        assert!(consumed, "Enter must be consumed even on read-only items");
5512        assert!(
5513            state_arc.lock().overlay.is_some(),
5514            "overlay must stay open when Enter hits a read-only item"
5515        );
5516        assert!(
5517            rx.try_recv().is_err(),
5518            "no overlay event must be emitted for a read-only Enter"
5519        );
5520    }
5521
5522    #[test]
5523    fn overlay_chars_route_to_search_field() {
5524        let mut state = RenderState::default();
5525        state.overlay = Some(OverlayState {
5526            title: "Select".to_string(),
5527            lines: Vec::new(),
5528            items: sample_overlay_items(),
5529            selected: 0,
5530            search: Some(OverlaySearchState {
5531                label: "filter".to_string(),
5532                placeholder: None,
5533                value: String::new(),
5534            }),
5535            secure_input: None,
5536        });
5537        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5538        let (tx, _rx) = mpsc::unbounded_channel();
5539
5540        handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
5541        handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
5542        handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
5543        let value = state_arc
5544            .lock()
5545            .overlay
5546            .as_ref()
5547            .unwrap()
5548            .search
5549            .as_ref()
5550            .unwrap()
5551            .value
5552            .clone();
5553        assert_eq!(value, "m", "Backspace should drop last char");
5554    }
5555
5556    #[test]
5557    fn overlay_key_no_op_when_no_overlay_open() {
5558        let state = RenderState::default();
5559        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5560        let (tx, _rx) = mpsc::unbounded_channel();
5561        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
5562        assert!(
5563            !consumed,
5564            "handle_overlay_key must return false when no overlay is open"
5565        );
5566    }
5567
5568    #[test]
5569    fn apply_command_show_overlay_populates_state() {
5570        use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
5571        let mut state = RenderState::default();
5572        let items = vec![
5573            InlineListItem {
5574                title: "alpha".to_string(),
5575                subtitle: None,
5576                badge: None,
5577                indent: 0,
5578                selection: None,
5579                search_value: None,
5580            },
5581            InlineListItem {
5582                title: "beta".to_string(),
5583                subtitle: None,
5584                badge: None,
5585                indent: 0,
5586                selection: None,
5587                search_value: None,
5588            },
5589        ];
5590        let request = OverlayRequest::List(ListOverlayRequest {
5591            title: "Pick".to_string(),
5592            lines: vec!["desc".to_string()],
5593            footer_hint: None,
5594            items,
5595            selected: None,
5596            search: None,
5597            hotkeys: Vec::new(),
5598        });
5599        let shutdown = apply_command(
5600            &mut state,
5601            InlineCommand::ShowOverlay {
5602                request: Box::new(request),
5603            },
5604        );
5605        assert!(!shutdown, "ShowOverlay must not request shutdown");
5606        let overlay = state.overlay.as_ref().expect("overlay must be Some");
5607        assert_eq!(overlay.title, "Pick");
5608        assert_eq!(overlay.items.len(), 2);
5609        assert_eq!(overlay.items[0].title, "alpha");
5610        assert_eq!(overlay.items[1].title, "beta");
5611        assert_eq!(overlay.lines.len(), 1);
5612
5613        // CloseOverlay clears it.
5614        apply_command(&mut state, InlineCommand::CloseOverlay);
5615        assert!(state.overlay.is_none(), "CloseOverlay must clear state");
5616    }
5617
5618    #[test]
5619    fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
5620        use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
5621        let request = OverlayRequest::Modal(ModalOverlayRequest {
5622            title: "API key".into(),
5623            lines: vec!["Paste your key".into()],
5624            secure_prompt: Some(SecurePromptConfig {
5625                label: "Key".into(),
5626                placeholder: Some("sk-...".into()),
5627                mask_input: true,
5628            }),
5629        });
5630        let state = materialize_overlay(request);
5631        let secure = state
5632            .secure_input
5633            .expect("secure_input must be Some when secure_prompt is Some");
5634        assert_eq!(secure.config.label, "Key");
5635        assert!(secure.config.mask_input);
5636        assert_eq!(secure.value, "");
5637        assert_eq!(secure.cursor, 0);
5638    }
5639
5640    #[test]
5641    fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
5642        use oxicode_vtui::tui::core::ModalOverlayRequest;
5643        let request = OverlayRequest::Modal(ModalOverlayRequest {
5644            title: "Confirm".into(),
5645            lines: vec!["y/n".into()],
5646            secure_prompt: None,
5647        });
5648        let state = materialize_overlay(request);
5649        assert!(
5650            state.secure_input.is_none(),
5651            "secure_input must be None when secure_prompt is None"
5652        );
5653    }
5654
5655    // ─── fold / grace tests ─────────────────────────────────────────────
5656
5657    fn three_block_transcript() -> Vec<TranscriptLine> {
5658        // Three distinct blocks: user(0), agent(1), user(2).
5659        vec![
5660            TranscriptLine {
5661                kind: InlineMessageKind::User,
5662                segments: vec![plain_segment("hi")],
5663                block_id: 0,
5664            },
5665            TranscriptLine {
5666                kind: InlineMessageKind::Agent,
5667                segments: vec![plain_segment("hello")],
5668                block_id: 1,
5669            },
5670            TranscriptLine {
5671                kind: InlineMessageKind::Agent,
5672                segments: vec![plain_segment("world")],
5673                block_id: 1,
5674            },
5675            TranscriptLine {
5676                kind: InlineMessageKind::User,
5677                segments: vec![plain_segment("bye")],
5678                block_id: 2,
5679            },
5680        ]
5681    }
5682
5683    #[test]
5684    fn default_block_mode_is_truncated() {
5685        let state = RenderState::default();
5686        assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
5687        assert!(state.block_display.is_empty(), "default needs no map entry");
5688    }
5689
5690    #[test]
5691    fn fold_all_collapses_every_block() {
5692        let mut state = RenderState::default();
5693        state.transcript = three_block_transcript();
5694        state.fold_all();
5695        assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
5696        assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
5697        assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
5698        assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
5699    }
5700
5701    #[test]
5702    fn expand_all_after_fold_all_shows_expanded() {
5703        let mut state = RenderState::default();
5704        state.transcript = three_block_transcript();
5705        state.fold_all();
5706        state.expand_all();
5707        assert_eq!(state.block_display.len(), 3);
5708        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
5709        assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
5710    }
5711
5712    #[test]
5713    fn truncate_all_resets_to_default() {
5714        let mut state = RenderState::default();
5715        state.transcript = three_block_transcript();
5716        state.fold_all();
5717        state.truncate_all();
5718        assert!(state.block_display.is_empty());
5719        assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
5720    }
5721
5722    #[test]
5723    fn fold_all_on_empty_transcript_is_noop() {
5724        let mut state = RenderState::default();
5725        state.fold_all();
5726        assert!(state.block_display.is_empty());
5727    }
5728
5729    #[test]
5730    fn cycle_block_advances_through_three_states() {
5731        let mut state = RenderState::default();
5732        state.transcript = three_block_transcript();
5733        state.scroll_offset = 0; // view on block 0
5734        // Truncated (default) → Expanded
5735        state.cycle_block_at_view();
5736        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
5737        // Expanded → Collapsed
5738        state.cycle_block_at_view();
5739        assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
5740        // Collapsed → Truncated (default — removed from the map)
5741        state.cycle_block_at_view();
5742        assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
5743        assert!(!state.block_display.contains_key(&0));
5744    }
5745
5746    #[test]
5747    fn cancel_grace_field_defaults_none() {
5748        let state = RenderState::default();
5749        assert!(
5750            state.cancel_grace_until.is_none(),
5751            "cancel_grace_until must default to None"
5752        );
5753    }
5754
5755    #[test]
5756    fn cancel_routes_to_interrupt_when_streaming() {
5757        assert_eq!(
5758            route_cancel(true),
5759            CancelRoute::Interrupt,
5760            "Esc while streaming must route through the interrupt path"
5761        );
5762    }
5763
5764    #[test]
5765    fn cancel_routes_to_exit_when_idle() {
5766        assert_eq!(
5767            route_cancel(false),
5768            CancelRoute::Exit,
5769            "Esc while idle must exit immediately (one-press quit)"
5770        );
5771    }
5772    #[test]
5773    fn scrollbar_paints_thumb_when_content_overflows() {
5774        // 40 distinct blocks in a 24-row viewport must produce a scrollbar
5775        // thumb (█) in the rendered frame.
5776        let mut state = RenderState::default();
5777        for i in 0..40u32 {
5778            state.transcript.push(TranscriptLine {
5779                kind: InlineMessageKind::Agent,
5780                segments: vec![plain_segment(format!("line {i}"))],
5781                block_id: i as usize,
5782            });
5783        }
5784        let rendered = render_frame_to_string(&state);
5785        assert!(
5786            rendered.contains('\u{2588}'),
5787            "scrollbar thumb (█) must render when transcript overflows the viewport"
5788        );
5789    }
5790
5791    #[test]
5792    fn scrollbar_absent_when_content_fits_viewport() {
5793        // A single short line fits without overflow — no thumb character.
5794        let mut state = RenderState::default();
5795        state.transcript.push(TranscriptLine {
5796            kind: InlineMessageKind::Agent,
5797            segments: vec![plain_segment("hi")],
5798            block_id: 0,
5799        });
5800        let rendered = render_frame_to_string(&state);
5801        assert!(
5802            !rendered.contains('\u{2588}'),
5803            "no scrollbar thumb when content fits the viewport"
5804        );
5805    }
5806
5807    // ─── confirmation modal tests ───────────────────────────────────────
5808
5809    #[test]
5810    fn confirmation_modal_renders_title() {
5811        let mut state = RenderState::default();
5812        state.confirmation = Some(quit_confirmation());
5813        let rendered = render_frame_to_string(&state);
5814        assert!(
5815            rendered.contains("Quit oxicode?"),
5816            "confirmation title must render"
5817        );
5818    }
5819
5820    #[test]
5821    fn confirmation_yes_sends_exit_and_closes() {
5822        let mut state = RenderState::default();
5823        state.confirmation = Some(quit_confirmation());
5824        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5825        let (tx, mut rx) = mpsc::unbounded_channel();
5826        handle_confirmation_key(&state_arc, &tx, KeyCode::Char('y'));
5827        assert!(
5828            state_arc.lock().confirmation.is_none(),
5829            "yes must close the modal"
5830        );
5831        let ev = rx.try_recv().expect("yes must send an event");
5832        assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
5833    }
5834
5835    #[test]
5836    fn confirmation_no_closes_without_event() {
5837        let mut state = RenderState::default();
5838        state.confirmation = Some(quit_confirmation());
5839        let state_arc = Arc::new(parking_lot::Mutex::new(state));
5840        let (tx, mut rx) = mpsc::unbounded_channel();
5841        handle_confirmation_key(&state_arc, &tx, KeyCode::Char('n'));
5842        assert!(
5843            state_arc.lock().confirmation.is_none(),
5844            "no must close the modal"
5845        );
5846        assert!(rx.try_recv().is_err(), "no must not send an event");
5847    }
5848    // ─── ephemeral tip tests ───────────────────────────────────────────
5849
5850    #[test]
5851    fn tip_banner_renders_when_active() {
5852        let mut state = RenderState::default();
5853        let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
5854        state.tip = Some(EphemeralTip {
5855            text: "hello-tip-marker".to_string(),
5856            born_tick: now_tick,
5857            ttl_ticks: 100,
5858            key: "test",
5859            ambient: false,
5860        });
5861        let rendered = render_frame_to_string(&state);
5862        assert!(
5863            rendered.contains("hello-tip-marker"),
5864            "active tip must render above the composer"
5865        );
5866    }
5867
5868    #[test]
5869    fn tip_visible_within_ttl_window() {
5870        let tip = EphemeralTip {
5871            text: "x".to_string(),
5872            born_tick: 10,
5873            ttl_ticks: 5,
5874            key: "test",
5875            ambient: false,
5876        };
5877        assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
5878        assert!(
5879            !tip_is_visible(&tip, 15),
5880            "at TTL boundary (born + ttl) must expire"
5881        );
5882        assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
5883    }
5884
5885    // ─── sticky header tests ───────────────────────────────────────────
5886
5887    #[test]
5888    fn sticky_header_pins_block_head_when_scrolled_into_body() {
5889        // One big block (40 same-block lines); scroll the viewport into the
5890        // body. The sticky header must pin the block's first line at the top.
5891        let mut state = RenderState::default();
5892        for i in 0..40u32 {
5893            state.transcript.push(TranscriptLine {
5894                kind: InlineMessageKind::Agent,
5895                segments: vec![plain_segment(format!("body-line-{i:02}"))],
5896                block_id: 0,
5897            });
5898        }
5899        state.scroll_offset = 10;
5900        let rendered = render_frame_to_string(&state);
5901        assert!(
5902            rendered.contains("body-line-00"),
5903            "sticky header must pin the block head when scrolled into the body"
5904        );
5905    }
5906
5907    #[test]
5908    fn sticky_header_absent_when_viewport_at_block_head() {
5909        // Viewport top is the block head itself — no sticky pin needed.
5910        let mut state = RenderState::default();
5911        for i in 0..40u32 {
5912            state.transcript.push(TranscriptLine {
5913                kind: InlineMessageKind::Agent,
5914                segments: vec![plain_segment(format!("head-line-{i:02}"))],
5915                block_id: 0,
5916            });
5917        }
5918        state.scroll_offset = 0;
5919        let rendered = render_frame_to_string(&state);
5920        // head-line-00 is the viewport top already; it renders exactly once
5921        // (no separate sticky row). Just assert it is present.
5922        assert!(rendered.contains("head-line-00"));
5923    }
5924
5925    // ─── prompt queue tests ─────────────────────────────────────────────
5926
5927    #[test]
5928    fn turn_end_drains_queue_head() {
5929        let mut state = RenderState::default();
5930        state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
5931        state.drain_queue_head();
5932        assert_eq!(
5933            state.queued_inputs.len(),
5934            1,
5935            "drain_queue_head must drop the head (now running)"
5936        );
5937        assert_eq!(state.queued_inputs[0], "queued-2");
5938    }
5939
5940    // ─── render_frame integration ──────────────────────────────────────
5941
5942    #[test]
5943    fn render_frame_paints_transcript_content() {
5944        // Guard against render_frame losing its render_transcript call
5945        // (which only a content assertion through render_frame can catch —
5946        // render_transcript unit tests bypass render_frame entirely).
5947        let mut state = RenderState::default();
5948        state.transcript.push(TranscriptLine {
5949            kind: InlineMessageKind::Agent,
5950            segments: vec![plain_segment("frame-content-marker-xyz")],
5951            block_id: 0,
5952        });
5953        let rendered = render_frame_to_string(&state);
5954        assert!(
5955            rendered.contains("frame-content-marker-xyz"),
5956            "render_frame must paint transcript content"
5957        );
5958    }
5959
5960    #[test]
5961    fn file_search_dropdown_renders_results() {
5962        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5963        let mut state = RenderState::default();
5964        state.input_enabled = true;
5965        state.file_search = Some(FileSearchState {
5966            query: "main".into(),
5967            at_offset: 0,
5968            hidden_mode: false,
5969            results: vec![
5970                FileSearchResult {
5971                    path: "src/main.rs".into(),
5972                    score: 100,
5973                },
5974                FileSearchResult {
5975                    path: "tests/main.rs".into(),
5976                    score: 50,
5977                },
5978            ],
5979            selected: 0,
5980            index: vec![],
5981            line_mode: false,
5982        });
5983        let rendered = render_frame_to_string(&state);
5984        assert!(rendered.contains("Files"), "dropdown title must render");
5985        assert!(
5986            rendered.contains("src/main.rs"),
5987            "dropdown must show file paths"
5988        );
5989    }
5990
5991    #[test]
5992    fn file_search_dropdown_hidden_mode_title() {
5993        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
5994        let mut state = RenderState::default();
5995        state.input_enabled = true;
5996        state.file_search = Some(FileSearchState {
5997            query: "".into(),
5998            at_offset: 0,
5999            hidden_mode: true,
6000            results: vec![FileSearchResult {
6001                path: ".env".into(),
6002                score: 0,
6003            }],
6004            selected: 0,
6005            index: vec![],
6006            line_mode: false,
6007        });
6008        let rendered = render_frame_to_string(&state);
6009        assert!(
6010            rendered.contains("hidden"),
6011            "hidden mode must be indicated in title"
6012        );
6013    }
6014
6015    #[test]
6016    fn file_search_and_composer_render_together() {
6017        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
6018        let mut state = RenderState::default();
6019        state.input_enabled = true;
6020        state.prompt_prefix = "> ".into();
6021        state.input_buffer = "@main".into();
6022        state.input_cursor = 5;
6023        state.file_search = Some(FileSearchState {
6024            query: "main".into(),
6025            at_offset: 0,
6026            hidden_mode: false,
6027            results: vec![FileSearchResult {
6028                path: "src/main.rs".into(),
6029                score: 100,
6030            }],
6031            selected: 0,
6032            index: vec![],
6033            line_mode: false,
6034        });
6035        let rendered = render_frame_to_string(&state);
6036        // Both the composer text and the dropdown must appear.
6037        assert!(rendered.contains('>'), "composer must still render");
6038        assert!(
6039            rendered.contains("src/main.rs"),
6040            "dropdown must render alongside composer"
6041        );
6042    }
6043
6044    #[test]
6045    fn flatten_todo_items_preserves_order_and_status() {
6046        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
6047        let phases = vec![
6048            TodoPhase {
6049                name: "A".into(),
6050                tasks: vec![
6051                    TodoItem {
6052                        content: "write code".into(),
6053                        status: TodoStatus::InProgress,
6054                        notes: None,
6055                        block_reason: None,
6056                    },
6057                    TodoItem {
6058                        content: "write tests".into(),
6059                        status: TodoStatus::Pending,
6060                        notes: None,
6061                        block_reason: None,
6062                    },
6063                ],
6064            },
6065            TodoPhase {
6066                name: "B".into(),
6067                tasks: vec![TodoItem {
6068                    content: "waiting on review".into(),
6069                    status: TodoStatus::Blocked,
6070                    notes: None,
6071                    block_reason: None,
6072                }],
6073            },
6074        ];
6075        let flat = flatten_todo_items(&phases);
6076        assert_eq!(flat.len(), 3);
6077        assert_eq!(flat[0], ("write code".to_string(), TodoStatus::InProgress));
6078        assert_eq!(flat[1], ("write tests".to_string(), TodoStatus::Pending));
6079        assert_eq!(
6080            flat[2],
6081            ("waiting on review".to_string(), TodoStatus::Blocked)
6082        );
6083    }
6084
6085    #[test]
6086    fn todo_pane_renders_when_items_present() {
6087        // The sticky pane is populated from the live provider in the event
6088        // loop; here we seed it directly to assert the pane paints task text.
6089        let mut state = RenderState::default();
6090        state.todo_items = vec![
6091            ("active task".to_string(), TodoStatus::InProgress),
6092            ("open task".to_string(), TodoStatus::Pending),
6093        ];
6094        let rendered = render_frame_to_string(&state);
6095        assert!(
6096            rendered.contains("active task"),
6097            "in-progress task must render"
6098        );
6099        assert!(rendered.contains("open task"), "pending task must render");
6100        // The in-progress glyph (▶) must distinguish the active task.
6101        assert!(
6102            rendered.contains('\u{25B6}'),
6103            "in-progress glyph must render"
6104        );
6105    }
6106
6107    #[test]
6108    fn todo_pane_hidden_when_empty() {
6109        let state = RenderState::default();
6110        let rendered = render_frame_to_string(&state);
6111        // No todo content should leak when the list is empty.
6112        assert!(!rendered.contains('\u{2611}'), "no checkmark when empty");
6113    }
6114
6115    #[test]
6116    fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
6117        use ratatui::{Terminal, backend::TestBackend};
6118        let backend = TestBackend::new(80, 24);
6119        let mut terminal = Terminal::new(backend).unwrap();
6120        let overlay = OverlayState {
6121            title: "OpenAI key".into(),
6122            lines: vec!["Paste your API key".into()],
6123            items: Vec::new(),
6124            selected: 0,
6125            search: None,
6126            secure_input: Some(OverlaySecureInput {
6127                config: SecurePromptConfig {
6128                    label: "Key".into(),
6129                    placeholder: Some("sk-...".into()),
6130                    mask_input: true,
6131                },
6132                value: "sk-abc".into(),
6133                cursor: 6,
6134            }),
6135        };
6136        terminal
6137            .draw(|f| render_overlay(f, f.area(), &overlay))
6138            .unwrap();
6139        let buf = terminal.backend().buffer().clone();
6140        // Mask must show 6 asterisks, never the value.
6141        let text: String = buf
6142            .content()
6143            .iter()
6144            .map(|c| c.symbol())
6145            .collect::<Vec<_>>()
6146            .join("");
6147        assert!(text.contains("Key:"));
6148        assert!(text.contains("******"));
6149        assert!(!text.contains("sk-abc"));
6150    }
6151
6152    #[test]
6153    fn render_overlay_secure_input_placeholder_when_empty() {
6154        use ratatui::{Terminal, backend::TestBackend};
6155        let backend = TestBackend::new(80, 24);
6156        let mut terminal = Terminal::new(backend).unwrap();
6157        let overlay = OverlayState {
6158            title: "OpenAI key".into(),
6159            lines: vec!["Paste your API key".into()],
6160            items: Vec::new(),
6161            selected: 0,
6162            search: None,
6163            secure_input: Some(OverlaySecureInput {
6164                config: SecurePromptConfig {
6165                    label: "Key".into(),
6166                    placeholder: Some("sk-...".into()),
6167                    mask_input: true,
6168                },
6169                value: String::new(),
6170                cursor: 0,
6171            }),
6172        };
6173        terminal
6174            .draw(|f| render_overlay(f, f.area(), &overlay))
6175            .unwrap();
6176        let buf = terminal.backend().buffer().clone();
6177        let text: String = buf
6178            .content()
6179            .iter()
6180            .map(|c| c.symbol())
6181            .collect::<Vec<_>>()
6182            .join("");
6183        assert!(text.contains("sk-..."));
6184    }
6185}
6186
6187#[cfg(test)]
6188mod secure_input_tests {
6189    use super::*;
6190    use oxicode_vtui::tui::core::OverlaySubmission;
6191
6192    #[test]
6193    fn overlay_submission_secure_input_is_routed_to_host() {
6194        // Smoke: serialization round-trip — the variant must be reachable
6195        // through the protocol so the input thread can dispatch it.
6196        let _ = OverlaySubmission::SecureInput("sk-test".into());
6197        let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
6198        assert!(serialized.contains("SecureInput"));
6199    }
6200
6201    #[test]
6202    fn providers_action_matrix_branches_correctly() {
6203        // Pin the (has_key, oauth_capable) → Vec<AuthAction> matrix
6204        // exactly. Refactors MUST keep this contract: the order of
6205        // returned actions drives the visible action menu order.
6206        assert_eq!(
6207            next_provider_actions(true, true),
6208            vec![
6209                AuthAction::SetApiKey,
6210                AuthAction::StartOAuth,
6211                AuthAction::RemoveKey,
6212            ],
6213            "has key + oauth-capable: replace, oauth, remove"
6214        );
6215        assert_eq!(
6216            next_provider_actions(true, false),
6217            vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
6218            "has key, key-only provider: replace, remove"
6219        );
6220        assert_eq!(
6221            next_provider_actions(false, true),
6222            vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
6223            "no key + oauth-capable: set key, oauth"
6224        );
6225        assert_eq!(
6226            next_provider_actions(false, false),
6227            vec![AuthAction::SetApiKey],
6228            "no key + key-only provider: set key only"
6229        );
6230    }
6231
6232    #[test]
6233    fn insert_char_at_middle() {
6234        let (s, c) = insert_char_into_secure_input("abcd", 2, 'X');
6235        assert_eq!(s, "abXcd");
6236        assert_eq!(c, 3);
6237    }
6238
6239    #[test]
6240    fn backspace_at_start_is_noop() {
6241        let (s, c) = backspace_secure_input("abc", 0);
6242        assert_eq!(s, "abc");
6243        assert_eq!(c, 0);
6244    }
6245
6246    #[test]
6247    fn backspace_at_middle() {
6248        let (s, c) = backspace_secure_input("abcd", 2);
6249        assert_eq!(s, "acd");
6250        assert_eq!(c, 1);
6251    }
6252
6253    #[test]
6254    fn paste_strips_trailing_newline_and_drops_non_ascii() {
6255        let (s, c) = insert_paste_into_secure_input("ab", 2, "sk-xyz\nABC\u{1F600}");
6256        assert_eq!(s, "absk-xyzABC");
6257        assert_eq!(c, 11);
6258    }
6259
6260    #[test]
6261    fn insert_at_end_appends() {
6262        let (s, c) = insert_char_into_secure_input("hello", 5, '!');
6263        assert_eq!(s, "hello!");
6264        assert_eq!(c, 6);
6265    }
6266}
6267
6268// ═════════════════════════════════════════════════════════════════════════
6269// `/providers` overlay chaining — regression for the bug where the
6270// `OverlayEvent::Submitted` arm closed the current overlay
6271// unconditionally, even when the handler opened a fresh overlay (action
6272// menu, secure prompt). The cmd channel processes `ShowOverlay` and
6273// `CloseOverlay` in submit order, so a `CloseOverlay` enqueued right
6274// after the `ShowOverlay` from the action menu won — leaving the user
6275// with nothing visible on Enter.
6276// ═════════════════════════════════════════════════════════════════════════
6277
6278#[cfg(test)]
6279mod provider_overlay_tests {
6280    use super::*;
6281    use crate::app::agent_session::{AgentSession, AgentSessionHandle};
6282    use crate::store::session::SessionManager;
6283    use crate::store::settings::Settings;
6284    use oxicode_agent::{Agent, AgentConfig};
6285    use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
6286    use oxicode_vtui::tui::core::OverlayEvent;
6287    use std::pin::Pin;
6288    use std::sync::Arc;
6289    use std::task::{Context as TaskContext, Poll};
6290
6291    /// Minimal mock provider — produces an empty stream so `AgentSession`
6292    /// can construct (the `ProviderRow` dispatch never streams).
6293    struct EmptyStream;
6294    impl futures::Stream for EmptyStream {
6295        type Item = ProviderEvent;
6296        fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
6297            Poll::Ready(None)
6298        }
6299    }
6300
6301    struct StubProvider;
6302    impl Provider for StubProvider {
6303        fn stream<'a>(
6304            &'a self,
6305            _model: &'a oxicode_sdk::Model,
6306            _context: &'a oxicode_sdk::Context,
6307            _options: Option<oxicode_sdk::StreamOptions>,
6308        ) -> Pin<
6309            Box<
6310                dyn Future<
6311                        Output = Result<
6312                            Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
6313                            ProviderError,
6314                        >,
6315                    > + Send
6316                    + 'a,
6317            >,
6318        > {
6319            Box::pin(async move {
6320                Ok::<_, ProviderError>(Box::pin(EmptyStream)
6321                    as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
6322            })
6323        }
6324    }
6325
6326    fn make_session() -> AgentSessionHandle {
6327        let provider = Arc::new(StubProvider);
6328        let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
6329        let agent = Arc::new(Agent::new(
6330            provider,
6331            config,
6332            Arc::new(oxicode_agent::ToolRegistry::new()),
6333        ));
6334        let settings = Settings::default();
6335        let session_manager = SessionManager::in_memory("/tmp/test_providers");
6336        let session = AgentSession::new(
6337            agent,
6338            settings,
6339            session_manager,
6340            "/tmp/test_providers".to_string(),
6341            crate::SessionState::default(),
6342        );
6343        session.clone_handle()
6344    }
6345
6346    /// `InlineCommand` does not implement `Debug`, so summarise the channel
6347    /// contents by command variant for assertion failure messages.
6348    fn summarise(cmds: &[InlineCommand]) -> String {
6349        let mut show = 0;
6350        let mut close = 0;
6351        let mut other = 0;
6352        for c in cmds {
6353            match c {
6354                InlineCommand::ShowOverlay { .. } => show += 1,
6355                InlineCommand::CloseOverlay => close += 1,
6356                _ => other += 1,
6357            }
6358        }
6359        format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
6360    }
6361
6362    /// Regression: `/providers` row selection for an OAuth-capable
6363    /// provider with no stored key triggers the multi-action chain
6364    /// `[SetApiKey, StartOAuth]` → `handle.show_list_modal` opens the
6365    /// action menu. The bug closed that menu instantly. The fix tracks
6366    /// whether the handler opened a new overlay and only emits the
6367    /// trailing `close_overlay()` when nothing was opened.
6368    #[test]
6369    fn provider_row_opens_action_menu_without_close() {
6370        // openai is OAuth-capable (per `product-meta.toml`), no key in
6371        // the env / storage, so the action matrix returns the
6372        // multi-action list.
6373        let session = make_session();
6374        let mut state = RenderState::default();
6375        state.overlay_providers = vec!["openai".to_string()];
6376        state.overlay = Some(OverlayState {
6377            title: "Providers".to_string(),
6378            lines: Vec::new(),
6379            items: Vec::new(),
6380            selected: 0,
6381            search: None,
6382            secure_input: None,
6383        });
6384
6385        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6386        let handle = InlineHandle::new_for_tests(cmd_tx);
6387        let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6388
6389        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6390            InlineListSelection::ProviderRow(0),
6391        )));
6392        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6393
6394        let cmds: Vec<InlineCommand> = {
6395            let mut out = Vec::new();
6396            while let Ok(cmd) = cmd_rx.try_recv() {
6397                out.push(cmd);
6398            }
6399            out
6400        };
6401        let show_count = cmds
6402            .iter()
6403            .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6404            .count();
6405        assert_eq!(
6406            show_count,
6407            1,
6408            "submitting a provider row must ShowOverlay exactly once (commands: {})",
6409            summarise(&cmds)
6410        );
6411
6412        // The bug: a `CloseOverlay` followed the `ShowOverlay` on the
6413        // cmd channel and won the order-of-application race. After the
6414        // fix, no `CloseOverlay` may follow the `ShowOverlay`.
6415        let show_idx = cmds
6416            .iter()
6417            .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6418            .expect("ShowOverlay must be present");
6419        let trailing = &cmds[show_idx + 1..];
6420        assert!(
6421            !trailing
6422                .iter()
6423                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6424            "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
6425            summarise(&cmds)
6426        );
6427
6428        // Stale-state cleanup must still run so future `/providers`
6429        // does not see stale indices.
6430        assert!(
6431            state.overlay_providers.is_empty(),
6432            "overlay_providers must be cleared after dispatch (got {:?})",
6433            state.overlay_providers
6434        );
6435    }
6436
6437    /// Regression: `/providers` row selection for a key-only provider
6438    /// (no OAuth spec) with no stored key triggers the single-action
6439    /// chain `[SetApiKey]` → `handle_auth_action` opens the secure
6440    /// prompt modal. The bug closed that modal instantly. The fix
6441    /// propagates the `opened_new_overlay` flag through `|=` so the
6442    /// secure prompt survives.
6443    #[test]
6444    fn provider_row_set_api_key_opens_secure_prompt_without_close() {
6445        // cerebras is key-only (no OAuth spec in `product-meta.toml`).
6446        let session = make_session();
6447        let mut state = RenderState::default();
6448        state.overlay_providers = vec!["cerebras".to_string()];
6449        state.overlay = Some(OverlayState {
6450            title: "Providers".to_string(),
6451            lines: Vec::new(),
6452            items: Vec::new(),
6453            selected: 0,
6454            search: None,
6455            secure_input: None,
6456        });
6457
6458        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6459        let handle = InlineHandle::new_for_tests(cmd_tx);
6460        let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6461
6462        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6463            InlineListSelection::ProviderRow(0),
6464        )));
6465        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6466
6467        let cmds: Vec<InlineCommand> = {
6468            let mut out = Vec::new();
6469            while let Ok(cmd) = cmd_rx.try_recv() {
6470                out.push(cmd);
6471            }
6472            out
6473        };
6474        let show_count = cmds
6475            .iter()
6476            .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6477            .count();
6478        assert_eq!(
6479            show_count,
6480            1,
6481            "submitting a provider row must ShowOverlay exactly once (commands: {})",
6482            summarise(&cmds)
6483        );
6484
6485        let show_idx = cmds
6486            .iter()
6487            .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
6488            .expect("ShowOverlay must be present");
6489        let trailing = &cmds[show_idx + 1..];
6490        assert!(
6491            !trailing
6492                .iter()
6493                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6494            "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
6495            summarise(&cmds)
6496        );
6497
6498        // The secure prompt origin must be stashed so a subsequent
6499        // `SecureInput` submission routes the key to the right provider
6500        // and emits a contextual follow-up message.
6501        assert_eq!(
6502            state.secure_input_origin,
6503            Some(SecureInputOrigin::SetKey {
6504                provider: "cerebras".to_string(),
6505            }),
6506            "secure_input_origin must be stashed by SetApiKey"
6507        );
6508    }
6509
6510    /// Catalog model selection (the working baseline) must remain
6511    /// closing — pinning the behavior so the conditional close does
6512    /// not regress the other `Submitted` branches.
6513    #[test]
6514    fn catalog_model_selection_still_closes_overlay() {
6515        let session = make_session();
6516        let mut state = RenderState::default();
6517        state.overlay_catalog_models = vec![(
6518            "anthropic".to_string(),
6519            "claude-sonnet-4-20250514".to_string(),
6520        )];
6521        state.overlay = Some(OverlayState {
6522            title: "Models".to_string(),
6523            lines: Vec::new(),
6524            items: Vec::new(),
6525            selected: 0,
6526            search: None,
6527            secure_input: None,
6528        });
6529
6530        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6531        let handle = InlineHandle::new_for_tests(cmd_tx);
6532        let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6533
6534        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6535            InlineListSelection::CatalogModel(0),
6536        )));
6537        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6538
6539        let cmds: Vec<InlineCommand> = {
6540            let mut out = Vec::new();
6541            while let Ok(cmd) = cmd_rx.try_recv() {
6542                out.push(cmd);
6543            }
6544            out
6545        };
6546        assert!(
6547            cmds.iter()
6548                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
6549            "catalog model selection must close the overlay (commands: {})",
6550            summarise(&cmds)
6551        );
6552    }
6553
6554    /// `add_custom_provider` chains into the secure prompt via
6555    /// `open_secure_prompt` with `SecureInputOrigin::NewlyAdded`. The
6556    /// provider must be reachable from either variant so the
6557    /// `OverlaySubmission::SecureInput` consumer routes the key to the
6558    /// right slot without a per-variant branch.
6559    fn secure_input_origin_carries_provider_independently_of_variant() {
6560        let set = SecureInputOrigin::SetKey {
6561            provider: "openai".to_string(),
6562        };
6563        let added = SecureInputOrigin::NewlyAdded {
6564            provider: "minimax".to_string(),
6565        };
6566        // `provider` must be reachable regardless of variant so the
6567        // `OverlaySubmission::SecureInput` consumer can route the key
6568        // without a per-variant branch.
6569        assert_eq!(
6570            match &set {
6571                SecureInputOrigin::SetKey { provider }
6572                | SecureInputOrigin::NewlyAdded { provider } => provider,
6573            },
6574            "openai"
6575        );
6576        assert_eq!(
6577            match &added {
6578                SecureInputOrigin::SetKey { provider }
6579                | SecureInputOrigin::NewlyAdded { provider } => provider,
6580            },
6581            "minimax"
6582        );
6583        // Variants are distinct (so the follow-up message can branch).
6584        assert_ne!(set, added);
6585    }
6586
6587    /// Regression: the `/sessions` picker arm previously set
6588    /// `state.pending_resume` without the `is_streaming()` gate that the
6589    /// direct `/sessions <id>` path and `/handoff` both use. A mid-stream
6590    /// pick + Enter fired the drain, which calls `resume_from_file` →
6591    /// `AgentSession::new` → `agent.update_state` on the shared
6592    /// `Arc<Agent>`, clobbering the in-flight conversation's message
6593    /// history. The picker now refuses with the same error wording as
6594    /// the direct path and never sets `pending_resume` while streaming.
6595    #[test]
6596    fn session_picker_resume_refused_while_streaming() {
6597        let session = make_session();
6598        // Flip the streaming flag BEFORE dispatch so the gate fires.
6599        // `streaming_flag()` returns an `Arc<AtomicBool>` shared with the
6600        // worker thread, so the production code observes the new value.
6601        session
6602            .streaming_flag()
6603            .store(true, std::sync::atomic::Ordering::SeqCst);
6604
6605        let mut state = RenderState::default();
6606        // Sanity: no resume queued yet.
6607        assert!(
6608            state.pending_resume.is_none(),
6609            "precondition: pending_resume must start None"
6610        );
6611
6612        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
6613        let handle = InlineHandle::new_for_tests(cmd_tx);
6614        let (prompt_tx, _prompt_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
6615
6616        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
6617            InlineListSelection::Session("some-id".to_string()),
6618        )));
6619        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_tx, evt);
6620
6621        // The gate must have refused: pending_resume stays None.
6622        assert!(
6623            state.pending_resume.is_none(),
6624            "streaming session must not enqueue pending_resume (got {:?})",
6625            state.pending_resume
6626        );
6627
6628        // Drain the handle's cmd channel and inspect appended lines.
6629        let mut cmds: Vec<InlineCommand> = Vec::new();
6630        while let Ok(cmd) = cmd_rx.try_recv() {
6631            cmds.push(cmd);
6632        }
6633        let mut found_error = false;
6634        let mut error_text = String::new();
6635        for cmd in &cmds {
6636            if let InlineCommand::AppendLine { kind, segments } = cmd
6637                && matches!(kind, InlineMessageKind::Error)
6638            {
6639                error_text = segments
6640                    .iter()
6641                    .map(|s| s.text.as_str())
6642                    .collect::<Vec<_>>()
6643                    .join("");
6644                found_error = true;
6645            }
6646        }
6647        assert!(
6648            found_error,
6649            "expected an error AppendLine (commands: {})",
6650            summarise(&cmds)
6651        );
6652        assert!(
6653            error_text.contains("Cannot resume while agent is running"),
6654            "error text must match the direct-path wording (got {error_text:?})"
6655        );
6656
6657        // Cleanup: reset streaming so the flag doesn't leak across tests
6658        // in the same process.
6659        session
6660            .streaming_flag()
6661            .store(false, std::sync::atomic::Ordering::SeqCst);
6662    }
6663}