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