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