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