Skip to main content

oxicode/tui_vt/
main_loop.rs

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