Skip to main content

oxicode/tui_vt/
main_loop.rs

1#![allow(
2    clippy::field_reassign_with_default,
3    clippy::let_and_return,
4    clippy::borrow_interior_mutable_const,
5    clippy::derivable_impls
6)]
7//! TUI main event loop — connects oxicode's `AgentSession` to vtcode-ui's
8//! `InlineSession` protocol and a ratatui rendering backend.
9
10use std::collections::VecDeque;
11use std::io::{self, Stdout, Write};
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::Result;
17use crossterm::{
18    cursor::{Hide, Show},
19    event::{
20        self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
21        KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
22        PushKeyboardEnhancementFlags,
23    },
24    execute,
25    terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate, disable_raw_mode, enable_raw_mode},
26};
27use oxicode_agent::AgentEvent;
28use oxicode_agent::config::Mode;
29use oxicode_agent::tools::TodoStateProvider;
30use oxicode_agent::tools::todo::{TodoItem, TodoPhase, TodoStatus};
31use oxicode_vtui::theme::{ThemeStyles, active_styles};
32use oxicode_vtui::tui::core::{
33    AuthAction, InlineCommand, InlineEvent, InlineHandle, InlineHeaderContext,
34    InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineListItem, InlineListSelection,
35    InlineMessageKind, InlineSegment, InlineTextStyle, OverlayRequest, OverlaySubmission,
36    SecurePromptConfig,
37};
38use ratatui::{
39    Frame, Terminal, TerminalOptions, Viewport,
40    backend::CrosstermBackend,
41    buffer::Buffer,
42    layout::{Alignment, Margin, Position, Rect},
43    style::{Color, Modifier, Style},
44    text::{Line, Span},
45    widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Wrap},
46};
47use unicode_width::UnicodeWidthStr;
48
49use crate::App;
50use crate::app::agent_hub_registry::HubEntry;
51use crate::app::agent_session::SessionEvent;
52use crate::tui_vt::keymap::{GlobalAction, KeyCombo, Keymap};
53use crate::tui_vt::settings_defs::{
54    SETTING_DEFS, SettingKey, SettingWidget, SettingsMapRow, SettingsTab, defs_for_tab,
55    get_display_value,
56};
57use crate::tui_vt::slash::file_commands::FileCommand;
58use crate::tui_vt::slash::registry::{
59    SlashCtx, SlashOutcome, SlashRegistry, settings_overlay_items,
60};
61use oxicode_vtui::presentation::{
62    BlockAlloc, BlockDisplayMode, TranscriptLine, VisibleItem, allocate_rows, visible_items,
63};
64use oxicode_vtui::tui::ui::clamp_segments_to_width;
65
66use oxicode_textarea::{EditBuffer, ElementKind, TextArea, TextAreaState};
67
68use ratatui::widgets::FrameExt;
69/// Host-defined [`ElementKind`] tag for the secure-prompt overlay's masked
70/// element. The textarea treats the kind as opaque; this constant exists so
71/// every render of a masked overlay shares one stable id (handy for tests,
72/// logs, and future per-element metadata lookups).
73const MASKED_ELEMENT_KIND: ElementKind = ElementKind(1);
74// Terminal lifecycle (RAII)
75// ─────────────────────────────────────────────────────────────────────────
76
77/// Terminal wrapper with deterministic enter / exit / Drop semantics.
78///
79/// Each cleanup step in `exit` is independent — a failure in one stage
80/// (e.g. `PopKeyboardEnhancementFlags`) MUST NOT prevent later stages
81/// (`disable_raw_mode`) from running, or the user's terminal is left in
82/// raw mode (no echo, no line editing).
83pub struct Tui {
84    terminal: Terminal<CrosstermBackend<Stdout>>,
85    tty_ok: bool,
86}
87
88impl Tui {
89    /// Enable raw mode, push keyboard flags, enable bracketed paste,
90    /// hide the cursor, and enter an **inline viewport** anchored at the
91    /// cursor. The inline viewport is what lets finalized transcript
92    /// rows be printed into the host terminal's real scrollback
93    /// (`Terminal::insert_before`) — a fullscreen viewport would keep
94    /// every line inside the repaint region and native scroll-up would
95    /// show only pre-session content.
96    pub fn enter() -> Result<Self> {
97        Self::set_panic_hook();
98
99        let tty_ok = enable_raw_mode().is_ok();
100        let mut stdout = io::stdout();
101
102        if tty_ok {
103            // Report event types so key-release / repeat events arrive as
104            // distinct codes. Full Kitty flag set is gated on
105            // OXICODE_KITTY_KEYBOARD=1; default mirrors pre-Kitty behavior.
106            let flags = if std::env::var("OXICODE_KITTY_KEYBOARD").as_deref() == Ok("1") {
107                KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
108                    | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
109                    | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
110            } else {
111                KeyboardEnhancementFlags::REPORT_EVENT_TYPES
112            };
113            let _ = execute!(
114                stdout,
115                Hide,
116                EnableBracketedPaste,
117                PushKeyboardEnhancementFlags(flags)
118            );
119            let _ = stdout.flush();
120        }
121
122        // Inline viewport sized to the full terminal height (the live
123        // region keeps today's look: transcript tail + composer). On a
124        // non-tty (piped) run there is no scrollback to feed — fall
125        // back to fullscreen, where `insert_before` is a no-op.
126        //
127        // Entering the inline viewport also queries the cursor position
128        // (`CSI 6n`); a terminal that does not answer (piped pty, slow
129        // link) must not kill the app — degrade to fullscreen: no host
130        // scrollback committing, everything else identical.
131        let mut terminal = None;
132        if tty_ok {
133            let height = crossterm::terminal::size().map(|s| s.1).unwrap_or(24);
134            let backend = CrosstermBackend::new(stdout);
135            terminal = Terminal::with_options(
136                backend,
137                TerminalOptions {
138                    viewport: Viewport::Inline(height),
139                },
140            )
141            .ok();
142        }
143        let mut terminal = match terminal {
144            Some(t) => t,
145            None => Terminal::new(CrosstermBackend::new(io::stdout()))?,
146        };
147        if tty_ok {
148            let _ = terminal.clear();
149        }
150
151        Ok(Self { terminal, tty_ok })
152    }
153    /// Restore the terminal to its pre-TUI state. Each step is independent;
154    /// errors are swallowed so a partial restoration never strands the user
155    /// in raw mode.
156    pub fn exit(&mut self) -> Result<()> {
157        if self.tty_ok {
158            let _ = execute!(
159                self.terminal.backend_mut(),
160                PopKeyboardEnhancementFlags,
161                DisableBracketedPaste
162            );
163            let _ = self.terminal.show_cursor();
164            // disable_raw_mode is the most critical — always attempt it.
165            disable_raw_mode()?;
166            self.tty_ok = false;
167        }
168        Ok(())
169    }
170
171    /// Install a panic hook that restores the terminal before printing the
172    /// panic message. Without this, a panic inside the TUI strands the
173    /// user's shell in raw mode / alternate screen.
174    fn set_panic_hook() {
175        let original_hook = std::panic::take_hook();
176        std::panic::set_hook(Box::new(move |panic_info| {
177            let _ = execute!(io::stdout(), Show);
178            let _ = disable_raw_mode();
179            original_hook(panic_info);
180        }));
181    }
182}
183
184impl Drop for Tui {
185    fn drop(&mut self) {
186        let _ = self.exit();
187    }
188}
189
190// ─────────────────────────────────────────────────────────────────────────
191// Render state — shared between the input thread and the main loop.
192// ─────────────────────────────────────────────────────────────────────────
193
194/// The one authoritative prompt queue shared by the input handler and agent
195/// worker.  The visible queue is a projection of this deque, never a second
196/// queue that can drift from execution order.
197#[derive(Default)]
198struct PromptQueue {
199    pending: parking_lot::Mutex<VecDeque<String>>,
200    wake: tokio::sync::Notify,
201}
202
203impl PromptQueue {
204    fn enqueue(&self, prompt: String) {
205        self.pending.lock().push_back(prompt);
206        self.wake.notify_one();
207    }
208
209    fn remove(&self, index: usize) -> Option<String> {
210        self.pending.lock().remove(index)
211    }
212
213    fn move_by(&self, index: usize, delta: isize) -> bool {
214        let mut pending = self.pending.lock();
215        let Some(target) = index.checked_add_signed(delta) else {
216            return false;
217        };
218        if index >= pending.len() || target >= pending.len() {
219            return false;
220        }
221        pending.swap(index, target);
222        true
223    }
224
225    async fn next(&self) -> String {
226        loop {
227            let notified = self.wake.notified();
228            if let Some(prompt) = self.pending.lock().pop_front() {
229                return prompt;
230            }
231            notified.await;
232        }
233    }
234}
235
236/// Mutable state the input thread edits (text buffer, scroll, footer) and
237/// the main loop reads for rendering.
238//
239// `composer` is the single source of truth for the editable text. It owns
240// the buffer (replacing the old `input_buffer: String` + `input_cursor: usize
241// pair) and gives us correct CJK/emoji caret math, soft-wrap, horizontal
242// scroll, selection, and undo/redo for free. Hand-rolled byte math was
243// removed in Task 6 of the textarea port.
244/// oxibrain daemon connection state for the status-bar chip. Driven by a
245/// background prober (see `run_tui`); `Off` means the memory tools are
246/// disabled in settings and the chip renders nothing.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub(crate) enum BrainChip {
249    /// Memory disabled — chip hidden (quiet-chrome contract).
250    #[default]
251    Off,
252    /// Enabled but the daemon socket is absent.
253    Down,
254    /// Socket present but the last ping failed.
255    Degraded,
256    /// Ping succeeded.
257    Ok,
258}
259
260impl BrainChip {
261    /// `(label, healthy)` for the status bar; `None` hides the chip.
262    pub(crate) fn chip_label(self) -> Option<(&'static str, bool)> {
263        match self {
264            BrainChip::Off => None,
265            BrainChip::Ok => Some(("brain·ok", true)),
266            BrainChip::Degraded | BrainChip::Down => Some(("brain·down", false)),
267        }
268    }
269}
270
271/// Progress facts for the live agent run (`AgentStart` → `AgentEnd`).
272///
273/// Presence of the tracker (not `reasoning_stage`) is what keeps the
274/// indicator row above the composer owned during a run: turn boundaries
275/// clear the stage label, but the row must not flicker to the idle row
276/// (follow-ups / tips) until the whole run is over.
277#[derive(Debug, Clone)]
278pub(crate) struct RunState {
279    /// When the run started — drives the elapsed-time readout.
280    pub started_at: std::time::Instant,
281    /// LLM requests started so far (incremented on `MessageStart`).
282    pub turn: u32,
283    /// Tool executions started so far (incremented on `ToolExecutionStart`).
284    pub tool_calls: u32,
285}
286
287impl Default for RunState {
288    fn default() -> Self {
289        Self {
290            started_at: std::time::Instant::now(),
291            turn: 0,
292            tool_calls: 0,
293        }
294    }
295}
296
297pub struct RenderState {
298    /// Editable text in the composer. Source of truth for the prompt line.
299    pub composer: oxicode_textarea::TextArea,
300    /// Transcript lines, in display order.
301    pub transcript: Vec<TranscriptLine>,
302    /// Index of the line currently pinned at the top of the viewport.
303    /// `usize::MAX` means "follow the tail" (auto-scroll).
304    pub scroll_offset: usize,
305    /// Transcript entries [0, committed) are frozen in the host
306    /// terminal's real scrollback (printed above the viewport via
307    /// `Terminal::insert_before`). They never render in the live
308    /// viewport again — native scroll-up reads them.
309    pub committed_entries: usize,
310    /// Last known terminal width — omp-style tool boxes size their
311    /// borders to it. Refreshed each render pass; frozen transcript
312    /// lines keep the width they were built at (printed text does not
313    /// rewrap either).
314    pub viewport_width: u16,
315    /// Last known terminal height — paired with `viewport_width` for
316    /// resize-change detection (only width changes invalidate the
317    /// frozen scrollback).
318    pub last_viewport_height: u16,
319    /// Snapshot of the user's glyph-set setting — `nerd` swaps the
320    /// composer context labels for Nerd Font icons (never emoji).
321    pub glyph_set: crate::symbols::GlyphSet,
322    /// Inline image previews (kitty/iTerm2): protocol detection,
323    /// `inline_images` kill-switch, transmit budget, and the pending
324    /// live placements. Owned here so both the agent-event hook
325    /// (enqueue) and the post-draw step (emit) share one budget.
326    pub image_previews: super::image_preview::ImagePreviews,
327    /// Header context mirrored from `InlineHeaderContext`.
328    pub header_context: InlineHeaderContext,
329    /// Composer enabled state — mirrored from `SetInputEnabled`.
330    pub input_enabled: bool,
331    /// Composer prompt prefix — mirrored from `SetPrompt`.
332    pub prompt_prefix: String,
333    /// Composer placeholder — mirrored from `SetPlaceholder`.
334    pub placeholder: Option<String>,
335    /// Shutdown signal received from the harness.
336    pub shutdown_requested: bool,
337    /// Accumulated text for markdown rendering at message end.
338    pub message_buffer: String,
339    /// Accumulated reasoning text for the dimmed thinking block.
340    pub thinking_buffer: String,
341    /// Cache for the streaming assistant markdown render. Held on
342    /// `RenderState` so the cache survives across the many per-frame
343    /// `render_streamed_message` calls; the equality fast-path makes
344    /// most frames return without re-parsing.
345    pub md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache,
346    /// Transcript index where the in-flight assistant message's streamed
347    /// lines begin. `None` while nothing is streaming. The markdown
348    /// re-render at `MessageEnd` replaces from this anchor — a blind
349    /// tail-count would duplicate raw streamed lines whenever markdown
350    /// collapses the paragraph structure differently.
351    pub stream_anchor: Option<usize>,
352    /// Agent Hub overlay open.
353    pub agent_hub_open: bool,
354    /// Hub entries snapshotted when the overlay was opened (`/agents`).
355    pub hub_entries: Vec<(String, HubEntry)>,
356    /// Live Hub registry for per-frame subagent status (matched todo
357    /// highlight + auto-reconcile). `None` when the session provides none.
358    pub hub: Option<crate::app::agent_hub_registry::SharedHubRegistry>,
359    /// First Ctrl+C armed a quit; a second press exits (two-press quit).
360    pub pending_quit: bool,
361    /// Slash-command autocomplete popup state.
362    pub slash_popup: SlashPopup,
363    /// Current reasoning/tool stage (e.g. "tool: read"), shown above the composer.
364    pub reasoning_stage: Option<String>,
365    /// Live-run tracker — `Some` from `AgentStart` to `AgentEnd`. Owns the
366    /// indicator row across the per-turn stage clears so it never flickers
367    /// to the idle row mid-run, and carries progress facts for display.
368    pub(crate) active_run: Option<RunState>,
369    /// Typewriter reveal for the streamed body: how many BYTES of
370    /// `message_buffer` have been painted into the transcript.
371    /// `usize::MAX` = fully revealed (idle / finalized). The render tick
372    /// advances this so streamed text appears as a smooth flow instead
373    /// of per-network-chunk lumps.
374    pub stream_reveal: usize,
375    /// oxibrain daemon health for the status-bar chip (prober-fed).
376    pub(crate) brain: BrainChip,
377    /// Selected reasoning effort, reflected in the composer's context bar.
378    pub thinking_level: String,
379    /// Provider-reported prompt tokens for the most recently completed turn.
380    /// This is the closest available snapshot of the live context size.
381    pub context_tokens: Option<usize>,
382    /// Context capacity configured for the active agent session.
383    pub context_window: usize,
384    /// Overlay modal/list state — `Some` when an overlay is open.
385    pub overlay: Option<OverlayState>,
386    /// Model IDs for the /model overlay picker (ordered same as overlay items).
387    pub overlay_model_ids: Vec<String>,
388    /// `(provider, model_id)` pairs backing the `/models` catalog browser
389    /// overlay (ordered same as overlay items).
390    pub overlay_catalog_models: Vec<(String, String)>,
391    /// Provider names backing the `/providers` overlay (ordered same as items).
392    pub overlay_providers: Vec<String>,
393    /// Model catalog port handle, captured once at TUI startup so slash
394    /// commands (`/models`, `/providers`) can browse the full catalog.
395    pub catalog: Option<std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog>>,
396    /// Queued input prompts (waiting to be processed).
397    pub queued_inputs: Vec<String>,
398    /// Queued input prompts — interactive panel open (Ctrl+; toggles).
399    pub queue_panel_open: bool,
400    /// Selected index within the queue panel (when interactive).
401    pub queue_selected: usize,
402    /// Shell mode — `!` prefix for direct bash commands (grok-build parity).
403    pub shell_mode: bool,
404    /// Follow-up suggestion chips.
405    pub follow_ups: Vec<String>,
406    /// Live todo phases — refreshed from the live provider each frame.
407    pub todo_phases: Vec<TodoPhase>,
408    /// Whether the todo HUD is expanded (all phases) vs collapsed.
409    pub todo_expanded: bool,
410    /// HUD-only auto-clear deadline; does not mutate the underlying TodoState.
411    pub todo_clear_deadline: Option<std::time::Instant>,
412    /// Auto-clear delay (seconds) once the todo list settles (all closed).
413    /// `< 0` disables auto-clear. Wired from settings at TUI startup.
414    pub todo_clear_delay_secs: i64,
415    /// Live todo state provider — the same source the `todo` agent tool
416    /// writes to. `None` when todos are disabled; the pane stays hidden.
417    pub todo_provider: Option<Arc<dyn TodoStateProvider>>,
418    /// Vim editing state (enabled by /vim command).
419    pub vim_state: crate::tui_vt::vim::VimState,
420    /// Vim clipboard buffer.
421    pub vim_clipboard: String,
422    /// In-transcript search state — `None` when no search is active.
423    pub search: Option<SearchState>,
424    /// Per-block display override. An absent entry means the default
425    /// ([`BlockDisplayMode::Expanded] — chat responses must show their
426    /// full text; elision (`Truncated`) is opt-in via block cycling).
427    pub block_display: std::collections::HashMap<usize, BlockDisplayMode>,
428    /// Last Esc press timestamp (for double-Esc detection).
429    pub last_esc_at: Option<std::time::Instant>,
430    /// Multiline input mode — Enter inserts newline, Shift+Enter sends.
431    pub multiline_mode: bool,
432    /// Autonomy mode mirror for display. The authoritative value lives in
433    /// the shared `AskBridge` mode atomic (toggled by Shift+Tab); this field
434    /// is kept in lock-step so the render loop can draw a badge.
435    pub autonomy_mode: Mode,
436    /// Submitted prompt history (most-recent-first).
437    pub prompt_history: Vec<String>,
438    /// Current position in history navigation (None = not navigating).
439    pub history_pos: Option<usize>,
440    /// Next block ID to assign when appending transcript lines.
441    pub next_block_id: usize,
442    /// Cancel grace window — Esc pressed within this window after a cancel
443    /// is ignored (grok-build post-cancel grace, ~1s). Prevents mashing.
444    pub cancel_grace_until: Option<std::time::Instant>,
445    /// Active y/n/x confirmation dialog — `Some` while a modal confirmation
446    /// is open. The input thread resolves it; the render loop paints it
447    /// centered on top of everything else.
448    pub confirmation: Option<ModalConfirmation>,
449    /// Active ephemeral tip banner — `Some` for a bounded number of render
450    /// ticks, then auto-dismissed by expiry.
451    pub tip: Option<EphemeralTip>,
452    /// Workspace root — used by the @ file picker to walk + fuzzy-match.
453    pub cwd: PathBuf,
454    /// Active @-file-search dropdown — `Some` while the picker is open.
455    pub file_search: Option<crate::tui_vt::file_search::FileSearchState>,
456    /// `/issue` panel state. `None` = panel closed.
457    pub(crate) issues_panel: Option<crate::tui_vt::issues_panel::IssuesPanelState>,
458    /// Cached issue store handle, opened lazily on first `/issue` use.
459    pub issue_store: Option<std::sync::Arc<oxicode_sdk::FileIssueStore>>,
460    /// Per-tip-key show counter — suppresses ambient tips after SEEN_CAP views.
461    pub seen_tips: std::collections::HashMap<&'static str, u32>,
462    /// User-defined slash commands loaded once at startup from
463    /// `.oxicode/commands/` and `~/.oxicode/commands/`.
464    pub file_commands: Vec<FileCommand>,
465    /// Provider name and origin for the currently open secure prompt.
466    /// Set before opening the prompt; cleared on `OverlaySubmission::SecureInput`
467    /// after the key is written. `None` outside the secure-prompt flows
468    /// (`/providers` row action, `/providers add`, programmatic rekey) so a
469    /// stray `SecureInput` cannot leak into a different provider.
470    ///
471    /// The `SecureInputOrigin` variant lets the consumer of the submitted
472    /// key know whether to greet the user ("just added a provider") or
473    /// simply acknowledge ("key replaced") — both write to the same auth
474    /// storage slot, but the surrounding UX differs.
475    pub secure_input_origin: Option<SecureInputOrigin>,
476    /// Live-session swapper. `None` until the TUI startup wires it.
477    /// The render loop and the agent worker both call `current()` per
478    /// dispatch; the resume `tokio::spawn` calls `swap(new_handle)`.
479    /// `Option` because `#[derive(Default)]` requires it.
480    pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
481    /// `Some(path)` when the slash command wants the event loop to
482    /// drain a resume job on the next `Submitted` arm. The
483    /// `Submitted` arm calls `state.pending_resume.take()` and
484    /// enqueues the resume.
485    pub pending_resume: Option<PathBuf>,
486    pub session_state: Option<crate::SessionState>,
487    /// Active `/settings` tab. Persisted across overlay reopens within
488    /// the session; drives both the tab-switch rebuild and the sidebar
489    /// highlight.
490    pub settings_active_tab: crate::tui_vt::settings_defs::SettingsTab,
491    /// Row-kind table for the settings panel's map editors
492    /// (Keybindings / Model roles), index-aligned with
493    /// `overlay.items` while the tabbed panel is open. Built by the
494    /// same pass that builds the items; consulted by the input thread
495    /// to route `Enter` / `d` / `n` on map rows. Empty (or stale —
496    /// every consumer re-checks length alignment) for non-settings
497    /// overlays.
498    pub settings_map_rows: Vec<Option<SettingsMapRow>>,
499    /// Live global-shortcut resolver, seeded from
500    /// `Settings::keybindings` at TUI startup and swapped in place by
501    /// the keybindings editor. `parking_lot::RwLock` (not ArcSwap) — no
502    /// new dependency, and the per-keystroke read-lock cost is
503    /// negligible.
504    pub keymap: Arc<parking_lot::RwLock<crate::tui_vt::keymap::Keymap>>,
505    /// Test-only sandbox: when `Some`, the keybindings commit path
506    /// writes `Settings` to this path via `Settings::save_to` instead
507    /// of touching the real `~/.oxicode/settings.{json,toml}`. The
508    /// production TUI leaves this at `None`; only unit tests set it.
509    /// Thread-safety is the same as `RenderState` itself (single-thread
510    /// use in the input thread).
511    #[cfg(test)]
512    pub settings_override_path: Option<std::path::PathBuf>,
513
514    /// Git TUI overlay — `Some` while `/git` is open. The render loop
515    /// paints the overlay over the scrollback+composer region when set;
516    /// the input thread routes keys through `match_git_key` and never
517    /// lets them reach the composer.
518    pub git_tui: Option<crate::tui_vt::git_tui::GitTuiState>,
519    /// Width/height of the git TUI overlay viewport (mirrored from the
520    /// last render pass so resize events can be detected without a
521    /// round-trip into the ratatui Frame).
522    pub git_tui_viewport: (u16, u16),
523}
524
525impl Default for RenderState {
526    fn default() -> Self {
527        // for every other field. The composer starts empty.
528        Self {
529            composer: oxicode_textarea::TextArea::new(),
530            transcript: Vec::new(),
531            scroll_offset: usize::MAX,
532            committed_entries: 0,
533            header_context: InlineHeaderContext::default(),
534            input_enabled: false,
535            prompt_prefix: String::new(),
536            placeholder: None,
537            thinking_buffer: String::new(),
538            shutdown_requested: false,
539            message_buffer: String::new(),
540            stream_anchor: None,
541            md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache::default(),
542            agent_hub_open: false,
543            hub_entries: Vec::new(),
544            hub: None,
545            pending_quit: false,
546            slash_popup: SlashPopup::default(),
547            reasoning_stage: None,
548            active_run: None,
549            stream_reveal: usize::MAX,
550            thinking_level: "medium".to_string(),
551            viewport_width: 80,
552            last_viewport_height: 24,
553            glyph_set: crate::symbols::GlyphSet::default(),
554            image_previews: super::image_preview::ImagePreviews::default(),
555            overlay: None,
556            overlay_model_ids: Vec::new(),
557            overlay_catalog_models: Vec::new(),
558            overlay_providers: Vec::new(),
559            catalog: None,
560            queued_inputs: Vec::new(),
561            queue_panel_open: false,
562            queue_selected: 0,
563            shell_mode: false,
564            follow_ups: Vec::new(),
565            todo_phases: Vec::new(),
566            todo_expanded: false,
567            todo_clear_deadline: None,
568            todo_clear_delay_secs: -1,
569            todo_provider: None,
570            vim_state: crate::tui_vt::vim::VimState::default(),
571            vim_clipboard: String::new(),
572            search: None,
573            block_display: std::collections::HashMap::new(),
574            last_esc_at: None,
575            multiline_mode: false,
576            autonomy_mode: Mode::default(),
577            prompt_history: Vec::new(),
578            history_pos: None,
579            next_block_id: 0,
580            cancel_grace_until: None,
581            confirmation: None,
582            tip: None,
583            cwd: PathBuf::new(),
584            file_search: None,
585            issues_panel: None,
586            issue_store: None,
587            seen_tips: std::collections::HashMap::new(),
588            file_commands: Vec::new(),
589            secure_input_origin: None,
590            session_swapper: None,
591            context_tokens: None,
592            context_window: 128_000,
593            settings_active_tab: crate::tui_vt::settings_defs::SettingsTab::General,
594            settings_map_rows: Vec::new(),
595            // Default bindings only — `new_with_header` (the real TUI
596            // startup) layers `Settings::keybindings` on top, keeping
597            // `Default` free of disk I/O for tests.
598            keymap: Arc::new(parking_lot::RwLock::new(Keymap::from_settings(
599                &std::collections::HashMap::new(),
600            ))),
601            #[cfg(test)]
602            settings_override_path: None,
603            brain: BrainChip::default(),
604            pending_resume: None,
605            session_state: None,
606            git_tui: None,
607            git_tui_viewport: (80, 24),
608        }
609    }
610}
611
612/// Where a secure prompt came from. The `SecureInput` overlay has just one
613/// payload (the text); the origin discriminates the post-commit follow-up
614/// so the user gets a contextual flow instead of a generic "saved" line.
615#[derive(Clone, Debug, PartialEq, Eq)]
616pub enum SecureInputOrigin {
617    /// User picked a provider row and chose "Set API key" (or hit Enter
618    /// on a key-only provider with no key) — this is a *replace* or
619    /// first-time key entry for an existing provider.
620    SetKey { provider: String },
621    /// User just added a provider via `/providers add …` and we are
622    /// chaining straight into the key prompt so they can finish the
623    /// setup without another navigation step.
624    NewlyAdded { provider: String },
625    /// Model-roles map editor: the user pressed `n` — the submitted
626    /// text is the new ROLE name; the value prompt follows.
627    ModelRoleKey,
628    /// Model-roles map editor: the submitted text is the model pattern
629    /// for `role`.
630    ModelRoleValue { role: String },
631    /// Generic settings-panel text editor: the submitted text is
632    /// committed via `settings_defs::apply_change` for the named
633    /// SettingKey. Empty input clears the override (where the field
634    /// is `Option`); invalid input is rejected with an inline error.
635    TextEdit(crate::tui_vt::settings_defs::SettingKey),
636}
637
638/// In-transcript search state.
639#[derive(Clone, Debug)]
640pub struct SearchState {
641    pub query: String,
642    /// Transcript line indices that contain a match.
643    pub matches: Vec<usize>,
644    /// Current match cursor (index into `matches`).
645    pub current: usize,
646}
647
648/// One filtered entry in the `/`-command autocomplete popup.
649#[derive(Clone)]
650pub struct SlashPopupItem {
651    /// Display label, e.g. `"/quit, /exit, /q"`.
652    pub label: String,
653    /// Short human description.
654    pub description: String,
655    /// Canonical command name (no leading `/`), used for completion.
656    pub name: String,
657}
658
659/// Slash-command autocomplete popup state, managed by the input thread and
660/// read by the render loop. The popup is open when the input buffer starts
661/// with `/` and contains no space (i.e. the user is still typing the command
662/// token, not its arguments).
663#[derive(Default, Clone)]
664pub struct SlashPopup {
665    pub open: bool,
666    pub items: Vec<SlashPopupItem>,
667    pub selected: usize,
668}
669
670/// One item rendered inside a list overlay. Mirrors [`InlineListItem`] but
671/// is a value type owned by the TUI (the input thread reads/writes these
672/// fields directly via the `parking_lot::Mutex<RenderState>`).
673#[derive(Clone, Debug)]
674pub struct OverlayListItem {
675    pub title: String,
676    pub subtitle: Option<String>,
677    pub badge: Option<String>,
678    pub indent: u8,
679    pub search_value: Option<String>,
680    /// Original `InlineListSelection` echoed back to the harness on submit.
681    pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
682}
683
684/// Overlay modal/list state — materialised by `apply_command` when an
685/// `InlineCommand::ShowOverlay` arrives. The input thread mutates
686/// `selected` / `search` while the overlay is open and reads the same
687/// fields when forwarding `OverlayEvent`s.
688///
689/// `tabs` / `sections` carry the settings panel's tab bar and sidebar.
690/// Both stay default-empty for every other overlay — `render_overlay`
691/// only takes the tabbed/sidebar branches when they are populated.
692#[derive(Clone, Debug, Default)]
693pub struct OverlayState {
694    pub title: String,
695    pub lines: Vec<String>,
696    pub items: Vec<OverlayListItem>,
697    pub selected: usize,
698    pub search: Option<OverlaySearchState>,
699    pub secure_input: Option<OverlaySecureInput>,
700    /// Tab-bar labels (settings panel only; empty ⇒ no tab bar).
701    pub tabs: Vec<String>,
702    /// Index of the active tab into `tabs`.
703    pub active_tab: usize,
704    /// Sidebar section (group) labels for the active tab; the sidebar
705    /// renders when there are at least two.
706    pub sections: Vec<String>,
707    /// Index of the active section into `sections`, synced to the group
708    /// of the currently selected item.
709    pub active_section: usize,
710    /// Keybinding-capture mode (settings panel only): `Some(action
711    /// name)` while the "press a key combo" prompt is up. The input
712    /// thread intercepts the next key BEFORE global-shortcut resolution
713    /// so even a combo that currently triggers an action is captured
714    /// verbatim. Esc cancels.
715    pub key_capture: Option<String>,
716}
717
718/// Secure (masked) single-line input state carried by an overlay.
719/// Only present when the original `OverlayRequest::Modal` carried a
720/// `secure_prompt`. The input thread mutates `editor` while the overlay is
721/// open; on `Enter` it submits `OverlaySubmission::SecureInput` carrying
722/// the editor's text. The real secret never leaves the editor — the
723/// renderer paints the value via a `TextElement` whose display is the
724/// mask.
725#[derive(Clone, Debug)]
726pub struct OverlaySecureInput {
727    pub config: SecurePromptConfig,
728    pub editor: EditBuffer,
729}
730
731/// A y/n/x confirmation dialog (grok-build `ModalConfirmation` parity).
732/// Rendered centered on top of everything else; the input thread routes
733/// `y` → confirm, `n` → decline (when offered), `x`/`Esc` → cancel.
734#[derive(Clone, Debug)]
735pub struct ModalConfirmation {
736    pub title: String,
737    pub message: String,
738    /// What happens when the user confirms (`y`). Cancel (`n`/`x`/`Esc`)
739    /// always just closes the dialog.
740    pub action: ConfirmationAction,
741}
742
743/// The action bound to a [`ModalConfirmation`] — dispatched on `y`/Enter.
744#[derive(Clone, Debug, PartialEq, Eq)]
745pub enum ConfirmationAction {
746    /// Exit the application.
747    Quit,
748    /// Clear the conversation transcript + reset the agent session.
749    ClearConversation,
750    /// Remove the stored API key for a provider (`/providers` → confirm).
751    RemoveProviderKey(String),
752    /// Close the given issue id (from the issues panel's `c` key).
753    CloseIssue(u32),
754}
755
756/// A short-lived contextual tip banner (grok-build ephemeral tips parity).
757/// Shown as one line above the composer for a bounded number of render
758/// ticks, then auto-dismissed.
759#[derive(Clone, Debug)]
760pub struct EphemeralTip {
761    pub text: String,
762    /// Render tick the tip was born at (`FRAME_TICK` snapshot).
763    pub born_tick: u64,
764    /// How many ticks the tip stays visible before auto-dismissing.
765    pub ttl_ticks: u64,
766    /// Stable identifier for per-session seen-cap tracking. Tips with the
767    /// same key are suppressed after `SEEN_CAP` showings.
768    pub key: &'static str,
769    /// Ambient tips (background suggestions) are occluded — their TTL pauses
770    /// while an overlay/confirmation/dropdown is open. Non-ambient tips
771    /// (direct user-action feedback) always count down.
772    pub ambient: bool,
773}
774
775/// Search-bar state for an overlay. `None` value means search is disabled.
776#[derive(Clone, Debug)]
777pub struct OverlaySearchState {
778    pub label: String,
779    pub placeholder: Option<String>,
780    pub value: String,
781}
782
783impl RenderState {
784    fn new_with_header(header: InlineHeaderContext) -> Self {
785        let mut s = Self::default();
786        s.header_context = header;
787        s.prompt_prefix = "> ".to_string();
788        s.input_enabled = true;
789        // Build the live keymap once at startup from the persisted
790        // bindings — the input loop resolves every keystroke against it.
791        let bindings = crate::store::settings::Settings::load()
792            .unwrap_or_default()
793            .keybindings;
794        *s.keymap.write() = Keymap::from_settings(&bindings);
795        s
796    }
797
798    /// Get a clone of the live `SessionSwapper`. Panics if the TUI
799    /// wasn't initialized properly (the `run_tui` startup wires it
800    /// before any user input is processed, so the panic is
801    /// unreachable in normal use).
802    pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
803        self.session_swapper
804            .clone()
805            .expect("RenderState::session_swapper must be initialized at TUI startup")
806    }
807
808    /// Append one or more brand-new transcript lines.
809    ///
810    /// `ratatui::text::Line` is a single visual line: embedded `\n`
811    /// characters are flattened. Normalize protocol segments at this
812    /// boundary so `TranscriptLine` keeps its name and rendering contract.
813    fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
814        let block_id = self.block_id_for_kind(kind);
815        self.transcript
816            .extend(
817                Self::segments_by_explicit_line(segments)
818                    .into_iter()
819                    .map(|segments| TranscriptLine {
820                        kind,
821                        segments,
822                        block_id,
823                    }),
824            );
825    }
826
827    /// Append line(s) that open a NEW block instead of merging into the
828    /// last block of the same kind — omp-style tool boxes are one
829    /// atomic block per call (border, command, output, border).
830    fn append_line_new_block(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
831        let block_id = self.fresh_block_id();
832        self.transcript
833            .extend(
834                Self::segments_by_explicit_line(segments)
835                    .into_iter()
836                    .map(|segments| TranscriptLine {
837                        kind,
838                        segments,
839                        block_id,
840                    }),
841            );
842    }
843
844    /// Append a streaming delta to the active line. Explicit newlines finish
845    /// the current line and open another line in the same semantic block.
846    fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
847        let mut lines = Self::segments_by_explicit_line(vec![segment]).into_iter();
848
849        // Merge into the tail line only while it belongs to the in-flight
850        // stream. Without the anchor guard, the first delta of a NEW
851        // message would append into the previous message's final line —
852        // mutating history the user already read.
853        let streaming = self.stream_anchor.is_some();
854        if let Some(first) = lines.next() {
855            let merge_ok =
856                streaming && self.transcript.last().is_some_and(|last| last.kind == kind);
857            if merge_ok && let Some(last) = self.transcript.last_mut() {
858                last.segments.extend(first);
859            } else {
860                // A fresh streamed message opens its own block so folding
861                // and turn structure cannot bleed across messages.
862                let block_id = self.fresh_block_id();
863                if self.stream_anchor.is_none() {
864                    self.stream_anchor = Some(self.transcript.len());
865                }
866                self.transcript.push(TranscriptLine {
867                    kind,
868                    segments: first,
869                    block_id,
870                });
871            }
872        }
873        let block_id = self
874            .stream_anchor
875            .and_then(|a| self.transcript.get(a))
876            .map(|l| l.block_id)
877            .unwrap_or_else(|| self.fresh_block_id());
878        self.transcript.extend(lines.map(|segments| TranscriptLine {
879            kind,
880            segments,
881            block_id,
882        }));
883    }
884
885    /// Split styled segments without allocating on the common single-line
886    /// path. Empty chunks are retained because blank lines carry layout.
887    fn segments_by_explicit_line(segments: Vec<InlineSegment>) -> Vec<Vec<InlineSegment>> {
888        if !segments.iter().any(|segment| segment.text.contains('\n')) {
889            return vec![segments];
890        }
891
892        let mut lines = vec![Vec::new()];
893        for segment in segments {
894            let InlineSegment { text, style } = segment;
895            for (index, part) in text.split('\n').enumerate() {
896                if index > 0 {
897                    lines.push(Vec::new());
898                }
899                if !part.is_empty()
900                    && let Some(line) = lines.last_mut()
901                {
902                    line.push(InlineSegment {
903                        text: part.to_string(),
904                        style: Arc::clone(&style),
905                    });
906                }
907            }
908        }
909        lines
910    }
911
912    /// Determine the block_id for a new line: reuse the last line's block
913    /// if the kind matches, otherwise allocate a new block.
914    fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
915        if let Some(last) = self.transcript.last()
916            && last.kind == kind
917        {
918            return last.block_id;
919        }
920        let id = self.next_block_id;
921        self.next_block_id += 1;
922        id
923    }
924
925    /// Allocate a block id that cannot merge with an existing block.
926    fn fresh_block_id(&mut self) -> usize {
927        let id = self.next_block_id;
928        self.next_block_id += 1;
929        id
930    }
931
932    // ── Search ──
933
934    /// Start a new transcript search, collecting all matching line indices.
935    pub fn start_search(&mut self, query: &str) {
936        let needle = query.to_lowercase();
937        let matches: Vec<usize> = self
938            .transcript
939            .iter()
940            .enumerate()
941            // Committed entries are frozen in the host scrollback —
942            // the live region cannot scroll to them, so search skips.
943            .filter(|(i, _)| *i >= self.committed_entries)
944            .filter(|(_, line)| {
945                line.segments
946                    .iter()
947                    .any(|s| s.text.to_lowercase().contains(&needle))
948            })
949            .map(|(i, _)| i)
950            .collect();
951        self.search = Some(SearchState {
952            query: query.to_string(),
953            matches,
954            current: 0,
955        });
956        // Jump to the first match if any.
957        if let Some(s) = &self.search
958            && let Some(&first) = s.matches.first()
959        {
960            self.scroll_offset = first;
961        }
962    }
963
964    /// Advance to the next search match (wraps around).
965    pub fn search_next(&mut self) {
966        if let Some(s) = &mut self.search
967            && !s.matches.is_empty()
968        {
969            s.current = (s.current + 1) % s.matches.len();
970            let line = s.matches[s.current];
971            self.scroll_offset = line;
972        }
973    }
974
975    /// Go to the previous search match (wraps around).
976    pub fn search_prev(&mut self) {
977        if let Some(s) = &mut self.search
978            && !s.matches.is_empty()
979        {
980            if s.current == 0 {
981                s.current = s.matches.len() - 1;
982            } else {
983                s.current -= 1;
984            }
985            let line = s.matches[s.current];
986            self.scroll_offset = line;
987        }
988    }
989
990    // ── Block display modes (Collapsed / Truncated / Expanded) ──
991
992    /// The display mode for a block — explicit override or the Expanded
993    /// default. Chat content hides nothing by default: middle-elision
994    /// made long responses unreadable and unscrollable past the gap.
995    pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
996        self.block_display
997            .get(&block_id)
998            .copied()
999            .unwrap_or(BlockDisplayMode::Expanded)
1000    }
1001
1002    /// Cycle the display mode of the block at (or nearest above) the current
1003    /// scroll offset: Collapsed → Truncated → Expanded → Collapsed.
1004    pub fn cycle_block_at_view(&mut self) {
1005        let offset = self.effective_offset();
1006        if let Some(line) = self.transcript.get(offset) {
1007            let bid = line.block_id;
1008            let next = match self.block_mode(bid) {
1009                BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
1010                BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
1011                BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
1012            };
1013            // Expanded is the default — represent it by absence so the map
1014            // only carries real overrides.
1015            if next == BlockDisplayMode::Expanded {
1016                self.block_display.remove(&bid);
1017            } else {
1018                self.block_display.insert(bid, next);
1019            }
1020        }
1021    }
1022
1023    /// Expand every block. Expanded is the default, so this simply drops
1024    /// all overrides.
1025    pub fn expand_all(&mut self) {
1026        self.block_display.clear();
1027    }
1028
1029    /// Collapse every block (first line only).
1030    pub fn fold_all(&mut self) {
1031        for bid in self.all_block_ids() {
1032            self.block_display.insert(bid, BlockDisplayMode::Collapsed);
1033        }
1034    }
1035
1036    /// Reset every block to the default Truncated mode.
1037    pub fn truncate_all(&mut self) {
1038        // Truncated is no longer the default — it must be recorded
1039        // explicitly for every block.
1040        for bid in self.all_block_ids() {
1041            self.block_display.insert(bid, BlockDisplayMode::Truncated);
1042        }
1043    }
1044
1045    /// Distinct block ids in transcript order.
1046    fn all_block_ids(&self) -> Vec<usize> {
1047        let mut ids = Vec::new();
1048        let mut prev: Option<usize> = None;
1049        for l in &self.transcript {
1050            if prev != Some(l.block_id) {
1051                ids.push(l.block_id);
1052                prev = Some(l.block_id);
1053            }
1054        }
1055        ids
1056    }
1057
1058    // ── Turn navigation ──
1059
1060    /// Jump the scroll to the start of the next assistant (Agent) block.
1061    pub fn jump_next_turn(&mut self) {
1062        let offset = self.effective_offset();
1063        let search_after = self
1064            .transcript
1065            .iter()
1066            .enumerate()
1067            .skip(offset + 1)
1068            .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
1069        if let Some((idx, _)) = search_after {
1070            self.scroll_offset = idx;
1071        }
1072    }
1073
1074    /// Jump the scroll to the start of the previous user block.
1075    pub fn jump_prev_turn(&mut self) {
1076        let offset = self.effective_offset();
1077        let search_before = self
1078            .transcript
1079            .iter()
1080            .enumerate()
1081            .take(offset)
1082            .rev()
1083            .find(|(_, l)| l.kind == InlineMessageKind::User);
1084        if let Some((idx, _)) = search_before {
1085            self.scroll_offset = idx;
1086        }
1087    }
1088
1089    /// Effective scroll offset (resolves `usize::MAX` follow-tail to a real index).
1090    fn effective_offset(&self) -> usize {
1091        if self.scroll_offset == usize::MAX {
1092            self.transcript.len().saturating_sub(1)
1093        } else {
1094            self.scroll_offset
1095        }
1096    }
1097
1098    /// Drop the head of the queued-input list. Called when a turn ends so
1099    /// the queue pane stops showing the prompt that is now running.
1100    pub fn drain_queue_head(&mut self) {
1101        if !self.queued_inputs.is_empty() {
1102            self.queued_inputs.remove(0);
1103        }
1104    }
1105
1106    /// Show an ephemeral tip if the per-session seen-cap hasn't been reached.
1107    /// Each unique `key` can show at most `SEEN_CAP` times per session.
1108    pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
1109        let count = self.seen_tips.entry(key).or_insert(0);
1110        if *count >= SEEN_CAP {
1111            return;
1112        }
1113        *count += 1;
1114        self.tip = Some(EphemeralTip {
1115            text: text.to_string(),
1116            born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
1117            ttl_ticks: ttl,
1118            key,
1119            ambient,
1120        });
1121    }
1122}
1123
1124/// Max times an ambient tip key is shown per session before suppression.
1125const SEEN_CAP: u32 = 3;
1126
1127// ─────────────────────────────────────────────────────────────────────────
1128// Main entry: `pub async fn run_tui(app: App) -> Result<()>`
1129// ─────────────────────────────────────────────────────────────────────────
1130
1131/// Run the new oxicode-vtui powered TUI. Returns once the user exits or the
1132/// session is shut down.
1133pub async fn run_tui(app: App) -> Result<()> {
1134    // Resolve shared session-level context up-front so it can outlive the
1135    // TUI RAII guard via the worker thread.
1136    let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
1137    let git_branch = crate::util::git_utils::get_current_branch(&cwd);
1138    super::host::activate_theme(app.settings());
1139    // Validate active theme contrast and log any warnings.
1140    let theme_id = oxicode_vtui::theme::active_theme_id();
1141    let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
1142    if validation.warnings.is_empty() {
1143        tracing::debug!("theme '{theme_id}' passed contrast validation");
1144    } else {
1145        for w in &validation.warnings {
1146            tracing::warn!("theme contrast: {w}");
1147        }
1148    }
1149
1150    // Wire the inline-protocol channels. `cmd_tx` becomes the
1151    // `InlineHandle`; `evt_tx` is the input-thread → main-loop channel.
1152    let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
1153    let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
1154    let handle = InlineHandle::new_for_tests(cmd_tx);
1155
1156    // Build the AgentSession from the App. The helper wraps
1157    // `create_agent_session_from_services` so we can construct the session
1158    // without duplicating the runtime plumbing here.
1159    let session = build_agent_session(&app).await?;
1160    // No install_runtime_hooks call: session queues and stop flag are
1161    // wired into the agent hook chain at agent-build time via
1162    // App::from_oxicode → with_session_hooks.
1163    let session_handle = session.clone_handle();
1164
1165    // Wrap the initial handle in a SessionSwapper. The render loop
1166    // and the agent worker both read through `current()`; the
1167    // resume `tokio::spawn` (below) calls `swap(new_handle)`.
1168    let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
1169        session_handle.clone(),
1170    ));
1171
1172    // Forward session events to a tokio mpsc so the main loop can
1173    // `tokio::select!` on them. We do this in two stages:
1174    //  1. Subscribe to AgentSession — CompactionStart/End, Advisor,
1175    //     QueueUpdate, etc.
1176    //  2. A forwarder thread that drives `agent.run_with_channel` and
1177    //     calls `forward_event_to_extensions` so per-agent events also
1178    //     flow through the same listener.
1179    let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
1180    let _sub_guard = session.subscribe(Box::new(move |event| {
1181        let _ = session_tx.send(event.clone());
1182    }));
1183
1184    // Header context — built once at startup with workspace + branch.
1185    let header = build_header_context(&app, &cwd, git_branch.as_deref());
1186    handle.set_header_context(header.clone());
1187
1188    // Enter the terminal (RAII). Every setup step is fallible, but a
1189    // successful `Tui::enter` is required to draw anything.
1190    let mut tui = Tui::enter()?;
1191
1192    // Initial composer + placeholder — the harness receives these as
1193    // `SetPrompt` / `SetPlaceholder` commands once it spins up its own
1194    // consumer; we set them eagerly so the very first frame is correct.
1195    handle.set_prompt("> ".to_string(), InlineTextStyle::default());
1196    handle.set_placeholder(Some(
1197        "Describe the task, or type / for commands".to_string(),
1198    ));
1199
1200    // Render state — shared between the input thread (which edits the
1201    // buffer) and the main loop (which reads it for drawing).
1202    let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
1203        header,
1204    )));
1205    state.lock().cwd = cwd.clone();
1206    state.lock().catalog = Some(app.catalog());
1207    state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
1208    state.lock().todo_provider = session_handle.todo_provider();
1209    state.lock().todo_clear_delay_secs = app.settings().todo_clear_delay_secs;
1210    state.lock().hub = Some(session_handle.hub_arc());
1211    state.lock().session_swapper = Some(session_swapper.clone());
1212    state.lock().session_state = Some(app.session_state().clone());
1213    state.lock().thinking_level = format!("{:?}", session.thinking_level()).to_ascii_lowercase();
1214    // MODEL chip + CTX denominator from the live session (the boot header
1215    // context carries the model id; the context window comes from here).
1216    {
1217        let mut s = state.lock();
1218        sync_model_chips(&mut s, &session_handle);
1219    }
1220    // Onboarding tip: surfaces the cheatsheet and help command on first run,
1221    // auto-dismisses after ~30s of rendering.
1222    state.lock().tip = Some(EphemeralTip {
1223        text: "Press ? for shortcuts | /help for commands".to_string(),
1224        born_tick: 0,
1225        ttl_ticks: 900,
1226        key: "onboarding",
1227        ambient: true,
1228    });
1229    // SSH tip: suggest tmux when running over SSH (1-time).
1230    if std::env::var("SSH_CONNECTION").is_ok() {
1231        state.lock().show_tip(
1232            "ssh_wrap",
1233            "Over SSH? Consider tmux to keep sessions alive",
1234            600,
1235            true,
1236        );
1237    }
1238    // Shared autonomy-mode handle — Shift+Tab toggles it at runtime. The
1239    // AskBridge atomic is the authority; the render state mirrors it so the
1240    // composer can draw a mode badge.
1241    let mode_handle = app.ask_bridge().map(|b| {
1242        let handle = b.mode_handle();
1243        state.lock().autonomy_mode = Mode::load(&handle);
1244        handle
1245    });
1246    state.lock().glyph_set = app.settings().glyph_set;
1247    // `inline_images` kill-switch (default ON): flips off every image
1248    // escape write; the transcript's fallback text is all that shows.
1249    state
1250        .lock()
1251        .image_previews
1252        .set_enabled(app.settings().inline_images);
1253    let prompt_queue = Arc::new(PromptQueue::default());
1254    // User-remappable keybindings live in `RenderState::keymap`, seeded
1255    // from `Settings::keybindings` by `new_with_header` above and swapped
1256    // in place by the settings keybindings editor — no separate
1257    // keybindings.yml bootstrap.
1258    let (issue_action_tx, mut issue_action_rx) =
1259        tokio::sync::mpsc::unbounded_channel::<crate::tui_vt::issues_panel::IssueActionRequest>();
1260    spawn_input_thread(
1261        state.clone(),
1262        evt_tx.clone(),
1263        mode_handle,
1264        prompt_queue.clone(),
1265        issue_action_tx.clone(),
1266    );
1267
1268    // Worker thread owns the agent loop and takes prompts from the shared
1269    // authoritative queue before dispatching them through `run_with_channel`. The
1270    // returned `AgentEvent`s flow through a `std::sync::mpsc`; a paired
1271    // forwarder thread funnels them into the session's listener bus so
1272    // our subscriber above picks them up.
1273    spawn_agent_worker(session_swapper.clone(), prompt_queue.clone());
1274    // Brain health prober: pings the oxibrain daemon and feeds the
1275    // status-bar chip through a watch channel. The interval's first tick is
1276    // immediate, so the chip reflects reality on the first frame after a
1277    // brief probe; every 20 s afterwards. A slow/absent daemon never blocks
1278    // the loop — the ping is timeout-bounded.
1279    let (brain_tx, mut brain_rx) =
1280        tokio::sync::watch::channel(crate::services::initial_brain_chip(app.settings()));
1281    {
1282        let memory_enabled = app.settings().memory_enabled;
1283        let announce = handle.clone();
1284        tokio::spawn(async move {
1285            let backend = crate::foundation::brain::BrainMemoryBackend::new(
1286                crate::foundation::brain::default_socket_path(),
1287            );
1288            let mut tick = tokio::time::interval(std::time::Duration::from_secs(20));
1289            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1290            // One automatic revive per session (success or failure — a
1291            // broken daemon must not turn the prober into a spawn loop).
1292            let mut auto_revive_attempted = false;
1293            loop {
1294                tick.tick().await;
1295                let mut chip = if !memory_enabled {
1296                    BrainChip::Off
1297                } else if !crate::services::brain_socket_present(
1298                    &crate::foundation::brain::default_socket_path(),
1299                ) {
1300                    BrainChip::Down
1301                } else {
1302                    match tokio::time::timeout(
1303                        std::time::Duration::from_millis(1500),
1304                        backend.ping(),
1305                    )
1306                    .await
1307                    {
1308                        Ok(Ok(())) => BrainChip::Ok,
1309                        Ok(Err(_)) | Err(_) => BrainChip::Degraded,
1310                    }
1311                };
1312                // Auto-revive: memory users get their daemon back without
1313                // typing /brain. Never installs (binary check), never
1314                // retries, and says what it did on the transcript.
1315                let down = matches!(chip, BrainChip::Down | BrainChip::Degraded);
1316                if crate::foundation::brain_control::should_auto_revive(
1317                    memory_enabled,
1318                    down,
1319                    auto_revive_attempted,
1320                ) {
1321                    auto_revive_attempted = true;
1322                    let installed = crate::foundation::brain_control::probe_control()
1323                        .binary
1324                        .is_some();
1325                    if installed {
1326                        match crate::foundation::brain_control::revive().await {
1327                            Ok(msg) => {
1328                                announce.append_line(
1329                                    InlineMessageKind::Info,
1330                                    vec![plain_segment(format!("brain: daemon was down — {msg}"))],
1331                                );
1332                                // Re-probe now instead of waiting a tick.
1333                                chip = match tokio::time::timeout(
1334                                    std::time::Duration::from_millis(1500),
1335                                    backend.ping(),
1336                                )
1337                                .await
1338                                {
1339                                    Ok(Ok(())) => BrainChip::Ok,
1340                                    _ => chip,
1341                                };
1342                            }
1343                            Err(e) => {
1344                                announce.append_line(
1345                                    InlineMessageKind::Warning,
1346                                    vec![plain_segment(format!(
1347                                        "brain: auto-restart failed — {e} (run /brain for details)"
1348                                    ))],
1349                                );
1350                            }
1351                        }
1352                    }
1353                }
1354                let _ = brain_tx.send(chip);
1355            }
1356        });
1357    }
1358
1359    let result = run_event_loop(
1360        &mut tui.terminal,
1361        &mut cmd_rx,
1362        &mut evt_rx,
1363        &mut session_rx,
1364        &mut brain_rx,
1365        &handle,
1366        &state,
1367        &session_swapper,
1368        &prompt_queue,
1369        &mut issue_action_rx,
1370    )
1371    .await;
1372
1373    handle.shutdown();
1374    // Dropping `tui` restores the terminal. Drop is at function return.
1375    drop(tui);
1376
1377    result
1378}
1379
1380// ─────────────────────────────────────────────────────────────────────────
1381// Event loop
1382// ─────────────────────────────────────────────────────────────────────────
1383#[allow(clippy::too_many_arguments)]
1384async fn run_event_loop(
1385    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
1386    cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
1387    evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
1388    session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
1389    brain_rx: &mut tokio::sync::watch::Receiver<BrainChip>,
1390    handle: &InlineHandle,
1391    state: &Arc<parking_lot::Mutex<RenderState>>,
1392    session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
1393    prompt_queue: &Arc<PromptQueue>,
1394    issue_action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<
1395        crate::tui_vt::issues_panel::IssueActionRequest,
1396    >,
1397) -> Result<()> {
1398    // Drain any pending InlineCommands so the harness's initial set_header_context
1399    // (and similar) is observed before the first frame.
1400    while let Ok(cmd) = cmd_rx.try_recv() {
1401        apply_command(&mut state.lock(), cmd);
1402    }
1403
1404    // Seed the resize detector from the real terminal before any
1405    // frame is drawn (final-review finding 1). `RenderState::
1406    // default()`'s 80 columns is a test-only fallback; left in place
1407    // it made the first draw of any terminal wider than 80 look like
1408    // a resize (80 → real width) and fire CSI 3J + Clear, wiping the
1409    // user's pre-TUI shell scrollback on every launch. On a size
1410    // failure we park the 0 sentinel — `should_rebuild_scrollback`
1411    // refuses to wipe until a real width has been observed.
1412    {
1413        let mut s = state.lock();
1414        match terminal.size() {
1415            Ok(size) => {
1416                s.viewport_width = size.width;
1417                s.last_viewport_height = size.height;
1418            }
1419            Err(_) => {
1420                s.viewport_width = 0;
1421                s.last_viewport_height = 0;
1422            }
1423        }
1424    }
1425
1426    // Draw the initial frame *before* blocking on the first event. The
1427    // `select!` below parks until an event arrives, and the per-iteration
1428    // redraw only runs after it resolves — so without this eager draw the
1429    // screen stays black until the user presses a key.
1430    {
1431        let snapshot = state.lock();
1432        let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1433        if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
1434            tracing::warn!(?err, "initial tui draw failed");
1435        }
1436        let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1437    }
1438
1439    // Render tick. The input thread edits shared state (typing, cursor
1440    // movement, backspace, …) *without* sending an event, so without a
1441    // periodic wake the composer would never repaint what the user types.
1442    // The ratatui diff backend coalesces unchanged frames, so a steady tick
1443    // is cheap and also drives future spinner animation.
1444    let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
1445    render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1446    // Render coalescing: most iterations skip the full draw and instead
1447    // rely on the 50ms `render_tick` arm below to guarantee a heartbeat.
1448    // `priority` is raised by user-facing arms (keyboard, SIGINT, brain
1449    // chip) so typing/cancels/chip flips repaint immediately.
1450    let mut last_draw = std::time::Instant::now();
1451    let mut priority = false;
1452
1453    loop {
1454        tokio::select! {
1455            // biased: agent events take priority so streaming output is
1456            // never starved by Ctrl+C noise or sticky key repeats.
1457            biased;
1458
1459            // 1. Agent → TUI commands (transcript updates).
1460            Some(cmd) = cmd_rx.recv() => {
1461                let shutdown = {
1462                    let mut s = state.lock();
1463                    apply_command(&mut s, cmd)
1464                };
1465                if shutdown {
1466                    break;
1467                }
1468            }
1469
1470            // 2. Agent → TUI events (token deltas, tool calls, …).
1471            Some(event) = session_rx.recv() => {
1472                // Intercept handoff-completion to clear transcript + auto-submit.
1473                if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
1474                    let mut s = state.lock();
1475                    s.transcript.clear();
1476                    s.message_buffer.clear();
1477                    s.scroll_offset = usize::MAX;
1478                    s.append_line(
1479                        InlineMessageKind::Info,
1480                        vec![plain_segment(format!(
1481                            "Handoff written to {}. New session started.",
1482                            doc_path
1483                        ))],
1484                    );
1485                    let user_typed = !s.composer.text().trim().is_empty();
1486                    s.composer.set_text("");
1487                    if *auto_continue && !user_typed {
1488                        drop(s);
1489                        prompt_queue.enqueue(format!(
1490                            "Read the handoff document at {} and continue \
1491                             from where the previous session left off.",
1492                            doc_path
1493                        ));
1494                    } else if user_typed {
1495                        drop(s);
1496                        handle.append_line(
1497                            InlineMessageKind::Info,
1498                            vec![plain_segment(
1499                                "Handoff complete. Auto-continue skipped \
1500                                 because input was non-empty \u{2014} press \
1501                                 Enter to submit your message in the new \
1502                                 session."
1503                                    .to_string(),
1504                            )],
1505                        );
1506                    }
1507                    let session = session_swapper.current();
1508                    handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1509                } else {
1510                    // Every regular agent event must reach the presentation
1511                    // bridge.  The handoff path above already does this after
1512                    // resetting the transcript; previously it was the *only*
1513                    // path that did.  As a result, prompts ran in the worker
1514                    // but token deltas, tool progress, and provider errors
1515                    // were silently discarded before a frame could render.
1516                    let session = session_swapper.current();
1517                    handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1518                }
1519            }
1520
1521            // 3. Keyboard / paste / TUI events from the input thread.
1522            Some(evt) = evt_rx.recv() => {
1523                let outcome = handle_inline_event(
1524                    &mut state.lock(),
1525                    handle,
1526                    &session_swapper.current(),
1527                    prompt_queue,
1528                    evt,
1529                );
1530                if outcome == LoopOutcome::Exit {
1531                    break;
1532                }
1533                priority = true;
1534            }
1535
1536            // 4. Issue panel action requests from the input thread (CAS-guarded
1537            //    async store writes that can't run on the sync key path).
1538            Some(req) = issue_action_rx.recv() => {
1539                crate::tui_vt::issues_panel::dispatch_action(req, state.clone());
1540            }
1541
1542            // 5. External SIGINT — route through the same idle-vs-streaming
1543            //    policy as the key path (some terminals deliver Ctrl+C both
1544            //    as a key event AND raise SIGINT; `kill -INT` also lands here).
1545            _ = tokio::signal::ctrl_c() => {
1546                let outcome = {
1547                    let mut s = state.lock();
1548                    handle_interrupt(&mut s, &session_swapper.current(), handle)
1549                };
1550                if outcome == LoopOutcome::Exit {
1551                    break;
1552                }
1553                priority = true;
1554            }
1555            // 6. Brain health chip updates from the background prober.
1556            changed = brain_rx.changed() => {
1557                if changed.is_ok() {
1558                    state.lock().brain = *brain_rx.borrow_and_update();
1559                    priority = true;
1560                }
1561            }
1562
1563            // 7. Periodic repaint — echoes typed input and drives animation
1564            //    even when no other event is ready.
1565            _ = render_tick.tick() => {}
1566        }
1567
1568        // Render coalescing: skip the snapshot/draw pipeline when no
1569        // user-facing arm raised priority and the render cadence has not
1570        // elapsed. The 50ms `render_tick` arm guarantees the heartbeat.
1571        if coalesce_draw(last_draw, priority, DRAW_MIN_INTERVAL) == DrawDecision::DrawNow {
1572            // small_screen tip: warn when terminal is too narrow for full UI.
1573            if let Ok(size) = terminal.size()
1574                && size.width < 40
1575            {
1576                let mut s = state.lock();
1577                if s.tip.is_none() {
1578                    s.show_tip(
1579                        "small_screen",
1580                        "Terminal too narrow \u{2014} resize for full UI",
1581                        300,
1582                        true,
1583                    );
1584                }
1585            }
1586            // Redraw. The harness's redraw is idempotent — the ratatui
1587            let mut snapshot = state.lock();
1588            // Resize observation: ratatui's Inline viewport auto-resizes
1589            // the cursor-row viewport on terminal draw, but the frozen
1590            // transcript in the host scrollback was printed at the
1591            // previous width and cannot re-wrap. When the width changes
1592            // we must (1) wipe the scrollback (CSI 3J) so stale-width
1593            // rows disappear, (2) clear the visible screen so the
1594            // viewport re-anchors cleanly, and (3) reset
1595            // `committed_entries` so the next ticks re-commit at the
1596            // new width. Height-only resize is a no-op (the live
1597            // region just grows or shrinks under the frozen history).
1598            let mut prev_size: Option<(u16, u16)> = None;
1599            if let Ok(size) = terminal.size() {
1600                prev_size = Some((snapshot.viewport_width, snapshot.last_viewport_height));
1601                snapshot.viewport_width = size.width;
1602                snapshot.last_viewport_height = size.height;
1603            }
1604            if let Some((prev_w, prev_h)) = prev_size
1605                && let Ok(size) = terminal.size()
1606                && should_rebuild_scrollback(prev_w, size.width, prev_h, size.height)
1607            {
1608                // CSI 3J erases the host scrollback; Clear(All) wipes
1609                // the visible viewport so stale-width rows vanish.
1610                let _ = execute!(terminal.backend_mut(), crossterm::style::Print("\x1b[3J"));
1611                let _ = terminal.clear();
1612                snapshot.committed_entries = 0;
1613                // No `priority = true` here — the unconditional reset
1614                // at the end of the draw branch would clobber it. The
1615                // CSI 3J + Clear already wiped the visible frame, so
1616                // the next render cadence tick repaints cleanly.
1617            }
1618            // pane reflects phase changes written by the `todo` agent tool, plus
1619            // subagent auto-reconcile (idle subagents close their matched todos).
1620            if let Some(provider) = snapshot.todo_provider.as_ref() {
1621                snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1622            }
1623            // HUD-only auto-clear: once the list settles (all closed) and the
1624            // delay elapses, drop the phases from the pane. The underlying
1625            // TodoState is untouched, so a later `/todo` or `todo` tool call
1626            // still sees the historical phases.
1627            let clear_delay = snapshot.todo_clear_delay_secs;
1628            sync_todo_clear_timer(&mut snapshot, clear_delay);
1629            // Typewriter paint: reveal the streamed body a bounded step per
1630            // frame so it types out instead of jumping per network chunk.
1631            advance_stream_reveal(&mut snapshot);
1632            // Shed finalized rows into the host scrollback before the
1633            // synchronized repaint so the commit and the viewport redraw
1634            // land as one visual update.
1635            commit_scrollback(terminal, &mut snapshot, false);
1636            let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1637            let draw_err = terminal
1638                .draw(|frame| render_frame(frame, &snapshot, handle))
1639                .err();
1640            let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1641            // Inline image previews: now that the frame (with the image
1642            // tool boxes) has flushed, emit the kitty transmit/place or
1643            // iTerm2 inline escapes for rows that rendered LIVE this
1644            // frame. Committed rows never emit — their fallback text is
1645            // already in the host scrollback. The write goes through the
1646            // terminal backend (same path as the synchronized-update
1647            // escapes above).
1648            let committed = snapshot.committed_entries;
1649            let image_escapes = snapshot.image_previews.emit_live(committed);
1650            if !image_escapes.is_empty() {
1651                let _ = execute!(
1652                    terminal.backend_mut(),
1653                    crossterm::style::Print(image_escapes)
1654                );
1655            }
1656            if let Some(err) = draw_err {
1657                tracing::warn!(?err, "tui draw failed");
1658                break;
1659            }
1660            // Reset cadence — next draw is gated again until either
1661            // priority is raised or the interval elapses.
1662            last_draw = std::time::Instant::now();
1663            priority = false;
1664        }
1665    }
1666
1667    // Exit flush: land every committable finalized row into the host
1668    // scrollback before the caller drops `Tui` (which restores the
1669    // terminal — after that the host scrollback is no longer in raw
1670    // mode and the print-before rows survive). The cap is a safety
1671    // belt: a stuck `insert_before` (broken terminal) cannot trap us
1672    // in the flush.
1673    const MAX_EXIT_FLUSH_ITERATIONS: usize = 50;
1674    for _ in 0..MAX_EXIT_FLUSH_ITERATIONS {
1675        let mut snapshot = state.lock();
1676        let before = snapshot.committed_entries;
1677        if snapshot.transcript.is_empty() || before >= snapshot.transcript.len() {
1678            break;
1679        }
1680        // pane reflects phase changes written by the `todo` agent tool, plus
1681        // subagent auto-reconcile (idle subagents close their matched todos).
1682        if let Some(provider) = snapshot.todo_provider.as_ref() {
1683            snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1684        }
1685        // HUD-only auto-clear: once the list settles (all closed) and the
1686        // delay elapses, drop the phases from the pane. The underlying
1687        // TodoState is untouched, so a later `/todo` or `todo` tool call
1688        // still sees the historical phases.
1689        let clear_delay = snapshot.todo_clear_delay_secs;
1690        sync_todo_clear_timer(&mut snapshot, clear_delay);
1691        // Typewriter paint: reveal the streamed body a bounded step per
1692        // frame so it types out instead of jumping per network chunk.
1693        advance_stream_reveal(&mut snapshot);
1694        // Shed finalized rows into the host scrollback before the
1695        // synchronized repaint so the commit and the viewport redraw
1696        // land as one visual update.
1697        commit_scrollback(terminal, &mut snapshot, true);
1698        let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1699        let draw_err = terminal
1700            .draw(|frame| render_frame(frame, &snapshot, handle))
1701            .err();
1702        let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1703        if let Some(err) = draw_err {
1704            tracing::warn!(?err, "tui draw failed");
1705            break;
1706        }
1707    }
1708
1709    Ok(())
1710}
1711
1712/// Minimum interval between successive full `terminal.draw` passes driven
1713/// by the event loop. User-facing arms (keyboard, SIGINT, brain chip)
1714/// bypass this via `priority = true`; token-stream agent events coalesce
1715/// here so a 200-events/sec burst does not become 200 draws/sec.
1716const DRAW_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
1717
1718/// Whether the post-select draw block should run this iteration.
1719#[derive(Debug, PartialEq, Eq)]
1720enum DrawDecision {
1721    /// Run the snapshot/draw pipeline now.
1722    DrawNow,
1723    /// Skip the draw — nothing on screen needs an immediate repaint and
1724    /// the frame cadence has not elapsed yet.
1725    Defer,
1726}
1727
1728/// Pure coalescing decision: `priority` (user input) always wins; otherwise
1729/// we draw once the render-cadence timer has elapsed since the last draw.
1730fn coalesce_draw(
1731    last_draw_at: std::time::Instant,
1732    priority: bool,
1733    min_interval: std::time::Duration,
1734) -> DrawDecision {
1735    if priority || last_draw_at.elapsed() >= min_interval {
1736        DrawDecision::DrawNow
1737    } else {
1738        DrawDecision::Defer
1739    }
1740}
1741
1742#[derive(PartialEq, Eq)]
1743enum LoopOutcome {
1744    Continue,
1745    Exit,
1746}
1747
1748/// Whether an Esc-driven cancel should abort the running stream (via the
1749/// interrupt path, which sets the footer + abort) or exit the app outright
1750/// (idle one-press quit). Extracted as a pure function so the routing can
1751/// be unit-tested without a live `AgentSessionHandle`.
1752#[derive(PartialEq, Eq, Debug)]
1753enum CancelRoute {
1754    /// A stream is running: abort it. The input thread's ~1s post-cancel
1755    /// grace then prevents mashing Esc from firing repeated cancels.
1756    Interrupt,
1757    /// Idle: instant one-press quit — no quit-arming footer, no grace.
1758    Exit,
1759}
1760
1761/// Pure routing decision for `InlineEvent::Cancel`. While a stream is
1762/// running, Esc aborts it (matching Ctrl+C). When idle, Esc quits at once.
1763fn route_cancel(is_streaming: bool) -> CancelRoute {
1764    if is_streaming {
1765        CancelRoute::Interrupt
1766    } else {
1767        CancelRoute::Exit
1768    }
1769}
1770
1771// ─────────────────────────────────────────────────────────────────────────
1772/// Apply a single `InlineCommand` to the render state. Returns `true`
1773/// when the harness has requested a shutdown.
1774fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1775    match cmd {
1776        InlineCommand::AppendLine { kind, segments } => {
1777            state.append_line(kind, segments);
1778        }
1779        InlineCommand::Inline { kind, segment } => {
1780            state.inline_segment(kind, segment);
1781        }
1782        InlineCommand::BeginStream { .. } => {
1783            // A new streamed message opens: drop the anchor so the first
1784            // delta starts a fresh block instead of merging into the
1785            // previous message's rendered lines.
1786            state.stream_anchor = None;
1787        }
1788        InlineCommand::EndStream => {
1789            // The streamed message finalized: release the anchor so the
1790            // finished block can commit to the host scrollback. Travels
1791            // in the command stream after the final ReplaceLast — the
1792            // causal order keeps the anchor pinned through the last
1793            // re-render.
1794            state.stream_anchor = None;
1795        }
1796        InlineCommand::AppendLineBlockStart { kind, segments } => {
1797            state.append_line_new_block(kind, segments);
1798        }
1799        InlineCommand::ReplaceLast { kind, lines, .. } => {
1800            // The anchor records where this message's streamed block
1801            // begins; the markdown re-render replaces the whole block
1802            // from there (the raw stream and the markdown render split
1803            // the same text across different line counts, so a tail-pop
1804            // by count would duplicate or eat lines). Without an anchor
1805            // the lines append — a blind tail-pop could eat unrelated
1806            // transcript history.
1807            let from = state.stream_anchor.unwrap_or(state.transcript.len());
1808            state.transcript.truncate(from);
1809            for line in lines {
1810                state.append_line(kind, line);
1811            }
1812            // Keep the anchor pinned at the block start: every later
1813            // delta of the same message re-renders from here. BeginStream
1814            // clears it when the next message opens.
1815            state.stream_anchor = Some(from);
1816        }
1817        InlineCommand::AppendPastedMessage { kind, text, .. } => {
1818            state.append_line(kind, vec![plain_segment(text)]);
1819        }
1820        InlineCommand::SetPrompt { prefix, .. } => {
1821            state.prompt_prefix = prefix;
1822        }
1823        InlineCommand::SetPlaceholder { hint, .. } => {
1824            state.placeholder = hint;
1825        }
1826        InlineCommand::SetHeaderContext { context } => {
1827            state.header_context = *context;
1828        }
1829        InlineCommand::SetInputStatus { .. } => {
1830            // The dedicated status row was removed; input-status text has
1831            // no render surface. Kept as a graceful no-op for protocol
1832            // compatibility with harnesses that still send it.
1833        }
1834        InlineCommand::SetInputEnabled(enabled) => {
1835            state.input_enabled = enabled;
1836        }
1837        InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1838        InlineCommand::SetReasoningStage(stage) => {
1839            state.reasoning_stage = stage;
1840        }
1841        InlineCommand::SetVimModeEnabled(enabled) => {
1842            state.vim_state.set_enabled(enabled);
1843        }
1844        InlineCommand::SetQueuedInputs { entries } => {
1845            state.queued_inputs = entries;
1846        }
1847        InlineCommand::ShowOverlay { request } => {
1848            let mut overlay = materialize_overlay(*request);
1849            // The `/settings` panel arrives as the flat Task-4 list; its
1850            // rows are the only producers of ConfigAction selections.
1851            // Hydrate the full tabbed/sidebar overlay from the def table
1852            // (reopening on the last active tab) instead. Map-editor row
1853            // metadata rides along with the hydration; every other
1854            // overlay invalidates it.
1855            let mut map_rows = Vec::new();
1856            if overlay.items.iter().any(|it| {
1857                matches!(
1858                    it.selection,
1859                    Some(InlineListSelection::ConfigAction(_))
1860                        | Some(InlineListSelection::SettingsTab(_))
1861                        | Some(InlineListSelection::SettingsSection(_))
1862                        | Some(InlineListSelection::SettingKeyCapture(_))
1863                        | Some(InlineListSelection::SettingTextEdit(_))
1864                        | Some(InlineListSelection::SettingSubmenuOpen(_))
1865                        | Some(InlineListSelection::SettingMultiselect(_))
1866                )
1867            }) {
1868                let (hydrated, rows) = build_settings_overlay(state.settings_active_tab, None);
1869                overlay = hydrated;
1870                map_rows = rows;
1871            }
1872            state.overlay = Some(overlay);
1873            state.settings_map_rows = map_rows;
1874        }
1875        InlineCommand::CloseOverlay => {
1876            state.overlay = None;
1877        }
1878        InlineCommand::Shutdown => {
1879            state.shutdown_requested = true;
1880            return true;
1881        }
1882        _ => {
1883            // Surface unknown commands as info so they are visible
1884            // during development.
1885            tracing::trace!("unhandled InlineCommand (not rendered)");
1886        }
1887    }
1888    false
1889}
1890
1891/// Convert an `OverlayRequest` into the render-state representation used by
1892/// the TUI. The input thread mutates `selected` / `search` while the overlay
1893/// is open, and `handle_inline_event` projects the user's selection back to
1894/// the harness as `InlineEvent::Overlay`.
1895fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1896    match request {
1897        OverlayRequest::Modal(req) => {
1898            let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1899                config: cfg,
1900                editor: EditBuffer::new(),
1901            });
1902            OverlayState {
1903                title: req.title,
1904                lines: req.lines,
1905                items: Vec::new(),
1906                selected: 0,
1907                search: None,
1908                secure_input,
1909                ..Default::default()
1910            }
1911        }
1912        OverlayRequest::List(req) => {
1913            let search = req.search.map(|cfg| OverlaySearchState {
1914                label: cfg.label,
1915                placeholder: cfg.placeholder,
1916                value: String::new(),
1917            });
1918            OverlayState {
1919                title: req.title,
1920                lines: req.lines,
1921                items: req.items.into_iter().map(overlay_item_from).collect(),
1922                selected: 0,
1923                search,
1924                secure_input: None,
1925                ..Default::default()
1926            }
1927        }
1928        OverlayRequest::Wizard(req) => {
1929            // Wizard overlays are multi-step flows that this TUI does not yet
1930            // render natively; surface the first step's title/items so the
1931            // user still sees something instead of a blank panel.
1932            let step_items = req
1933                .steps
1934                .first()
1935                .map(|s| {
1936                    s.items
1937                        .iter()
1938                        .map(|it| overlay_item_from(it.clone()))
1939                        .collect()
1940                })
1941                .unwrap_or_default();
1942            let search = req.search.map(|cfg| OverlaySearchState {
1943                label: cfg.label,
1944                placeholder: cfg.placeholder,
1945                value: String::new(),
1946            });
1947            OverlayState {
1948                title: req.title,
1949                lines: Vec::new(),
1950                items: step_items,
1951                selected: 0,
1952                search,
1953                secure_input: None,
1954                ..Default::default()
1955            }
1956        }
1957    }
1958}
1959fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1960    OverlayListItem {
1961        title: item.title,
1962        subtitle: item.subtitle,
1963        badge: item.badge,
1964        indent: item.indent,
1965        search_value: item.search_value,
1966        selection: item.selection,
1967    }
1968}
1969
1970/// Canonical `/settings` tab order. Indices are the
1971/// `InlineListSelection::SettingsTab(usize)` payloads and
1972/// `OverlayState::active_tab`.
1973const SETTINGS_TABS: &[(SettingsTab, &str)] = &[
1974    (SettingsTab::General, "General"),
1975    (SettingsTab::Model, "Model"),
1976    (SettingsTab::Interaction, "Interaction"),
1977    (SettingsTab::Tools, "Tools"),
1978    (SettingsTab::Ui, "UI"),
1979    (SettingsTab::AdvisorMemory, "Advisor & Memory"),
1980    (SettingsTab::Keybindings, "Keybindings"),
1981    (SettingsTab::Advanced, "Advanced"),
1982];
1983
1984/// Build the full tabbed `/settings` overlay for `tab`: tab-bar labels,
1985/// sidebar section labels (group names, declaration order), and one row
1986/// per def via [`settings_overlay_items`] — the same row builder the
1987/// `/settings` slash command uses, hydrated with the tab/sidebar state.
1988/// `keep_search` preserves the live filter across tab switches.
1989///
1990/// Returns the overlay plus the map-row table (index-aligned with the
1991/// items) for the input thread's `Enter` / `d` / `n` routing.
1992fn build_settings_overlay(
1993    tab: SettingsTab,
1994    keep_search: Option<OverlaySearchState>,
1995) -> (OverlayState, Vec<Option<SettingsMapRow>>) {
1996    let settings = crate::store::settings::Settings::load().unwrap_or_default();
1997    let (items, map_rows) = settings_overlay_items(tab, &settings);
1998    let items: Vec<OverlayListItem> = items.into_iter().map(overlay_item_from).collect();
1999    let mut sections: Vec<String> = Vec::new();
2000    for def in defs_for_tab(tab, &settings) {
2001        if sections.last().map(String::as_str) != Some(def.group) {
2002            sections.push(def.group.to_string());
2003        }
2004    }
2005    let active_tab = SETTINGS_TABS
2006        .iter()
2007        .position(|(t, _)| *t == tab)
2008        .unwrap_or(0);
2009    (
2010        OverlayState {
2011            title: "Settings".into(),
2012            lines: vec!["Browse settings by group; filter with the search bar.".into()],
2013            items,
2014            selected: 0,
2015            search: keep_search.or(Some(OverlaySearchState {
2016                label: "Filter settings".into(),
2017                placeholder: Some("Type to filter".into()),
2018                value: String::new(),
2019            })),
2020            secure_input: None,
2021            tabs: SETTINGS_TABS
2022                .iter()
2023                .map(|(_, name)| name.to_string())
2024                .collect(),
2025            active_tab,
2026            sections,
2027            active_section: 0,
2028            key_capture: None,
2029        },
2030        map_rows,
2031    )
2032}
2033
2034/// Reopen (or switch) the settings panel on `tab`, replacing the first
2035/// context line with `status` when given. Keeps the live search filter,
2036/// syncs `settings_active_tab`, and refreshes the map-row table — the
2037/// single assignment path for the tabbed panel so the rows can never
2038/// drift from the items.
2039fn reopen_settings_panel(state: &mut RenderState, tab: SettingsTab, status: Option<String>) {
2040    let keep_search = state.overlay.as_ref().and_then(|o| o.search.clone());
2041    state.settings_active_tab = tab;
2042    let (mut overlay, map_rows) = build_settings_overlay(tab, keep_search);
2043    if let Some(status) = status {
2044        if overlay.lines.is_empty() {
2045            overlay.lines.push(status);
2046        } else {
2047            overlay.lines[0] = status;
2048        }
2049    }
2050    state.overlay = Some(overlay);
2051    state.settings_map_rows = map_rows;
2052}
2053
2054/// Switch the settings overlay to `SETTINGS_TABS[tab_idx]`: rebuild items
2055/// and sections for that tab (keeping the live search filter) and sync
2056/// `RenderState::settings_active_tab` so a later `/settings` reopens on
2057/// the same tab. No-op when the index is out of range or no overlay is
2058/// open.
2059fn switch_settings_tab(state: &mut RenderState, tab_idx: usize) {
2060    let Some(&(tab, _)) = SETTINGS_TABS.get(tab_idx) else {
2061        return;
2062    };
2063    let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2064    state.settings_active_tab = tab;
2065    let (overlay, map_rows) = build_settings_overlay(tab, search);
2066    state.overlay = Some(overlay);
2067    state.settings_map_rows = map_rows;
2068}
2069
2070/// Jump the settings overlay's selection to the first row of sidebar
2071/// section `section_idx` (an index into `OverlayState::sections`).
2072/// Rebuilds the overlay for the active tab first — submissions arrive
2073/// after the overlay was closed, so the panel has to be reopened anyway.
2074fn jump_settings_section(state: &mut RenderState, section_idx: usize) {
2075    let tab = state.settings_active_tab;
2076    let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2077    let (mut overlay, map_rows) = build_settings_overlay(tab, search);
2078    if let Some(target) = overlay.sections.get(section_idx).cloned() {
2079        // Heading rows (title-only items, per the settings_overlay_items
2080        // convention) delimit groups; the first selectable row after the
2081        // target heading is the section's anchor.
2082        let mut in_target = false;
2083        let mut anchor: Option<usize> = None;
2084        for (idx, item) in overlay.items.iter().enumerate() {
2085            let is_heading =
2086                item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2087            if is_heading {
2088                in_target = item.title == target;
2089            } else if in_target && anchor.is_none() {
2090                anchor = Some(idx);
2091            }
2092        }
2093        if let Some(idx) = anchor {
2094            overlay.selected = idx;
2095            overlay.active_section = section_idx;
2096        }
2097    }
2098    state.overlay = Some(overlay);
2099    state.settings_map_rows = map_rows;
2100}
2101
2102// ─────────────────────────────────────────────────────────────────────────
2103// Keybindings map editor (capture / remove / live swap)
2104// ─────────────────────────────────────────────────────────────────────────
2105
2106/// The "press a key combo" prompt shown after selecting an action row.
2107/// `key_capture` marks capture mode for the input thread.
2108fn build_key_capture_overlay(action_name: &str) -> OverlayState {
2109    OverlayState {
2110        title: format!("Keybinding: {action_name}"),
2111        lines: vec![format!(
2112            "Press a key combo for {action_name} (Esc to cancel)\u{2026}"
2113        )],
2114        key_capture: Some(action_name.to_string()),
2115        ..Default::default()
2116    }
2117}
2118
2119/// Serialize an incoming key event into its canonical `KeyCombo` text.
2120///
2121/// Only `Ctrl` / `Alt` / `Shift` survive (SUPER & co. would never
2122/// round-trip through `KeyCombo::parse`), and a shifted lowercase char
2123/// is uppercased — the same canonicalization `parse` applies — so the
2124/// serialization always round-trips. Kitty note (Task 2): with
2125/// `OXICODE_KITTY_KEYBOARD` the terminal already clears SHIFT on
2126/// shifted chars (they arrive uppercase), which this normalization is
2127/// self-consistent with.
2128fn key_event_to_combo_text(key: KeyEvent) -> Option<(String, KeyCombo)> {
2129    use crossterm::event::KeyCode as Kc;
2130    let mods = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
2131    let code = match key.code {
2132        // Canonical shifted-letter form: uppercase char (crossterm's
2133        // `normalize_case` shape), SHIFT retained.
2134        Kc::Char(c) if c.is_ascii_lowercase() && mods.contains(KeyModifiers::SHIFT) => {
2135            Kc::Char(c.to_ascii_uppercase())
2136        }
2137        other => other,
2138    };
2139    let combo = KeyCombo {
2140        code,
2141        modifiers: mods,
2142    };
2143    let text = combo.to_string();
2144    // Reject anything that cannot round-trip through `KeyCombo::parse`
2145    // (F-keys, arrows, Home/End, …): persisting them would write a
2146    // binding that never resolves.
2147    (KeyCombo::parse(&text) == Some(combo.clone())).then_some((text, combo))
2148}
2149
2150/// Handle the next key while the key-capture prompt is open. Esc (no
2151/// Ctrl/Alt) cancels back to the Keybindings tab; any other key is
2152/// validated (`key_event_to_combo_text` + a Ctrl/Alt requirement, since
2153/// an unmodified key would hijack typing) and, when valid, appended to
2154/// the action's live combo list, persisted, and swapped into
2155/// `RenderState::keymap`. Rejections keep the prompt open with the
2156/// reason as its only line.
2157fn handle_key_capture(state: &mut RenderState, key: KeyEvent) {
2158    let Some(action_name) = state.overlay.as_ref().and_then(|o| o.key_capture.clone()) else {
2159        return;
2160    };
2161    let Some(action) = GlobalAction::from_name(&action_name) else {
2162        // Unreachable unless a capture overlay is built by hand with a
2163        // bogus name — fail closed by closing the prompt.
2164        state.overlay = None;
2165        state.settings_map_rows.clear();
2166        return;
2167    };
2168    // Esc cancels without capturing.
2169    if key.code == KeyCode::Esc
2170        && !key
2171            .modifiers
2172            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2173    {
2174        reopen_settings_panel(state, SettingsTab::Keybindings, None);
2175        return;
2176    }
2177    let Some((text, _combo)) = key_event_to_combo_text(key) else {
2178        set_capture_prompt_line(
2179            state,
2180            "That key can't be captured (F-keys and arrows don't round-trip). \
2181             Try another combo, Esc to cancel\u{2026}",
2182        );
2183        return;
2184    };
2185    if !key
2186        .modifiers
2187        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2188    {
2189        set_capture_prompt_line(
2190            state,
2191            &format!(
2192                "'{text}' has no Ctrl or Alt — it would hijack typing. \
2193                 Try another combo, Esc to cancel\u{2026}"
2194            ),
2195        );
2196        return;
2197    }
2198    // Append to the action's LIVE combo list (defaults + overrides),
2199    // deduped: capturing an already-bound combo is a no-op edit.
2200    let mut combos: Vec<String> = state
2201        .keymap
2202        .read()
2203        .action_combos(action)
2204        .iter()
2205        .map(|c| c.to_string())
2206        .collect();
2207    if !combos.iter().any(|c| c == &text) {
2208        combos.push(text.clone());
2209    }
2210    match commit_keybindings(state, action, combos) {
2211        Ok(()) => reopen_settings_panel(
2212            state,
2213            SettingsTab::Keybindings,
2214            Some(format!("Captured {text} for {action_name}")),
2215        ),
2216        Err(e) => set_capture_prompt_line(
2217            state,
2218            &format!("Failed to save keybindings: {e} — Esc to cancel\u{2026}"),
2219        ),
2220    }
2221}
2222
2223fn set_capture_prompt_line(state: &mut RenderState, line: &str) {
2224    if let Some(overlay) = state.overlay.as_mut() {
2225        overlay.lines = vec![line.to_string()];
2226    }
2227}
2228
2229/// Persist `action`'s new combo list and swap the rebuilt keymap into
2230/// `RenderState::keymap` so the change takes effect on the very next
2231/// keystroke — no restart. The keymap swap only happens after a
2232/// successful save (never persist a binding the disk state disagrees
2233/// with).
2234fn commit_keybindings(
2235    state: &mut RenderState,
2236    action: GlobalAction,
2237    combos: Vec<String>,
2238) -> anyhow::Result<()> {
2239    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2240    crate::tui_vt::settings_defs::set_action_combos(&mut settings, action, combos);
2241    save_settings_sandboxed(state, &settings)?;
2242    *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
2243    Ok(())
2244}
2245
2246/// Persist `settings`, honoring the test-only
2247/// `RenderState::settings_override_path` sandbox: when set, the write
2248/// lands in the tempdir path via `Settings::save_to` instead of the
2249/// real `~/.oxicode/settings.{json,toml}`. Production code paths never
2250/// set the override, so they always take the plain `save()` branch.
2251fn save_settings_sandboxed(
2252    state: &RenderState,
2253    settings: &crate::store::settings::Settings,
2254) -> anyhow::Result<()> {
2255    #[cfg(test)]
2256    {
2257        if let Some(path) = state.settings_override_path.as_ref() {
2258            if let Some(parent) = path.parent() {
2259                std::fs::create_dir_all(parent).ok();
2260            }
2261            return settings.save_to(path);
2262        }
2263    }
2264    let _ = state; // production builds don't read the sandbox field
2265    settings.save()
2266}
2267
2268/// `d` on a keybinding-combo row: remove that combo. Guarded — the
2269/// final combo of an action is refused (an action with zero keys is a
2270/// silent trap: the user could no longer trigger it, or reach this
2271/// panel to fix it). A remove that lands back on the default list
2272/// drops the override entry entirely.
2273fn remove_keybinding_combo(state: &mut RenderState, action: GlobalAction, combo: &str) {
2274    let current: Vec<String> = state
2275        .keymap
2276        .read()
2277        .action_combos(action)
2278        .iter()
2279        .map(|c| c.to_string())
2280        .collect();
2281    if current.len() <= 1 {
2282        reopen_settings_panel(
2283            state,
2284            SettingsTab::Keybindings,
2285            Some(format!(
2286                "Refusing to remove the last combo for {} — add another first (Enter on the \
2287                 action row)",
2288                action.name()
2289            )),
2290        );
2291        return;
2292    }
2293    let next: Vec<String> = current
2294        .iter()
2295        .filter(|c| c.as_str() != combo)
2296        .cloned()
2297        .collect();
2298    if next.len() == current.len() {
2299        // Not bound (stale row) — nothing to do.
2300        return;
2301    }
2302    match commit_keybindings(state, action, next) {
2303        Ok(()) => reopen_settings_panel(
2304            state,
2305            SettingsTab::Keybindings,
2306            Some(format!("Removed {combo} from {}", action.name())),
2307        ),
2308        Err(e) => reopen_settings_panel(
2309            state,
2310            SettingsTab::Keybindings,
2311            Some(format!("Failed to save keybindings: {e}")),
2312        ),
2313    }
2314}
2315
2316// ─────────────────────────────────────────────────────────────────────────
2317// Model roles map editor (n / Enter / d + text prompts)
2318// ─────────────────────────────────────────────────────────────────────────
2319
2320/// Open the unmasked text prompt for a model-role value (Enter on a
2321/// role row). Prefills the current model pattern so Enter-as-no-op is a
2322/// cheap round-trip.
2323fn open_model_role_value_prompt(state: &mut RenderState, role: &str) {
2324    let current = crate::store::settings::Settings::load()
2325        .map(|s| s.model_roles.get(role).cloned())
2326        .ok()
2327        .flatten();
2328    state.secure_input_origin = Some(SecureInputOrigin::ModelRoleValue {
2329        role: role.to_string(),
2330    });
2331    state.overlay = Some(text_prompt_overlay(
2332        format!("Model for role '{role}'"),
2333        "Enter the model pattern (provider/model). Enter saves, Esc cancels.".into(),
2334        "model",
2335        Some("provider/model".into()),
2336        current.as_deref(),
2337    ));
2338    state.settings_map_rows.clear();
2339}
2340
2341/// Open the unmasked text prompt naming a NEW model role (`n`).
2342fn open_model_role_key_prompt(state: &mut RenderState) {
2343    state.secure_input_origin = Some(SecureInputOrigin::ModelRoleKey);
2344    state.overlay = Some(text_prompt_overlay(
2345        "New model role".to_string(),
2346        "Name the role (e.g. fast, reviewer). Enter continues, Esc cancels.".into(),
2347        "role",
2348        Some("role name".into()),
2349        None,
2350    ));
2351    state.settings_map_rows.clear();
2352}
2353
2354/// Single-line unmasked text prompt built directly on the secure-input
2355/// machinery (`mask_input: false` renders the value in the clear).
2356fn text_prompt_overlay(
2357    title: String,
2358    line: String,
2359    label: &str,
2360    placeholder: Option<String>,
2361    prefill: Option<&str>,
2362) -> OverlayState {
2363    let mut editor = EditBuffer::new();
2364    if let Some(text) = prefill {
2365        let _ = editor.insert_str(text);
2366    }
2367    OverlayState {
2368        title,
2369        lines: vec![line],
2370        secure_input: Some(OverlaySecureInput {
2371            config: SecurePromptConfig {
2372                label: label.to_string(),
2373                placeholder,
2374                mask_input: false,
2375            },
2376            editor,
2377        }),
2378        ..Default::default()
2379    }
2380}
2381
2382/// Whether the settings panel (tabbed overlay + fresh map-row table) is
2383/// open — the precondition for the map-editor hotkeys.
2384fn settings_map_editor_active(state: &RenderState) -> bool {
2385    state.overlay.as_ref().is_some_and(|o| {
2386        o.tabs.len() > 1
2387            && o.key_capture.is_none()
2388            && state.settings_map_rows.len() == o.items.len()
2389    })
2390}
2391
2392/// The map-row (if any) currently selected in the settings panel.
2393fn selected_settings_map_row(state: &RenderState) -> Option<SettingsMapRow> {
2394    if !settings_map_editor_active(state) {
2395        return None;
2396    }
2397    let selected = state.overlay.as_ref().map(|o| o.selected)?;
2398    state.settings_map_rows.get(selected).cloned().flatten()
2399}
2400
2401/// `Enter` on a model-role row opens the value prompt. Returns whether
2402/// the key was consumed (input thread only calls this for Enter).
2403fn try_edit_model_role(state: &mut RenderState) -> bool {
2404    match selected_settings_map_row(state) {
2405        Some(SettingsMapRow::ModelRole(role)) => {
2406            open_model_role_value_prompt(state, &role);
2407            true
2408        }
2409        _ => false,
2410    }
2411}
2412
2413/// `d` on a map row: remove a keybinding combo (guarded — see
2414/// [`remove_keybinding_combo`]) or delete a model role. Returns whether
2415/// the key was consumed.
2416fn try_remove_settings_map_row(state: &mut RenderState) -> bool {
2417    match selected_settings_map_row(state) {
2418        Some(SettingsMapRow::KeybindingCombo(action, combo)) => {
2419            remove_keybinding_combo(state, action, &combo);
2420            true
2421        }
2422        Some(SettingsMapRow::ModelRole(role)) => {
2423            let status = match crate::store::settings::Settings::load() {
2424                Ok(mut settings) => {
2425                    let existed =
2426                        crate::tui_vt::settings_defs::remove_model_role(&mut settings, &role);
2427                    match settings.save() {
2428                        Ok(()) if existed => format!("Removed model role '{role}'"),
2429                        Ok(()) => format!("Role '{role}' was already gone"),
2430                        Err(e) => format!("Failed to save model roles: {e}"),
2431                    }
2432                }
2433                Err(e) => format!("Failed to load settings: {e}"),
2434            };
2435            reopen_settings_panel(state, SettingsTab::Model, Some(status));
2436            true
2437        }
2438        _ => false,
2439    }
2440}
2441
2442/// `n` on the Model tab starts a new model role (name first, then the
2443/// model pattern). Returns whether the key was consumed.
2444fn try_start_new_model_role(state: &mut RenderState) -> bool {
2445    if settings_map_editor_active(state) && state.settings_active_tab == SettingsTab::Model {
2446        open_model_role_key_prompt(state);
2447        true
2448    } else {
2449        false
2450    }
2451}
2452
2453// ─────────────────────────────────────────────────────────────────────────
2454// Settings panel editors: Text / SubmenuSelect / Multiselect (Final-fix wave)
2455// ─────────────────────────────────────────────────────────────────────────
2456
2457/// Open the unmasked text-prompt overlay for a `Text` widget row. The
2458/// submitted text is routed through `SecureInputOrigin::TextEdit(key)`
2459/// so the `OverlaySubmission::SecureInput` consumer commits via
2460/// `settings_defs::apply_change` (parse-validated per-key).
2461fn open_text_edit_prompt(state: &mut RenderState, key: SettingKey) {
2462    let current = crate::store::settings::Settings::load()
2463        .map(|s| get_display_value(key, &s))
2464        .unwrap_or_default();
2465    state.secure_input_origin = Some(SecureInputOrigin::TextEdit(key));
2466    state.overlay = Some(text_prompt_overlay(
2467        format!("Edit {}", label_for_setting(key)),
2468        format!(
2469            "Enter the new value for {} (Esc to cancel). Empty clears the override              when the field supports it.",
2470            label_for_setting(key)
2471        ),
2472        "value",
2473        None,
2474        if current == "default" {
2475            None
2476        } else {
2477            Some(current.as_str())
2478        },
2479    ));
2480}
2481
2482/// Open the submenu-select overlay for a `SubmenuSelect` widget row:
2483/// a child list of the widget's allowed strings; the active value is
2484/// marked in the badge.
2485fn open_submenu_select_prompt(state: &mut RenderState, key: SettingKey) {
2486    let Some(options) = submenu_options_for(key) else {
2487        // Defensive: a stray SettingSubmenuOpen against a non-submenu
2488        // key shouldn't happen, but if it does, surface the mismatch
2489        // and reopen the panel so the user is never stuck on a dead
2490        // overlay.
2491        reopen_settings_panel(
2492            state,
2493            state.settings_active_tab,
2494            Some(format!("'{:?}' is not a submenu-select setting", key)),
2495        );
2496        return;
2497    };
2498    let current = crate::store::settings::Settings::load()
2499        .map(|s| get_display_value(key, &s))
2500        .unwrap_or_default();
2501    let items: Vec<OverlayListItem> = options
2502        .iter()
2503        .map(|opt| InlineListItem {
2504            title: (*opt).to_string(),
2505            subtitle: None,
2506            badge: if *opt == current {
2507                Some("current".to_string())
2508            } else {
2509                None
2510            },
2511            indent: 0,
2512            selection: Some(InlineListSelection::ConfigAction(format!(
2513                "SubmenuCommit:{:?}:{}",
2514                key, opt
2515            ))),
2516            search_value: None,
2517        })
2518        .map(overlay_item_from)
2519        .collect();
2520    let label = label_for_setting(key);
2521    state.overlay = Some(OverlayState {
2522        title: format!("Pick value for {label}"),
2523        lines: vec![format!("Esc cancels — current: {current}")],
2524        items,
2525        selected: options.iter().position(|o| *o == current).unwrap_or(0),
2526        ..Default::default()
2527    });
2528    state.settings_map_rows.clear();
2529}
2530
2531/// Source the registered tool list (live `ToolRegistry`) and open the
2532/// multiselect overlay for `DisabledTools`. Essential tools are shown
2533/// with their badge but cannot be toggled off (the input handler
2534/// refuses the toggle with an Error line).
2535fn open_disabled_tools_multiselect(
2536    state: &mut RenderState,
2537    session: &crate::app::agent_session::AgentSessionHandle,
2538) {
2539    let mut tools = session.agent_ref().tools().get_tools();
2540    tools.sort_by(|a, b| a.name().cmp(b.name()));
2541    let settings = crate::store::settings::Settings::load().unwrap_or_default();
2542    let disabled: std::collections::HashSet<String> =
2543        settings.disabled_tools.iter().cloned().collect();
2544    let items: Vec<OverlayListItem> = tools
2545        .iter()
2546        .map(|t| {
2547            let name = t.name();
2548            let is_disabled = disabled.contains(name);
2549            InlineListItem {
2550                title: name.to_string(),
2551                subtitle: Some(t.description().to_string()),
2552                badge: Some(if t.essential() {
2553                    if is_disabled {
2554                        "essential — locked".to_string()
2555                    } else {
2556                        "essential".to_string()
2557                    }
2558                } else if is_disabled {
2559                    "disabled".to_string()
2560                } else {
2561                    "enabled".to_string()
2562                }),
2563                indent: 0,
2564                selection: Some(InlineListSelection::ConfigAction(format!(
2565                    "DisabledToolToggle:{}",
2566                    name
2567                ))),
2568                search_value: None,
2569            }
2570        })
2571        .map(overlay_item_from)
2572        .collect();
2573    let disabled_count = items
2574        .iter()
2575        .filter(|i| {
2576            i.badge
2577                .as_deref()
2578                .map(|b| b == "disabled" || b == "essential — locked")
2579                .unwrap_or(false)
2580        })
2581        .count();
2582    state.overlay = Some(OverlayState {
2583        title: "Disabled tools".into(),
2584        lines: vec![format!(
2585            "{disabled_count} disabled — Enter/Space toggles, Esc closes"
2586        )],
2587        items,
2588        selected: 0,
2589        ..Default::default()
2590    });
2591    state.settings_map_rows.clear();
2592}
2593
2594/// Commit a `Text` widget edit: `apply_change` parses the input,
2595/// `Settings::save` persists, and the panel reopens with a status
2596/// line. Returns the outcome string for the caller to surface. The
2597/// session is required only for `sync_settings_live`; callers that
2598/// don't need live sync (e.g. model defaults — no live propagation
2599/// today) can pass a real handle from `handle_inline_event`'s scope.
2600/// Tests pass `&RenderState::default()` and skip the live sync via
2601/// the `with_session` toggle.
2602fn commit_text_edit(
2603    state: &mut RenderState,
2604    handle: &InlineHandle,
2605    session: Option<&crate::app::agent_session::AgentSessionHandle>,
2606    key: SettingKey,
2607    text: String,
2608) -> (anyhow::Result<()>, String) {
2609    let label = label_for_setting(key);
2610    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2611    let outcome =
2612        crate::tui_vt::settings_defs::apply_change(key, &mut settings, text.trim().to_string())
2613            .and_then(|_| save_settings_sandboxed(state, &settings));
2614    let new_display = get_display_value(key, &settings);
2615    match &outcome {
2616        Ok(()) => {
2617            if let Some(session) = session {
2618                // Best-effort live sync — `apply_change` already validated
2619                // the parse; sync failures don't undo the save.
2620                if let Err(e) = sync_settings_live(state, session, key, &settings) {
2621                    handle.append_line(
2622                        InlineMessageKind::Error,
2623                        vec![plain_segment(format!("{label}: {e}"))],
2624                    );
2625                }
2626            }
2627            let status = format!("{label}: {new_display}");
2628            // Match the ConfigAction path's transcript feedback: a
2629            // saved scalar edit is surfaced as an Info line, not just
2630            // the panel status.
2631            handle.append_line(InlineMessageKind::Info, vec![plain_segment(status.clone())]);
2632            reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2633            (Ok(()), status)
2634        }
2635        Err(e) => {
2636            let msg = format!("{label}: {e}");
2637            handle.append_line(InlineMessageKind::Error, vec![plain_segment(msg.clone())]);
2638            (Err(anyhow::anyhow!("{e}")), msg)
2639        }
2640    }
2641}
2642
2643/// Commit a `SubmenuSelect` widget edit: write the chosen option,
2644/// persist, reopen the panel with the new badge.
2645fn commit_submenu_choice(
2646    state: &mut RenderState,
2647    key: SettingKey,
2648    value: String,
2649) -> anyhow::Result<String> {
2650    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2651    crate::tui_vt::settings_defs::apply_change(key, &mut settings, value.clone())?;
2652    save_settings_sandboxed(state, &settings)?;
2653    let new_display = get_display_value(key, &settings);
2654    let label = label_for_setting(key);
2655    let status = format!("{label}: {new_display}");
2656    reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2657    Ok(status)
2658}
2659
2660/// Toggle one tool in `Settings::disabled_tools`. Returns the outcome
2661/// string for the caller to surface (success or refusal for
2662/// essential tools).
2663fn commit_disabled_tool_toggle(
2664    state: &mut RenderState,
2665    handle: &InlineHandle,
2666    session: &crate::app::agent_session::AgentSessionHandle,
2667    tool: String,
2668    essential: bool,
2669    currently_disabled: bool,
2670) {
2671    let label = label_for_setting(SettingKey::DisabledTools);
2672    if essential {
2673        handle.append_line(
2674            InlineMessageKind::Error,
2675            vec![plain_segment(format!(
2676                "'{tool}' is essential and cannot be disabled"
2677            ))],
2678        );
2679        // Refresh the overlay so the user's failed toggle doesn't show
2680        // a stale badge.
2681        open_disabled_tools_multiselect(state, session);
2682        return;
2683    }
2684    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2685    let new_enabled = currently_disabled; // toggling from disabled → enabled
2686    crate::tui_vt::settings_defs::toggle_disabled_tool(&mut settings, &tool, new_enabled);
2687    match save_settings_sandboxed(state, &settings) {
2688        Ok(()) => {
2689            let new_state = if new_enabled { "enabled" } else { "disabled" };
2690            handle.append_line(
2691                InlineMessageKind::Info,
2692                vec![plain_segment(format!("{label}: '{tool}' {new_state}"))],
2693            );
2694            open_disabled_tools_multiselect(state, session);
2695        }
2696        Err(e) => {
2697            handle.append_line(
2698                InlineMessageKind::Error,
2699                vec![plain_segment(format!("{label}: failed to save: {e}"))],
2700            );
2701        }
2702    }
2703}
2704
2705/// Human label for a SettingKey — mirrors the row label the user
2706/// sees on the panel, used in overlay titles and status messages.
2707fn label_for_setting(key: SettingKey) -> &'static str {
2708    SETTING_DEFS
2709        .iter()
2710        .find(|d| d.key == key)
2711        .map(|d| d.label)
2712        .unwrap_or("setting")
2713}
2714
2715/// Parse a `SettingKey::Debug`-formatted name (the payload the panel
2716/// ships through `InlineListSelection::SettingTextEdit` et al.) back
2717/// into a typed key. Returns `None` for unrecognized names; callers
2718/// must surface that as an Error line (no silent no-op).
2719fn parse_setting_key(name: &str) -> Option<SettingKey> {
2720    SETTING_DEFS
2721        .iter()
2722        .map(|d| d.key)
2723        .find(|k| format!("{k:?}") == name)
2724}
2725
2726/// The allowed option list for a `SubmenuSelect` key, looked up from
2727/// its def. Returns `None` for non-submenu keys.
2728fn submenu_options_for(key: SettingKey) -> Option<&'static [&'static str]> {
2729    SETTING_DEFS
2730        .iter()
2731        .find(|d| d.key == key)
2732        .and_then(|d| match d.widget {
2733            SettingWidget::SubmenuSelect(opts) => Some(opts),
2734            _ => None,
2735        })
2736}
2737
2738/// Sidebar section index an item belongs to: the group of the last
2739/// heading row at or above it. Returns `None` for items outside every
2740/// section (or when the overlay has no sections).
2741fn item_section_idx(overlay: &OverlayState, idx: usize) -> Option<usize> {
2742    let mut current: Option<String> = None;
2743    for (i, item) in overlay.items.iter().enumerate() {
2744        let is_heading =
2745            item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2746        if is_heading {
2747            current = Some(item.title.clone());
2748        }
2749        if i == idx {
2750            return current
2751                .as_deref()
2752                .and_then(|g| overlay.sections.iter().position(|s| s == g));
2753        }
2754    }
2755    None
2756}
2757
2758/// Next variant string for a `Cycle` widget row — the value an Enter
2759/// submits to `settings_defs::apply_change`.
2760fn next_cycle_value(key: SettingKey, s: &crate::store::settings::Settings) -> Option<String> {
2761    match key {
2762        // Mirrors `AgentSession::cycle_thinking_level`'s order.
2763        SettingKey::ThinkingLevel => {
2764            const LEVELS: [&str; 6] = ["off", "minimal", "low", "medium", "high", "xhigh"];
2765            let cur = get_display_value(key, s);
2766            let idx = LEVELS.iter().position(|l| *l == cur).unwrap_or(0);
2767            Some(LEVELS[(idx + 1) % LEVELS.len()].to_string())
2768        }
2769        SettingKey::GlyphSet => Some(s.glyph_set.next().to_string()),
2770        SettingKey::EditFormat => Some(
2771            if get_display_value(key, s) == "hashline" {
2772                "str_replace"
2773            } else {
2774                "hashline"
2775            }
2776            .to_string(),
2777        ),
2778        _ => None,
2779    }
2780}
2781
2782/// Live-sync the handful of settings the open session / render state read
2783/// eagerly, so an overlay edit takes effect without a restart. Everything
2784/// else is re-read from disk on the next turn
2785/// (`AgentSession::rebuild_system_prompt` already reloads on demand).
2786///
2787/// Returns `Err` only when a live propagation genuinely failed (the
2788/// advisor toggle can refuse to start/stop) — the caller surfaces that
2789/// instead of a silent success; the disk value is already saved at that
2790/// point, so the message says what the user must do (restart).
2791fn sync_settings_live(
2792    state: &mut RenderState,
2793    session: &crate::app::agent_session::AgentSessionHandle,
2794    key: SettingKey,
2795    settings: &crate::store::settings::Settings,
2796) -> anyhow::Result<()> {
2797    match key {
2798        SettingKey::ThinkingLevel => {
2799            session.set_thinking_level(settings.thinking_level);
2800            state.thinking_level = get_display_value(key, settings);
2801        }
2802        SettingKey::GlyphSet => state.glyph_set = settings.glyph_set,
2803        SettingKey::AutoCompaction => session.set_auto_compaction(settings.auto_compaction),
2804        SettingKey::AdvisorEnabled if session.is_advisor_enabled() != settings.advisor.enabled => {
2805            session
2806                .set_advisor_enabled(settings.advisor.enabled)
2807                .map_err(|e| {
2808                    anyhow::anyhow!(
2809                        "failed to {} the advisor live: {e} (saved; restart to apply)",
2810                        if settings.advisor.enabled {
2811                            "enable"
2812                        } else {
2813                            "disable"
2814                        }
2815                    )
2816                })?;
2817        }
2818        _ => {}
2819    }
2820    Ok(())
2821}
2822
2823/// Map a `SessionEvent` to the matching `InlineHandle` calls. This is the
2824/// single place where the agent's event vocabulary meets the harness's
2825/// transcript vocabulary.
2826fn handle_session_event(
2827    state: &mut RenderState,
2828    handle: &InlineHandle,
2829    event: &SessionEvent,
2830    session: Option<&crate::app::agent_session::AgentSessionHandle>,
2831) {
2832    match event {
2833        SessionEvent::Agent(boxed) => {
2834            let event = *boxed.clone();
2835            if let (AgentEvent::Error { message, .. }, Some(session)) = (&event, session)
2836                && is_missing_api_key_error(message)
2837            {
2838                let provider = provider_from_model_id(&session.model_id());
2839                handle.append_line(
2840                    InlineMessageKind::Info,
2841                    vec![plain_segment(format!(
2842                        "Authentication is required for '{provider}'. Enter an API key to continue."
2843                    ))],
2844                );
2845                open_secure_prompt(state, handle, SecureInputOrigin::SetKey { provider });
2846            }
2847            map_agent_event(handle, event, state);
2848        }
2849        SessionEvent::CompactionStart { .. } => {
2850            handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
2851        }
2852        SessionEvent::CompactionEnd { error_message, .. } => {
2853            handle.set_reasoning_stage(None);
2854            if let Some(msg) = error_message {
2855                handle.append_line(
2856                    InlineMessageKind::Error,
2857                    vec![plain_segment(format!("Compaction failed: {msg}"))],
2858                );
2859            }
2860        }
2861        SessionEvent::ThinkingLevelChanged { level } => {
2862            state.thinking_level = format!("{level:?}").to_ascii_lowercase();
2863        }
2864        SessionEvent::QueueUpdate { .. } => {
2865            // Surface the queue length as a footer status update.
2866            // The exact count is computed lazily by the agent session;
2867            // we approximate it via the snapshot we hold.
2868            let pending = state.transcript.len();
2869            handle.set_input_status(
2870                None,
2871                Some(if pending == 0 {
2872                    "ready".to_string()
2873                } else {
2874                    "queued".to_string()
2875                }),
2876            );
2877        }
2878        SessionEvent::Advisor { body, .. } => {
2879            handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
2880        }
2881        SessionEvent::SessionInfoChanged => {
2882            // The session name is reflected via header context on next
2883            // `set_header_context`. Nothing to do here.
2884        }
2885        SessionEvent::HandoffComplete { .. } => {
2886            // Intercepted in the event loop's session_rx arm before
2887            // reaching this function — transcript clearing and prompt
2888            // submission happen there. This arm exists for exhaustiveness.
2889        }
2890        SessionEvent::HandoffFailed { error } => {
2891            handle.append_line(
2892                InlineMessageKind::Error,
2893                vec![plain_segment(format!("Handoff failed: {}", error))],
2894            );
2895        }
2896    }
2897}
2898
2899/// Whether a provider failure means the active credential is absent. Keep this
2900/// deliberately narrow: transport, quota, and invalid-key errors must remain
2901/// visible as errors instead of unexpectedly opening a credential prompt.
2902fn is_missing_api_key_error(message: &str) -> bool {
2903    let message = message.to_ascii_lowercase();
2904    message.contains("missing api key") || message.contains("api key is required")
2905}
2906
2907/// The agent model id is always represented as `provider/model`. A malformed
2908/// legacy id still gets a usable, explicit destination for the credential UI.
2909fn provider_from_model_id(model_id: &str) -> String {
2910    model_id
2911        .split_once('/')
2912        .map(|(provider, _)| provider)
2913        .filter(|provider| !provider.is_empty())
2914        .unwrap_or("provider")
2915        .to_string()
2916}
2917
2918/// Push the active model into every render surface that shows it: the
2919/// composer's MODEL field (`header_context`) and the CTX denominator.
2920///
2921/// Before this, both were written once at startup and went stale: the
2922/// MODEL chip kept the boot model after `/model`, and `context_window`
2923/// kept its 128_000 default forever — a 1M-context model showed a
2924/// wrong CTX total for the whole session.
2925pub(crate) fn apply_model_to_chips(state: &mut RenderState, model_id: &str, ctx_window: usize) {
2926    if model_id.is_empty() {
2927        return;
2928    }
2929    state.header_context.provider = provider_from_model_id(model_id);
2930    state.header_context.model = model_id.to_string();
2931    state.header_context.editor_context = Some(model_id.to_string());
2932    if ctx_window > 0 {
2933        state.context_window = ctx_window;
2934    }
2935}
2936
2937/// [`apply_model_to_chips`] sourced from the live session.
2938pub(crate) fn sync_model_chips(
2939    state: &mut RenderState,
2940    session: &crate::app::agent_session::AgentSessionHandle,
2941) {
2942    apply_model_to_chips(state, &session.model_id(), session.context_window());
2943}
2944/// Render the in-flight message: the dimmed italic thinking block (one
2945/// line per explicit newline, reasoning-styled) above the markdown-rendered
2946/// answer. Re-rendered whole on every reveal step so the live view equals
2947/// the final render — but only for the REVEALED prefix of the body (see
2948/// [`advance_stream_reveal`]).
2949fn render_streamed_message(state: &mut RenderState) -> Vec<Vec<InlineSegment>> {
2950    let mut lines = Vec::new();
2951    if !state.thinking_buffer.is_empty() {
2952        let styles = active_styles();
2953        let mut style = InlineTextStyle::default();
2954        style.color = styles.reasoning.get_fg_color();
2955        style.effects |= anstyle::Effects::DIMMED | anstyle::Effects::ITALIC;
2956        for chunk in state.thinking_buffer.split('\n') {
2957            lines.push(vec![InlineSegment {
2958                text: chunk.to_string(),
2959                style: Arc::new(style.clone()),
2960            }]);
2961        }
2962        // One blank row breathes between the thinking block and the
2963        // answer — only once the answer has started streaming.
2964        if !state.message_buffer.is_empty() {
2965            lines.push(vec![plain_segment("")]);
2966        }
2967    }
2968    let body = revealed_stream_body(state).to_string();
2969    if !body.is_empty() {
2970        // Tables pre-compute their geometry, so they must know the real
2971        // content width — a table built wider wraps at the terminal
2972        // edge and every border row breaks.
2973        let (_, content_w) = super::frame_layout::scrollback_geometry(Rect {
2974            x: 0,
2975            y: 0,
2976            width: state.viewport_width,
2977            height: 24,
2978        });
2979        lines.extend(oxicode_vtui::tui::ui::markdown::render_markdown_cached(
2980            &body,
2981            content_w as usize,
2982            &mut state.md_cache,
2983        ));
2984    }
2985    lines
2986}
2987
2988/// The portion of the streamed body currently revealed by the
2989/// typewriter (char-boundary-safe). `usize::MAX` reveals everything.
2990fn revealed_stream_body(state: &RenderState) -> &str {
2991    if state.stream_reveal == usize::MAX {
2992        &state.message_buffer
2993    } else {
2994        let idx = floor_char_boundary(&state.message_buffer, state.stream_reveal);
2995        &state.message_buffer[..idx]
2996    }
2997}
2998
2999/// Largest char-boundary index `<= i` (std's `floor_char_boundary` is
3000/// still unstable).
3001fn floor_char_boundary(s: &str, mut i: usize) -> usize {
3002    if i >= s.len() {
3003        return s.len();
3004    }
3005    while !s.is_char_boundary(i) {
3006        i -= 1;
3007    }
3008    i
3009}
3010
3011/// Advance the typewriter one frame and paint the newly revealed text
3012/// into the streamed block. Returns `true` when something was painted.
3013///
3014/// Network chunks land in `message_buffer` whole; painting them whole
3015/// made the transcript jump in lumps. The reveal advances per render
3016/// tick (50 ms) by `remaining / 6` (min 8 bytes), so any backlog drains
3017/// in a handful of frames while steady streams type out at their
3018/// arrival pace. The final authoritative paint at `MessageEnd` reveals
3019/// everything at once.
3020fn advance_stream_reveal(state: &mut RenderState) -> bool {
3021    if state.stream_anchor.is_none() || state.stream_reveal == usize::MAX {
3022        return false;
3023    }
3024    let len = state.message_buffer.len();
3025    if state.stream_reveal >= len {
3026        state.stream_reveal = len;
3027        return false;
3028    }
3029    let remaining = len - state.stream_reveal;
3030    let step = (remaining / 6).max(8);
3031    let target = (state.stream_reveal + step).min(len);
3032    state.stream_reveal = floor_char_boundary(&state.message_buffer, target);
3033    // Paint: replace the streamed block with the revealed prefix — the
3034    // same mutation `InlineCommand::ReplaceLast` applies.
3035    let lines = render_streamed_message(state);
3036    let from = state.stream_anchor.unwrap_or(state.transcript.len());
3037    state.transcript.truncate(from);
3038    for line in lines {
3039        state.append_line(InlineMessageKind::Agent, line);
3040    }
3041    state.stream_anchor = Some(from);
3042    true
3043}
3044
3045/// Project the agent-level event variants onto the harness transcript.
3046/// One-line human preview of a tool call's arguments: the command for
3047/// shell tools, key=value pairs otherwise, bounded to the transcript
3048/// width. "Which command ran" is the single most useful fact about a
3049/// tool call — peers (Claude Code, pi, OpenCode) all surface it.
3050fn tool_args_preview(args: &serde_json::Value) -> String {
3051    use serde_json::Value;
3052    let raw = match args {
3053        Value::Null => return String::new(),
3054        Value::String(s) => s.clone(),
3055        Value::Object(map) => {
3056            if let Some(Value::String(cmd)) = map.get("command") {
3057                cmd.clone()
3058            } else {
3059                map.iter()
3060                    .filter_map(|(k, v)| match v {
3061                        Value::String(s) => Some(format!("{k}={s}")),
3062                        _ => None,
3063                    })
3064                    .take(3)
3065                    .collect::<Vec<_>>()
3066                    .join(" ")
3067            }
3068        }
3069        other => other.to_string(),
3070    };
3071    if raw.chars().count() > 72 {
3072        let head: String = raw.chars().take(71).collect();
3073        format!("{head}\u{2026}")
3074    } else {
3075        raw
3076    }
3077}
3078/// Tool box content width: the LIVE transcript content width (layout
3079/// gutters), floored so narrow terminals still draw a coherent box.
3080/// Building at the terminal width would wrap every row's right border
3081/// onto the next visual line.
3082fn tool_box_width(state: &RenderState) -> usize {
3083    let area = Rect {
3084        x: 0,
3085        y: 0,
3086        width: state.viewport_width,
3087        height: 24,
3088    };
3089    let (_x, w) = super::frame_layout::scrollback_geometry(area);
3090    w.max(24) as usize
3091}
3092
3093fn border_segment(text: impl Into<String>, color: anstyle::Color) -> InlineSegment {
3094    let mut style = InlineTextStyle::default();
3095    style.color = Some(color);
3096    InlineSegment {
3097        text: text.into(),
3098        style: Arc::new(style),
3099    }
3100}
3101
3102/// `╭────╮` — rounded top border, no interior fill.
3103fn tool_box_top(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3104    vec![border_segment(
3105        format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(w.saturating_sub(2))),
3106        color,
3107    )]
3108}
3109
3110/// `╰────╯` — rounded bottom border.
3111fn tool_box_bottom(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3112    vec![border_segment(
3113        format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(w.saturating_sub(2))),
3114        color,
3115    )]
3116}
3117
3118/// `├── Output ───┤` — section divider with a label, omp-style.
3119fn tool_box_divider(label: &str, w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3120    let text = format!(" {label} ");
3121    let dashes = w
3122        .saturating_sub(2)
3123        .saturating_sub(text.chars().count())
3124        .saturating_sub(2);
3125    vec![border_segment(
3126        format!(
3127            "\u{251C}\u{2500}{}{}\u{2500}\u{2524}",
3128            text,
3129            "\u{2500}".repeat(dashes)
3130        ),
3131        color,
3132    )]
3133}
3134
3135/// `│ text │` rows with the right border aligned at `w`. Long text
3136/// hard-wraps at the inner width; explicit newlines open new rows.
3137fn tool_box_rows(
3138    text: &str,
3139    w: usize,
3140    style: InlineTextStyle,
3141    color: anstyle::Color,
3142) -> Vec<Vec<InlineSegment>> {
3143    let inner = w.saturating_sub(4).max(1);
3144    text.split('\n')
3145        .map(|line| expand_tabs(line, TAB_WIDTH))
3146        .flat_map(|line| wrap_by_display_width(&line, inner))
3147        .map(|chunk| {
3148            // Pad by DISPLAY width — CJK chars occupy two cells, so a
3149            // char-count pad misaligns the right border on Korean text.
3150            let pad = inner.saturating_sub(chunk.width());
3151            vec![
3152                border_segment("\u{2502} ", color),
3153                InlineSegment {
3154                    text: chunk,
3155                    style: Arc::new(style.clone()),
3156                },
3157                border_segment(format!("{} \u{2502}", " ".repeat(pad)), color),
3158            ]
3159        })
3160        .collect()
3161}
3162
3163/// Hard-wrap a line into chunks of at most `inner` DISPLAY cells
3164/// (Korean/CJK glyphs count as 2). Zero-width chars never break a chunk.
3165fn wrap_by_display_width(line: &str, inner: usize) -> Vec<String> {
3166    use unicode_width::UnicodeWidthChar as _;
3167    if line.width() <= inner {
3168        return vec![line.to_string()];
3169    }
3170    let mut out: Vec<String> = Vec::new();
3171    let mut cur = String::new();
3172    let mut cur_w = 0usize;
3173    for ch in line.chars() {
3174        let ch_w = ch.width().unwrap_or(0);
3175        if cur_w + ch_w > inner && !cur.is_empty() {
3176            out.push(std::mem::take(&mut cur));
3177            cur_w = 0;
3178        }
3179        cur.push(ch);
3180        cur_w += ch_w;
3181    }
3182    if !cur.is_empty() {
3183        out.push(cur);
3184    }
3185    out
3186}
3187
3188/// Tab stop for box-content expansion. Tool output (e.g. the read tool's
3189/// `{:>6}\t{line}` numbering) carries literal tabs; ratatui drops them when
3190/// filling cells while unicode-width 0.2 counts them as 1 — a row padded
3191/// with tab width in its math renders one column short. Expand tabs to the
3192/// next stop so builder and renderer agree on every cell.
3193const TAB_WIDTH: usize = 4;
3194
3195/// Expand tabs to spaces at `TAB_WIDTH` display-column stops.
3196fn expand_tabs(line: &str, tab_width: usize) -> String {
3197    use unicode_width::UnicodeWidthChar as _;
3198    if !line.contains('\t') {
3199        return line.to_string();
3200    }
3201    let mut out = String::with_capacity(line.len() + tab_width);
3202    let mut col = 0usize;
3203    for ch in line.chars() {
3204        if ch == '\t' {
3205            let spaces = tab_width - (col % tab_width);
3206            for _ in 0..spaces {
3207                out.push(' ');
3208                col += 1;
3209            }
3210        } else {
3211            out.push(ch);
3212            col += ch.width().unwrap_or(1).max(1);
3213        }
3214    }
3215    out
3216}
3217
3218/// Diff rows for a tool box: colored +/- lines plus a diffstat header.
3219/// Returns `None` when the content is not a recognizable diff.
3220fn diff_rows(content: &str) -> Option<Vec<(String, InlineTextStyle)>> {
3221    let lines: Vec<&str> = content.lines().collect();
3222    // Require a unified-diff hunk header (`@@ … @@`) as a strong signal that
3223    // the content is actually a diff — prevents grep context lines, bullet
3224    // lists, and shell output from being mis-rendered as deletions.
3225    if !lines.iter().any(|l| l.starts_with("@@")) {
3226        return None;
3227    }
3228    let additions = lines
3229        .iter()
3230        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
3231        .count();
3232    let deletions = lines
3233        .iter()
3234        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
3235        .count();
3236    if additions + deletions < 2 {
3237        return None;
3238    }
3239
3240    let styles = active_styles();
3241    let green = styles.secondary.get_fg_color();
3242    let red = styles.error.get_fg_color();
3243    const MAX_DIFF_LINES: usize = 30;
3244
3245    let mut rows: Vec<(String, InlineTextStyle)> = Vec::new();
3246    let mut hdr = InlineTextStyle::default();
3247    hdr.effects |= anstyle::Effects::DIMMED;
3248    rows.push((format!("diff +{additions} -{deletions}"), hdr));
3249    for line in lines.iter().take(MAX_DIFF_LINES) {
3250        let mut style = InlineTextStyle::default();
3251        if line.starts_with('+') && !line.starts_with("+++") {
3252            style.color = green;
3253        } else if line.starts_with('-') && !line.starts_with("---") {
3254            style.color = red;
3255        } else {
3256            style.effects |= anstyle::Effects::DIMMED;
3257        }
3258        rows.push(((*line).to_string(), style));
3259    }
3260    if lines.len() > MAX_DIFF_LINES {
3261        let mut more = InlineTextStyle::default();
3262        more.effects |= anstyle::Effects::DIMMED;
3263        rows.push((
3264            format!("\u{2026} +{} lines", lines.len() - MAX_DIFF_LINES),
3265            more,
3266        ));
3267    }
3268    Some(rows)
3269}
3270
3271fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
3272    match event {
3273        AgentEvent::TextChunk { text } => {
3274            state.reasoning_stage = Some("generating response".to_string());
3275            state.message_buffer.push_str(&text);
3276            handle.inline(InlineMessageKind::Agent, plain_segment(text));
3277        }
3278        AgentEvent::AgentStart { .. } => {
3279            // The run is live until the matching AgentEnd. The tracker —
3280            // not the stage label — owns the indicator row, so the row
3281            // survives the per-turn stage clears of a tool loop.
3282            state.active_run = Some(RunState::default());
3283        }
3284        AgentEvent::MessageStart { .. } => {
3285            if let Some(run) = &mut state.active_run {
3286                run.turn += 1;
3287            }
3288            state.reasoning_stage = Some("generating response".to_string());
3289            state.message_buffer.clear();
3290            state.thinking_buffer.clear();
3291            state.stream_reveal = 0;
3292            // The stream boundary travels in the command stream so the
3293            // anchor lifecycle shares one causal order with Inline and
3294            // ReplaceLast — a direct state write here would race batched
3295            // command application.
3296            handle.begin_stream(InlineMessageKind::Agent);
3297        }
3298        AgentEvent::MessageUpdate { delta, .. } => match &delta {
3299            oxicode_sdk::StreamDelta::Text(text) => {
3300                // The Text delta is the lifecycle owner of the visible
3301                // answer: the first one transitions the reasoning stage
3302                // off `thinking…` into `generating response`. Raw
3303                // `MessageUpdate { delta: Text }` is the live streaming
3304                // path (oxicode-agent/src/agent_loop/streaming.rs:277-280).
3305                state.reasoning_stage = Some("generating response".to_string());
3306                state.message_buffer.push_str(text);
3307                handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3308            }
3309            oxicode_sdk::StreamDelta::Thinking(text) => {
3310                // The reasoning text renders as a dimmed italic block above
3311                // the answer (peer parity: Claude Code / pi). The stage
3312                // indicator keeps a fixed `thinking…` label — streaming raw
3313                // fragments into `reasoning_stage` would leak them through
3314                // the composer `RUN ` field and the indicator row.
3315                state.reasoning_stage = Some("thinking\u{2026}".to_string());
3316                state.thinking_buffer.push_str(text);
3317                handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3318            }
3319            oxicode_sdk::StreamDelta::Sync => {
3320                // Re-render the complete message as markdown
3321                if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3322                    handle.replace_last(
3323                        0,
3324                        InlineMessageKind::Agent,
3325                        render_streamed_message(state),
3326                    );
3327                    state.message_buffer.clear();
3328                    state.thinking_buffer.clear();
3329                    state.stream_reveal = 0;
3330                }
3331            }
3332        },
3333        AgentEvent::MessageEnd { message } => {
3334            // Between turns of a live tool loop the stage is briefly
3335            // `None`; the run tracker keeps the indicator row up (the
3336            // renderer falls back to `working…`). Only a finished run
3337            // releases the row to follow-ups / tips.
3338            if state.active_run.is_none() {
3339                state.reasoning_stage = None;
3340            }
3341            // Authoritative final render: the Done message REPLACES the
3342            // accumulated partial in agent_loop/streaming.rs, so the
3343            // final message — not the delta buffers — carries the
3344            // complete text. Providers can coalesce the stream tail
3345            // into it without a matching delta; rendering from the
3346            // buffers lost that tail until the next prompt rebuilt
3347            // history from the session.
3348            if let oxicode_ai::Message::Assistant(a) = &message {
3349                state.thinking_buffer = a
3350                    .content
3351                    .iter()
3352                    .filter_map(|b| b.as_thinking().map(|t| t.thinking.clone()))
3353                    .collect();
3354                state.message_buffer = a.text_content();
3355                state.stream_reveal = usize::MAX;
3356            }
3357            if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3358                handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3359                state.message_buffer.clear();
3360                state.thinking_buffer.clear();
3361            }
3362            // The message is final: release the anchor in the command
3363            // stream (after the final ReplaceLast above) so the finished
3364            // block becomes committable to the host scrollback.
3365            handle.end_stream();
3366        }
3367        AgentEvent::ToolExecutionStart {
3368            tool_name, args, ..
3369        } => {
3370            // omp-style tool box: rounded border, no fill, the call in
3371            // the header — "which command ran" is the headline fact.
3372            let styles = active_styles();
3373            let border = styles
3374                .tool
3375                .get_fg_color()
3376                .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White));
3377            let w = tool_box_width(state);
3378            let header = match args.get("command").and_then(|v| v.as_str()) {
3379                Some(cmd) => format!("$ {cmd}"),
3380                None => {
3381                    let preview = tool_args_preview(&args);
3382                    if preview.is_empty() {
3383                        tool_name.clone()
3384                    } else {
3385                        format!("{tool_name}  {preview}")
3386                    }
3387                }
3388            };
3389            handle.append_line_block_start(InlineMessageKind::Tool, tool_box_top(w, border));
3390            for row in tool_box_rows(&header, w, InlineTextStyle::default(), border) {
3391                handle.append_line(InlineMessageKind::Tool, row);
3392            }
3393            let stage = format!("tool: {tool_name}");
3394            if let Some(run) = &mut state.active_run {
3395                run.tool_calls += 1;
3396            }
3397            state.reasoning_stage = Some(stage.clone());
3398            handle.set_reasoning_stage(Some(stage));
3399        }
3400        AgentEvent::ToolExecutionEnd {
3401            tool_name,
3402            result,
3403            is_error,
3404            ..
3405        } => {
3406            // Close the box: a labeled divider separates the call from
3407            // its output (errors redden the border and the label), then
3408            // the bottom border. Diffs render colored inside the box.
3409            let styles = active_styles();
3410            let (border, label) = if is_error {
3411                (
3412                    styles
3413                        .error
3414                        .get_fg_color()
3415                        .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3416                    "Error",
3417                )
3418            } else {
3419                (
3420                    styles
3421                        .tool
3422                        .get_fg_color()
3423                        .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3424                    "Output",
3425                )
3426            };
3427            let w = tool_box_width(state);
3428            handle.append_line(InlineMessageKind::Tool, tool_box_divider(label, w, border));
3429            // Inline image preview: a successful generate_image result
3430            // renders the text-fallback row here (this is what the
3431            // scrollback keeps); the decoded PNG is queued so the
3432            // post-draw step can transmit + place the real pixels over
3433            // the LIVE rows only. Unsupported terminals and the
3434            // `inline_images = false` kill-switch degrade to this text.
3435            let embedded_png = if tool_name == "generate_image" && !is_error {
3436                extract_generated_png(&result.content)
3437            } else {
3438                None
3439            };
3440            if let Some(png) = embedded_png {
3441                let id = super::image_preview::content_hash_id(&png);
3442                let label = format!("generate_image:{id:08x}");
3443                let mut dim = InlineTextStyle::default();
3444                dim.effects |= anstyle::Effects::DIMMED;
3445                let fallback = super::image_preview::text_fallback(&label);
3446                for row in tool_box_rows(&fallback, w, dim, border) {
3447                    handle.append_line(InlineMessageKind::Tool, row);
3448                }
3449                // The row index is resolved later, at render time — the
3450                // append command is still in the harness channel.
3451                state
3452                    .image_previews
3453                    .enqueue(id, std::sync::Arc::new(png), label);
3454            } else if let Some(rows) = diff_rows(&result.content) {
3455                for (text, style) in rows {
3456                    for row in tool_box_rows(&text, w, style, border) {
3457                        handle.append_line(InlineMessageKind::Tool, row);
3458                    }
3459                }
3460            } else {
3461                const MAX_BOX_LINES: usize = 12;
3462                let preview = preview_tool_result(&result.content);
3463                let lines: Vec<&str> = preview.split('\n').collect();
3464                let mut dim = InlineTextStyle::default();
3465                dim.effects |= anstyle::Effects::DIMMED;
3466                for line in lines.iter().take(MAX_BOX_LINES) {
3467                    for row in tool_box_rows(line, w, dim.clone(), border) {
3468                        handle.append_line(InlineMessageKind::Tool, row);
3469                    }
3470                }
3471                if lines.len() > MAX_BOX_LINES {
3472                    let more = format!("\u{2026} +{} lines", lines.len() - MAX_BOX_LINES);
3473                    for row in tool_box_rows(&more, w, dim, border) {
3474                        handle.append_line(InlineMessageKind::Tool, row);
3475                    }
3476                }
3477            }
3478            handle.append_line(InlineMessageKind::Tool, tool_box_bottom(w, border));
3479            state.reasoning_stage = Some("generating response".to_string());
3480            handle.set_reasoning_stage(Some("generating response".to_string()));
3481            handle.set_input_enabled(true);
3482        }
3483        AgentEvent::Error { message, .. } => {
3484            handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
3485            state.active_run = None;
3486            state.reasoning_stage = None;
3487            handle.set_input_enabled(true);
3488            handle.set_input_status(None, None);
3489        }
3490        AgentEvent::Compaction { .. } => {
3491            // Detailed lifecycle is handled by the AgentSession layer
3492            // (CompactionStart/End SessionEvents).
3493        }
3494        AgentEvent::Cancelled => {
3495            state.active_run = None;
3496            state.reasoning_stage = None;
3497            handle.set_input_enabled(true);
3498            handle.set_input_status(None, Some("cancelled".to_string()));
3499        }
3500        AgentEvent::AutoRetryStart {
3501            attempt,
3502            max_attempts,
3503            ..
3504        } => {
3505            state.reasoning_stage = Some(format!("retrying {attempt} of {max_attempts}"));
3506            handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
3507        }
3508        AgentEvent::TurnEnd { .. } => {
3509            // Notify via the terminal's best-supported desktop-notification
3510            // protocol (OSC 9/99/777, falling back to BEL) so the user
3511            // notices a finished turn even when the window is unfocused.
3512            crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
3513            // The next queued prompt (if any) now starts running — drop it
3514            // from the visible queue pane so the pane only shows still-pending
3515            // inputs.
3516            state.drain_queue_head();
3517            // Mid-run TurnEnds (a tool loop turn boundary) must not clear
3518            // the stage through the command path either — the run tracker
3519            // owns the row until AgentEnd.
3520            if state.active_run.is_none() {
3521                handle.set_reasoning_stage(None);
3522            }
3523        }
3524        AgentEvent::Usage { input_tokens, .. } => {
3525            // `input_tokens` is the provider's tokenization of the complete
3526            // prompt for this turn, so it is a useful live snapshot of the
3527            // context currently occupying the window (unlike a character
3528            // count or a local approximation).
3529            state.context_tokens = Some(input_tokens);
3530        }
3531        AgentEvent::AgentEnd { .. } => {
3532            // The run is over: release the indicator row to follow-ups /
3533            // tips and reset the tracker.
3534            state.active_run = None;
3535            state.reasoning_stage = None;
3536            handle.set_reasoning_stage(None);
3537        }
3538        AgentEvent::TodoReminder { open, attempt, max } => {
3539            // Commit a visible banner of *why* the agent kept going; the
3540            // injected user turn itself is hidden (UserMessage::hidden).
3541            let header = format!(
3542                "⚠ {} incomplete todo{} — reminder {attempt}/{max}",
3543                open.len(),
3544                if open.len() == 1 { "" } else { "s" }
3545            );
3546            handle.append_line(InlineMessageKind::Warning, vec![plain_segment(header)]);
3547            for t in &open {
3548                handle.append_line(
3549                    InlineMessageKind::Warning,
3550                    vec![plain_segment(format!("  ☐ {}", t.content))],
3551                );
3552            }
3553        }
3554        _ => {
3555            // Other variants (TurnStart, Compaction, ToolCallDelta, …) are
3556            // logged but not rendered — they're either metadata or covered
3557            // by the dedicated SessionEvent variants above.
3558            tracing::debug!(?event, "ignored AgentEvent variant");
3559        }
3560    }
3561}
3562
3563/// Decide which `/providers` actions apply for a provider, given whether
3564/// the user already has a stored credential and whether the provider
3565/// supports the OAuth `authorization_code` flow.
3566///
3567/// Single-action branches skip the menu entirely and drive directly
3568/// (no user-visible "Pick an action" list for the obvious cases).
3569pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
3570    match (has_key, oauth_capable) {
3571        (true, true) => vec![
3572            AuthAction::SetApiKey,
3573            AuthAction::StartOAuth,
3574            AuthAction::RemoveKey,
3575        ],
3576        (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
3577        (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
3578        (false, false) => vec![AuthAction::SetApiKey],
3579    }
3580}
3581
3582/// Open a masked secure prompt and stash the `origin` so the
3583/// `OverlaySubmission::SecureInput` consumer can route the key to the
3584/// right provider slot and emit a contextual follow-up message.
3585///
3586/// Shared by:
3587/// - `handle_auth_action::SetApiKey` (replace or first-time key entry)
3588/// - `add_custom_provider` (chain immediately after persisting a new
3589///   custom provider so the user does not have to navigate back)
3590///
3591/// The caller must consume the boolean return value the same way it does
3592/// for `handle_auth_action`: `true` means a new overlay was opened, so
3593/// the previously-open overlay must NOT be closed in the same submit
3594pub(crate) fn open_secure_prompt(
3595    state: &mut RenderState,
3596    handle: &InlineHandle,
3597    origin: SecureInputOrigin,
3598) {
3599    // Model-role prompts are built by their own (unmasked, prefilled)
3600    // builders; this auth-specific helper is never called with them.
3601    let provider = match &origin {
3602        SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
3603            provider.clone()
3604        }
3605        SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => return,
3606        SecureInputOrigin::TextEdit(_) => return,
3607    };
3608    state.secure_input_origin = Some(origin);
3609    handle.show_modal(
3610        format!("Set API key for {provider}"),
3611        vec![
3612            "Paste the API key. Press Enter to save, Esc to cancel.".into(),
3613            "The key is masked on screen; nothing is logged.".into(),
3614        ],
3615        Some(SecurePromptConfig {
3616            label: format!("{provider} key"),
3617            placeholder: Some("sk-...".into()),
3618            mask_input: true,
3619        }),
3620    );
3621}
3622
3623/// Dispatch a single `AuthAction` for `provider`.
3624///
3625/// `SetApiKey` opens the secure (masked) prompt via `open_secure_prompt`
3626/// (stashing `SecureInputOrigin::SetKey` so the consumer can route the
3627/// key to the right provider). `StartOAuth` spawns `run_oauth_flow` on a
3628/// dedicated tokio task (PKCE + loopback callback + token exchange +
3629/// persistence). `RemoveKey` reuses the existing confirmation modal —
3630/// its `ConfirmationAction::RemoveProviderKey` handler runs through
3631/// `/providers remove <name> --yes`.
3632pub(crate) fn handle_auth_action(
3633    provider: &str,
3634    action: &AuthAction,
3635    auth: &Arc<crate::store::auth_storage::AuthStorage>,
3636    handle: &InlineHandle,
3637    state: &mut RenderState,
3638) -> bool {
3639    // Returns true when the dispatched action opened a new overlay via
3640    // `handle.show_*` (currently only `SetApiKey` opens the secure prompt
3641    // modal). The caller — the `OverlayEvent::Submitted` arm in
3642    // `handle_inline_event` — uses this signal to decide whether the
3643    // previously-open overlay should be closed after dispatch. Closing
3644    // unconditionally would also clear the freshly-opened overlay because
3645    // the cmd channel processes `ShowOverlay` and `CloseOverlay` in submit
3646    // order, so a stale `CloseOverlay` enqueued right after the
3647    // `ShowOverlay` wins. Branches that do NOT open a new overlay
3648    // (`StartOAuth` spawns an async task, `RemoveKey` sets
3649    // `state.confirmation` rather than `state.overlay`) return false so
3650    // the caller is free to close the old overlay.
3651    match action {
3652        AuthAction::SetApiKey => {
3653            open_secure_prompt(
3654                state,
3655                handle,
3656                SecureInputOrigin::SetKey {
3657                    provider: provider.to_string(),
3658                },
3659            );
3660            true
3661        }
3662        AuthAction::StartOAuth => {
3663            // PKCE + loopback-callback glue lives in `run_oauth_flow`
3664            // (defined just below `handle_auth_action`). Spawn it on a
3665            // dedicated tokio task so the main loop can continue
3666            // rendering; the spawned task posts status updates back to
3667            // the transcript via the cloned `InlineHandle`.
3668            //
3669            // First, gate on the provider actually having an OAuth
3670            // spec in `product-meta.toml` — the action is only offered
3671            // when `next_provider_actions` includes it, so this branch
3672            // is purely defensive against a stale UI state.
3673            let spec = match crate::provider_oauth::spec_for(provider) {
3674                Some(s) => s,
3675                None => {
3676                    handle.append_line(
3677                        InlineMessageKind::Error,
3678                        vec![plain_segment(format!(
3679                            "OAuth: no OAuth config for '{provider}'."
3680                        ))],
3681                    );
3682                    return false;
3683                }
3684            };
3685            // `provider_owned` and `tx` are cloned Strings/`InlineHandle`s
3686            // owned by the task; `auth_clone` is the shared storage
3687            // singleton (cheap to clone — it is already `Arc`-backed).
3688            // `spec` is moved into the task.
3689            let provider_owned = provider.to_string();
3690            let tx = handle.clone();
3691            let auth_clone = Arc::clone(auth);
3692            tokio::spawn(async move {
3693                run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
3694            });
3695            false
3696        }
3697        AuthAction::RemoveKey => {
3698            state.confirmation = Some(ModalConfirmation {
3699                title: format!("Remove key for {provider}?"),
3700                message: "  y \u{2014} remove key     n / x \u{2014} cancel".into(),
3701                action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
3702            });
3703            false
3704        }
3705    }
3706}
3707
3708/// Drive the OAuth `authorization_code` flow for `provider` end to end:
3709///
3710/// 1. Bind an ephemeral loopback TCP listener and capture its port.
3711/// 2. Generate PKCE verifier + S256 challenge (`provider_oauth::pkce_pair`).
3712/// 3. Build the authorization URL (`provider_oauth::build_auth_url`) and
3713///    open it in the user's browser (`provider_oauth::open_browser`).
3714/// 4. Wait on the listener for the redirect carrying the `code` + `state`
3715///    (`oauth_listener::await_callback`); bind a timeout so a stuck
3716///    listener cannot leak.
3717/// 5. Exchange the code for tokens at the provider's token URL
3718///    (`provider_oauth::exchange_code`).
3719/// 6. Persist the OAuth credential via `AuthStorage::set_oauth_full` so
3720///    subsequent requests can use the access token (and `refresh_token`
3721///    if granted) without re-prompting the user.
3722///
3723/// Steps that hard-fail (callback timeout, state mismatch, missing
3724/// `code`, exchange error, persist error) post an `InlineMessageKind::Error`
3725/// line to the transcript and return; the bound listener is dropped on
3726/// every return path, satisfying the single-shot invariant.
3727///
3728/// Headless fallback (plan §3 / design §3): if `open_browser` returns
3729/// `Err`, we do NOT abort. We post an `Info` line printing the auth URL
3730/// and lengthen the callback timeout to 5 minutes so the user can paste
3731/// the URL into a browser on another machine and complete the flow.
3732/// Masking: every user-facing line that mentions the access token
3733/// surfaces only the token length (`access_token.chars().count()`), never
3734/// the value. Tokens are never logged via `tracing`.
3735pub(crate) async fn run_oauth_flow(
3736    provider: String,
3737    spec: crate::provider_oauth::ProviderOAuthSpec,
3738    handle: InlineHandle,
3739    auth: Arc<crate::store::auth_storage::AuthStorage>,
3740) {
3741    use std::time::Duration;
3742    // Timeout is selected AFTER the browser attempt: 2 minutes when the
3743    // browser opened (the user is right in front of it), 5 minutes when
3744    // it didn't (headless box — user has to copy the URL to another
3745    // machine, sign in there, and the redirect has to traverse NAT).
3746    // The variable is declared once as `mut` and then frozen below.
3747    // 1. Bind the loopback listener BEFORE opening the browser so the
3748    //    `redirect_uri` we hand to the provider already points at a live
3749    //    port. `TcpListener::bind("127.0.0.1:0")` picks an ephemeral port.
3750    let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
3751        Ok(l) => l,
3752        Err(e) => {
3753            handle.append_line(
3754                InlineMessageKind::Error,
3755                vec![plain_segment(format!(
3756                    "OAuth: could not bind loopback listener for '{provider}': {e}"
3757                ))],
3758            );
3759            return;
3760        }
3761    };
3762    let port = match listener.local_addr() {
3763        Ok(addr) => addr.port(),
3764        Err(e) => {
3765            handle.append_line(
3766                InlineMessageKind::Error,
3767                vec![plain_segment(format!(
3768                    "OAuth: could not read loopback port for '{provider}': {e}"
3769                ))],
3770            );
3771            return;
3772        }
3773    };
3774
3775    // 2. PKCE pair + per-flow `state`. The state must match what we send
3776    //    in the auth URL and what we accept on the callback — a single
3777    //    random base64-url string is enough since the flow is single-shot.
3778    let (verifier, challenge) = crate::provider_oauth::pkce_pair();
3779    let state_token = crate::provider_oauth::pkce_pair().0; // 43-char url-safe random
3780
3781    // 3. Build auth URL and open the browser. `open_browser` already
3782    //    validates the URL scheme so a malformed spec would have failed
3783    //    at `build_auth_url` time (it calls `Url::parse` internally).
3784    let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
3785    handle.append_line(
3786        InlineMessageKind::Info,
3787        vec![plain_segment(format!(
3788            "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
3789            spec.redirect_path
3790        ))],
3791    );
3792    // Pick the callback timeout based on whether the browser opened.
3793    // Headless fallback (plan §3 / design §3): when the OS refuses to
3794    // launch a browser, we surface the URL and KEEP listening so a user
3795    // on a different machine can paste it, sign in, and let the
3796    // redirect land back on our loopback port. A 5-minute window is
3797    // long enough for that round-trip; a 2-minute window is plenty
3798    // when the browser already opened in front of the user.
3799    let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
3800        Ok(()) => Duration::from_secs(120),
3801        Err(e) => {
3802            handle.append_line(
3803                InlineMessageKind::Info,
3804                vec![plain_segment(format!(
3805                    "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n  {auth_url}"
3806                ))],
3807            );
3808            Duration::from_secs(300)
3809        }
3810    };
3811
3812    // 4. Wait for the callback. The listener is single-shot by design:
3813    //    `await_callback` accepts exactly one connection.
3814    let callback = match crate::oauth_listener::await_callback(
3815        listener,
3816        state_token.clone(),
3817        spec.redirect_path.clone(),
3818        callback_timeout,
3819    )
3820    .await
3821    {
3822        Ok(c) => c,
3823        Err(crate::oauth_listener::CallbackError::Timeout) => {
3824            handle.append_line(
3825                InlineMessageKind::Error,
3826                vec![plain_segment(format!(
3827                    "OAuth: timed out waiting for '{provider}' callback (after {}s)",
3828                    callback_timeout.as_secs()
3829                ))],
3830            );
3831            return;
3832        }
3833        Err(e) => {
3834            handle.append_line(
3835                InlineMessageKind::Error,
3836                vec![plain_segment(format!(
3837                    "OAuth: callback failed for '{provider}': {e}"
3838                ))],
3839            );
3840            return;
3841        }
3842    };
3843
3844    // 5. Exchange code → tokens.
3845    let tokens =
3846        match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
3847            Ok(t) => t,
3848            Err(e) => {
3849                handle.append_line(
3850                    InlineMessageKind::Error,
3851                    vec![plain_segment(format!(
3852                        "OAuth: token exchange failed for '{provider}': {e}"
3853                    ))],
3854                );
3855                return;
3856            }
3857        };
3858
3859    // 6. Persist. `set_oauth_full` takes u64 `expires_at`; `OAuthTokens`
3860    //    exposes i64 (so callers can branch on `now < expires_at` in
3861    //    signed arithmetic). Saturate defensively — the value is always
3862    //    `now + expires_in` with `expires_in >= 0`, so negatives are
3863    //    impossible here, but a guard costs nothing.
3864    let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
3865    let access_token_len = tokens.access_token.chars().count();
3866    // `set_oauth_full` returns `()` and logs persistence failures via
3867    // `tracing::warn` — the in-memory credential is always updated.
3868    auth.set_oauth_full(
3869        &provider,
3870        tokens.access_token,
3871        tokens.refresh_token,
3872        new_expires_at,
3873        if tokens.scopes.is_empty() {
3874            None
3875        } else {
3876            Some(tokens.scopes.join(" "))
3877        },
3878        None,
3879    );
3880    handle.append_line(
3881        InlineMessageKind::Info,
3882        vec![plain_segment(format!(
3883            "OAuth: '{provider}' logged in. Token stored ({} chars).",
3884            access_token_len
3885        ))],
3886    );
3887}
3888
3889/// Map an input-thread `InlineEvent` to agent actions / state edits.
3890fn handle_inline_event(
3891    state: &mut RenderState,
3892    handle: &InlineHandle,
3893    session: &crate::app::agent_session::AgentSessionHandle,
3894    prompt_queue: &Arc<PromptQueue>,
3895    evt: InlineEvent,
3896) -> LoopOutcome {
3897    match evt {
3898        InlineEvent::Submit(text) => {
3899            // ── Drain pending resume (set by /sessions <id> or the picker). ──
3900            if let Some(path) = state.pending_resume.take() {
3901                let swapper = state.swapper();
3902                let agent_arc = Arc::clone(&session.agent_arc());
3903                let settings = session.settings_clone();
3904                let session_state = state
3905                    .session_state
3906                    .clone()
3907                    .expect("RenderState::session_state must be initialized at TUI startup");
3908                let path_for_log = path.clone();
3909                let handle = handle.clone();
3910                let swapper_for_swap = swapper.clone();
3911                tokio::spawn(async move {
3912                    match crate::app::agent_session::resume_from_file(
3913                        agent_arc,
3914                        settings,
3915                        session_state,
3916                        &path,
3917                        None,
3918                    )
3919                    .await
3920                    {
3921                        Ok(new_session) => {
3922                            swapper_for_swap.swap(new_session.clone_handle());
3923                            let n = new_session.messages().len();
3924                            let id = new_session.session_id();
3925                            handle.append_line(
3926                                InlineMessageKind::Info,
3927                                vec![plain_segment(format!(
3928                                    "Resumed session {id} ({n} messages)"
3929                                ))],
3930                            );
3931                        }
3932                        Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
3933                            handle.append_line(
3934                                InlineMessageKind::Error,
3935                                vec![plain_segment(format!("No session file: {}", p.display()))],
3936                            );
3937                        }
3938                        Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
3939                            handle.append_line(
3940                                InlineMessageKind::Error,
3941                                vec![plain_segment(format!(
3942                                    "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
3943                                     Use /export to save its content, then /clear.",
3944                                    path_for_log.display()
3945                                ))],
3946                            );
3947                        }
3948                    }
3949                });
3950                return LoopOutcome::Continue;
3951            }
3952            // Drain the composer — the input thread already cleared its
3953            // local copy once Submit fired, but we keep the canonical
3954            // buffer here in sync.
3955            let prompt = text.to_string();
3956            state.composer.set_text("");
3957            if prompt.is_empty() {
3958                return LoopOutcome::Continue;
3959            }
3960            state.pending_quit = false;
3961            // Slash commands: dispatch locally instead of forwarding to
3962            // the agent. The echoed line is appended before dispatch so
3963            // every command output appears after the prompt.
3964            if prompt.trim_start().starts_with('/') {
3965                state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
3966                let mut ctx = SlashCtx {
3967                    session,
3968                    handle,
3969                    state,
3970                };
3971                return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
3972                    SlashOutcome::Quit => LoopOutcome::Exit,
3973                    SlashOutcome::Handled => LoopOutcome::Continue,
3974                    SlashOutcome::NotHandled => {
3975                        // File-based commands: try before erroring.
3976                        if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
3977                            &ctx.state.file_commands,
3978                            &prompt,
3979                        ) {
3980                            // Send expanded text directly to the agent worker.
3981                            // The original `/cmd args` is already echoed above.
3982                            prompt_queue.enqueue(expanded);
3983                            LoopOutcome::Continue
3984                        } else {
3985                            ctx.reply(
3986                                InlineMessageKind::Error,
3987                                format!("Unknown command: {}", prompt.trim()),
3988                            );
3989                            LoopOutcome::Continue
3990                        }
3991                    }
3992                };
3993            }
3994            state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
3995            // While a run is active, mirror the prompt into the queue pane so
3996            // the user sees their input is queued (the worker channel already
3997            // serialises execution; this is the visible counterpart).
3998            if session.is_streaming() {
3999                state.queued_inputs.push(prompt.clone());
4000                state.show_tip(
4001                    "send_now",
4002                    "Ctrl+Enter sends now | Ctrl+; manages queue",
4003                    240,
4004                    true,
4005                );
4006            }
4007            // Hand the prompt to the worker thread. If the worker has
4008            // already exited (e.g. shutdown), drop it on the floor.
4009            prompt_queue.enqueue(prompt);
4010        }
4011        InlineEvent::Cancel => {
4012            // Esc-driven cancel. While a stream is running, abort it (the
4013            // input thread's ~1s post-cancel grace then prevents mashing).
4014            // When idle, Esc is an instant one-press quit — no grace, no
4015            // quit-arming footer that would invite a re-press the grace
4016            // swallows.
4017            return match route_cancel(session.is_streaming()) {
4018                CancelRoute::Interrupt => handle_interrupt(state, session, handle),
4019                CancelRoute::Exit => LoopOutcome::Exit,
4020            };
4021        }
4022        InlineEvent::Exit => {
4023            return LoopOutcome::Exit;
4024        }
4025        InlineEvent::Interrupt => {
4026            return handle_interrupt(state, session, handle);
4027        }
4028        InlineEvent::ScrollLineUp => {
4029            state.scroll_offset = state.scroll_offset.saturating_add(1);
4030        }
4031        InlineEvent::ScrollLineDown => {
4032            state.scroll_offset = state.scroll_offset.saturating_sub(1);
4033        }
4034        InlineEvent::ScrollPageUp => {
4035            state.scroll_offset = state.scroll_offset.saturating_add(10);
4036        }
4037        InlineEvent::ScrollPageDown => {
4038            state.scroll_offset = state.scroll_offset.saturating_sub(10);
4039        }
4040        InlineEvent::CyclePrimaryAgent => {
4041            let _ = session.cycle_model();
4042        }
4043        InlineEvent::CyclePrimaryAgentPrevious => {
4044            // No dedicated reverse-cycling API in AgentSession yet;
4045            // forward-cycle is the closest match.
4046            let _ = session.cycle_model();
4047        }
4048        InlineEvent::Overlay(overlay_evt) => {
4049            use oxicode_vtui::tui::core::OverlayEvent;
4050            match overlay_evt {
4051                OverlayEvent::Submitted(sub) => {
4052                    // Tracks whether this submission chained into a new overlay
4053                    // (the action menu after `/providers` row selection, or the
4054                    // secure prompt after `SetApiKey`). When set, the
4055                    // unconditional `close_overlay()` at the end of the arm
4056                    // would clear the freshly-opened overlay because the
4057                    // `cmd` channel processes `ShowOverlay` and
4058                    // `CloseOverlay` in submit order. Stale-state cleanup
4059                    // (clearing `overlay_providers` etc.) still runs — only
4060                    // the close is gated.
4061                    let mut opened_new_overlay = false;
4062                    // If this was a /model picker, set the selected model.
4063                    if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
4064                        && idx < &state.overlay_model_ids.len()
4065                    {
4066                        let model_id = state.overlay_model_ids[*idx].clone();
4067                        match session.set_model(&model_id) {
4068                            Ok(()) => {
4069                                sync_model_chips(state, session);
4070                                handle.append_line(
4071                                    InlineMessageKind::Info,
4072                                    vec![plain_segment(format!("Switched to {model_id}"))],
4073                                );
4074                            }
4075                            Err(e) => handle.append_line(
4076                                InlineMessageKind::Error,
4077                                vec![plain_segment(format!("Failed to set model: {e}"))],
4078                            ),
4079                        }
4080                    }
4081                    // If this was a /theme picker, apply the selected theme.
4082                    if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
4083                    {
4084                        match oxicode_vtui::theme::set_active_theme(theme_id) {
4085                            Ok(()) => {
4086                                let label = oxicode_vtui::theme::theme_label(theme_id)
4087                                    .unwrap_or(theme_id.as_ref())
4088                                    .to_string();
4089                                handle.append_line(
4090                                    InlineMessageKind::Info,
4091                                    vec![plain_segment(format!("Theme: {label}"))],
4092                                );
4093                            }
4094                            Err(e) => handle.append_line(
4095                                InlineMessageKind::Error,
4096                                vec![plain_segment(format!("Unknown theme: {e}"))],
4097                            ),
4098                        }
4099                    }
4100                    // If this was a command palette selection, fill the prompt.
4101                    if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
4102                        &sub
4103                    {
4104                        state.composer.set_text(&format!("/{name} "));
4105                    }
4106                    // Settings overlay: toggle/cycle the selected setting.
4107                    // `ConfigAction` carries the `SettingKey` Debug name
4108                    // emitted by `settings_overlay_items`; dispatch goes
4109                    // through the def table (`apply_change`), never a
4110                    // per-name match.
4111                    // Synthetic ConfigAction payloads emitted by the
4112                    // panel editors: handled FIRST so they never reach
4113                    // the generic `ConfigAction(name)` arm (which would
4114                    // treat `SubmenuCommit:…` / `DisabledToolToggle:…`
4115                    // as a bogus SettingKey name and error out).
4116                    let synthetic_dispatched = if let OverlaySubmission::Selection(
4117                        InlineListSelection::ConfigAction(payload),
4118                    ) = &sub
4119                    {
4120                        if let Some(rest) = payload.strip_prefix("SubmenuCommit:") {
4121                            if let Some((key_str, value)) = rest.split_once(':') {
4122                                if let Some(key) = parse_setting_key(key_str) {
4123                                    match commit_submenu_choice(state, key, value.to_string()) {
4124                                        Ok(status) => {
4125                                            handle.append_line(
4126                                                InlineMessageKind::Info,
4127                                                vec![plain_segment(status.clone())],
4128                                            );
4129                                            opened_new_overlay = state.overlay.is_some();
4130                                        }
4131                                        Err(e) => handle.append_line(
4132                                            InlineMessageKind::Error,
4133                                            vec![plain_segment(format!(
4134                                                "Failed to save setting: {e}"
4135                                            ))],
4136                                        ),
4137                                    }
4138                                } else {
4139                                    handle.append_line(
4140                                        InlineMessageKind::Error,
4141                                        vec![plain_segment(format!(
4142                                            "Unknown setting key in submenu commit: {key_str}"
4143                                        ))],
4144                                    );
4145                                }
4146                            } else {
4147                                handle.append_line(
4148                                    InlineMessageKind::Error,
4149                                    vec![plain_segment(format!(
4150                                        "Malformed submenu commit payload: {payload}"
4151                                    ))],
4152                                );
4153                            }
4154                            true
4155                        } else if let Some(tool) = payload.strip_prefix("DisabledToolToggle:") {
4156                            let essential = session
4157                                .agent_ref()
4158                                .tools()
4159                                .get_tools()
4160                                .into_iter()
4161                                .find(|t| t.name() == tool)
4162                                .is_some_and(|t| t.essential());
4163                            let currently_disabled = crate::store::settings::Settings::load()
4164                                .map(|s| s.disabled_tools.iter().any(|t| t == tool))
4165                                .unwrap_or(false);
4166                            commit_disabled_tool_toggle(
4167                                state,
4168                                handle,
4169                                session,
4170                                tool.to_string(),
4171                                essential,
4172                                currently_disabled,
4173                            );
4174                            opened_new_overlay = state.overlay.is_some();
4175                            true
4176                        } else {
4177                            false
4178                        }
4179                    } else {
4180                        false
4181                    };
4182                    if !synthetic_dispatched
4183                        && let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
4184                            &sub
4185                    {
4186                        let def = SETTING_DEFS.iter().find(|d| format!("{:?}", d.key) == *key);
4187                        match def {
4188                            Some(def) => {
4189                                let mut settings =
4190                                    crate::store::settings::Settings::load().unwrap_or_default();
4191                                // Toggle submits the inverted bool; Cycle
4192                                // the next variant. The structured editors
4193                                // (Text/Submenu/Multiselect/MapEditor)
4194                                // commit their own explicit values.
4195                                let next_value = match def.widget {
4196                                    SettingWidget::Toggle => Some(
4197                                        (get_display_value(def.key, &settings) != "true")
4198                                            .to_string(),
4199                                    ),
4200                                    SettingWidget::Cycle => next_cycle_value(def.key, &settings),
4201                                    _ => None,
4202                                };
4203                                if let Some(next) = next_value {
4204                                    match crate::tui_vt::settings_defs::apply_change(
4205                                        def.key,
4206                                        &mut settings,
4207                                        next,
4208                                    ) {
4209                                        Ok(()) => match settings.save() {
4210                                            Ok(()) => {
4211                                                if let Err(e) = sync_settings_live(
4212                                                    state, session, def.key, &settings,
4213                                                ) {
4214                                                    // Saved, but the live
4215                                                    // toggle failed — an
4216                                                    // Error line, never a
4217                                                    // silent success.
4218                                                    handle.append_line(
4219                                                        InlineMessageKind::Error,
4220                                                        vec![plain_segment(format!(
4221                                                            "{}: {e}",
4222                                                            def.label
4223                                                        ))],
4224                                                    );
4225                                                } else {
4226                                                    handle.append_line(
4227                                                        InlineMessageKind::Info,
4228                                                        vec![plain_segment(format!(
4229                                                            "{}: {}",
4230                                                            def.label,
4231                                                            get_display_value(def.key, &settings)
4232                                                        ))],
4233                                                    );
4234                                                }
4235                                            }
4236                                            Err(e) => handle.append_line(
4237                                                InlineMessageKind::Error,
4238                                                vec![plain_segment(format!(
4239                                                    "Failed to save {}: {e}",
4240                                                    def.label
4241                                                ))],
4242                                            ),
4243                                        },
4244                                        Err(e) => handle.append_line(
4245                                            InlineMessageKind::Error,
4246                                            vec![plain_segment(format!(
4247                                                "Failed to apply {}: {e}",
4248                                                def.label
4249                                            ))],
4250                                        ),
4251                                    }
4252                                }
4253                            }
4254                            None => handle.append_line(
4255                                InlineMessageKind::Error,
4256                                vec![plain_segment(format!("Unknown setting: {key}"))],
4257                            ),
4258                        }
4259                    }
4260                    // Settings panel tab switch: reopen the panel rebuilt
4261                    // for the requested tab (Enter closes the overlay, so
4262                    // the switch has to reopen it).
4263                    if let OverlaySubmission::Selection(InlineListSelection::SettingsTab(idx)) =
4264                        &sub
4265                    {
4266                        switch_settings_tab(state, *idx);
4267                        opened_new_overlay = state.overlay.is_some();
4268                    }
4269                    // Settings panel sidebar section jump: reopen on the
4270                    // active tab with the selection moved to the section's
4271                    // first row.
4272                    if let OverlaySubmission::Selection(InlineListSelection::SettingsSection(idx)) =
4273                        &sub
4274                    {
4275                        jump_settings_section(state, *idx);
4276                        opened_new_overlay = state.overlay.is_some();
4277                    }
4278                    // Keybinding capture: selecting an action row opens
4279                    // the "press a key combo" prompt. The INPUT thread
4280                    // consumes the next key before global-shortcut
4281                    // resolution (`handle_key_capture`) and commits
4282                    // through the keybindings map editor.
4283                    if let OverlaySubmission::Selection(InlineListSelection::SettingKeyCapture(
4284                        name,
4285                    )) = &sub
4286                    {
4287                        if GlobalAction::from_name(name).is_some() {
4288                            state.overlay = Some(build_key_capture_overlay(name));
4289                            state.settings_map_rows.clear();
4290                            opened_new_overlay = true;
4291                        } else {
4292                            handle.append_line(
4293                                InlineMessageKind::Error,
4294                                vec![plain_segment(format!("Unknown keybinding action: {name}"))],
4295                            );
4296                        }
4297                    }
4298                    // Settings-panel text editor: open the prompt; the
4299                    // submitted text arrives via the SecureInput arm
4300                    // below (`SecureInputOrigin::TextEdit(key)`).
4301                    if let OverlaySubmission::Selection(InlineListSelection::SettingTextEdit(
4302                        key_name,
4303                    )) = &sub
4304                    {
4305                        if let Some(key) = parse_setting_key(key_name) {
4306                            open_text_edit_prompt(state, key);
4307                            opened_new_overlay = true;
4308                        } else {
4309                            handle.append_line(
4310                                InlineMessageKind::Error,
4311                                vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4312                            );
4313                        }
4314                    }
4315                    // Settings-panel submenu-select: open a child list
4316                    // whose selections arrive as synthetic
4317                    // `ConfigAction("SubmenuCommit:Key:value")` payloads
4318                    // routed by the ConfigAction arm below.
4319                    if let OverlaySubmission::Selection(InlineListSelection::SettingSubmenuOpen(
4320                        key_name,
4321                    )) = &sub
4322                    {
4323                        if let Some(key) = parse_setting_key(key_name) {
4324                            open_submenu_select_prompt(state, key);
4325                            opened_new_overlay = true;
4326                        } else {
4327                            handle.append_line(
4328                                InlineMessageKind::Error,
4329                                vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4330                            );
4331                        }
4332                    }
4333                    // Settings-panel multiselect: open a tool list
4334                    // sourced live from `session.agent_ref().tools()`;
4335                    // selections arrive as synthetic
4336                    // `ConfigAction("DisabledToolToggle:tool")` payloads.
4337                    if let OverlaySubmission::Selection(InlineListSelection::SettingMultiselect(
4338                        key_name,
4339                    )) = &sub
4340                    {
4341                        if let Some(parsed) = parse_setting_key(key_name) {
4342                            if parsed == SettingKey::DisabledTools {
4343                                open_disabled_tools_multiselect(state, session);
4344                                opened_new_overlay = true;
4345                            } else {
4346                                handle.append_line(
4347                                    InlineMessageKind::Error,
4348                                    vec![plain_segment(format!(
4349                                        "'{parsed:?}' has no multiselect editor"
4350                                    ))],
4351                                );
4352                            }
4353                        } else {
4354                            handle.append_line(
4355                                InlineMessageKind::Error,
4356                                vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4357                            );
4358                        }
4359                    }
4360                    // Session picker: enqueue the selected session. The next
4361                    // Submit event drains it before normal composer dispatch.
4362                    if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
4363                        // Gate: refuse to queue a resume while the agent is
4364                        // running — the pending_resume drain would clobber
4365                        // the in-flight conversation's message history on
4366                        // the shared Arc<Agent> (same wording as the direct
4367                        // /sessions <id> path and /handoff).
4368                        if session.is_streaming() {
4369                            handle.append_line(
4370                                InlineMessageKind::Error,
4371                                vec![plain_segment(
4372                                    "Cannot resume while agent is running. Use /cancel first.",
4373                                )],
4374                            );
4375                        } else {
4376                            let path = crate::tui_vt::slash::registry::sessions_dir()
4377                                .join(format!("{id}.jsonl"));
4378                            if !path.is_file() {
4379                                handle.append_line(
4380                                    InlineMessageKind::Error,
4381                                    vec![plain_segment(format!(
4382                                        "No session file: {}",
4383                                        path.display()
4384                                    ))],
4385                                );
4386                            } else {
4387                                state.pending_resume = Some(path);
4388                            }
4389                        }
4390                    }
4391                    // `/models` catalog browser: switch to the selected model.
4392                    if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
4393                        &sub
4394                        && idx < &state.overlay_catalog_models.len()
4395                    {
4396                        let (provider, model_id) = &state.overlay_catalog_models[*idx];
4397                        let full = format!("{provider}/{model_id}");
4398                        match session.set_model(&full) {
4399                            Ok(()) => {
4400                                sync_model_chips(state, session);
4401                                handle.append_line(
4402                                    InlineMessageKind::Info,
4403                                    vec![plain_segment(format!("Switched to {full}"))],
4404                                );
4405                            }
4406                            Err(e) => handle.append_line(
4407                                InlineMessageKind::Error,
4408                                vec![plain_segment(format!("Failed to set model: {e}"))],
4409                            ),
4410                        }
4411                    }
4412                    // `/providers` list: pick a provider, then drive the
4413                    // `next_provider_actions(has_key, oauth_capable)` matrix.
4414                    // Single-action cases fire straight into
4415                    // `handle_auth_action`; multi-action cases open a
4416                    // one-shot action list whose selections are
4417                    // `ProviderAction { provider, action }`.
4418                    if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
4419                        &sub
4420                        && idx < &state.overlay_providers.len()
4421                    {
4422                        let name = state.overlay_providers[*idx].clone();
4423                        let auth = crate::store::auth_storage::shared_auth_storage();
4424                        let has_key = auth.has(&name);
4425                        let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
4426                        let actions = next_provider_actions(has_key, oauth_capable);
4427                        if actions.len() == 1 {
4428                            // Single action — drive directly with no menu.
4429                            opened_new_overlay |=
4430                                handle_auth_action(&name, &actions[0], &auth, handle, state);
4431                        } else {
4432                            // Show action menu.
4433                            let items: Vec<InlineListItem> = actions
4434                                .iter()
4435                                .map(|a| InlineListItem {
4436                                    title: match a {
4437                                        AuthAction::SetApiKey => "Set API key".into(),
4438                                        AuthAction::StartOAuth => "Login with OAuth".into(),
4439                                        AuthAction::RemoveKey => "Remove key".into(),
4440                                    },
4441                                    subtitle: None,
4442                                    badge: None,
4443                                    indent: 0,
4444                                    selection: Some(InlineListSelection::ProviderAction {
4445                                        provider: name.clone(),
4446                                        action: a.clone(),
4447                                    }),
4448                                    search_value: None,
4449                                })
4450                                .collect();
4451                            handle.show_list_modal(
4452                                name.clone(),
4453                                vec!["Pick an action".into()],
4454                                items,
4455                                None,
4456                                None,
4457                            );
4458                            opened_new_overlay = true;
4459                        }
4460                    }
4461                    // `/providers` action menu: forward the chosen
4462                    // `AuthAction` to the host dispatcher. Selecting
4463                    // "Remove key" reuses the existing y/n confirmation
4464                    // modal; "Set API key" opens the secure prompt;
4465                    // "Login with OAuth" prints the Task 8 stub.
4466                    if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
4467                        provider,
4468                        action,
4469                    }) = &sub
4470                    {
4471                        let auth = crate::store::auth_storage::shared_auth_storage();
4472                        opened_new_overlay |=
4473                            handle_auth_action(provider, action, &auth, handle, state);
4474                    }
4475                    // Text/secure prompt committed by the user. The
4476                    // matching open prompt must have stashed
4477                    // `state.secure_input_origin`; we trust that field
4478                    // here because every prompt path sets it before
4479                    // opening the modal (`open_secure_prompt` for auth,
4480                    // the model-role prompt builders for the map
4481                    // editor).
4482                    if let OverlaySubmission::SecureInput(text) = &sub
4483                        && let Some(origin) = state.secure_input_origin.take()
4484                    {
4485                        match origin {
4486                            SecureInputOrigin::ModelRoleKey => {
4487                                // Phase 1 of the new-role flow: the text
4488                                // is the role NAME — chain straight into
4489                                // the value prompt.
4490                                let role = text.trim().to_string();
4491                                if role.is_empty() {
4492                                    handle.append_line(
4493                                        InlineMessageKind::Error,
4494                                        vec![plain_segment(
4495                                            "Model role name can't be empty".to_string(),
4496                                        )],
4497                                    );
4498                                } else {
4499                                    open_model_role_value_prompt(state, &role);
4500                                    opened_new_overlay = true;
4501                                }
4502                            }
4503                            SecureInputOrigin::TextEdit(key) => {
4504                                let (_outcome, _msg) = commit_text_edit(
4505                                    state,
4506                                    handle,
4507                                    Some(session),
4508                                    key,
4509                                    text.clone(),
4510                                );
4511                                opened_new_overlay = state.overlay.is_some();
4512                            }
4513                            SecureInputOrigin::ModelRoleValue { role } => {
4514                                let model = text.trim().to_string();
4515                                let outcome = if model.is_empty() {
4516                                    Err("model pattern can't be empty".to_string())
4517                                } else {
4518                                    crate::store::settings::Settings::load()
4519                                        .map_err(|e| e.to_string())
4520                                        .and_then(|mut settings| {
4521                                            crate::tui_vt::settings_defs::set_model_role(
4522                                                &mut settings,
4523                                                &role,
4524                                                model.clone(),
4525                                            );
4526                                            settings.save().map_err(|e| e.to_string())
4527                                        })
4528                                };
4529                                match outcome {
4530                                    Ok(()) => {
4531                                        handle.append_line(
4532                                            InlineMessageKind::Info,
4533                                            vec![plain_segment(format!(
4534                                                "Model role '{role}' \u{2192} {model}"
4535                                            ))],
4536                                        );
4537                                        reopen_settings_panel(
4538                                            state,
4539                                            SettingsTab::Model,
4540                                            Some(format!("Saved '{role}' \u{2192} {model}")),
4541                                        );
4542                                        opened_new_overlay = true;
4543                                    }
4544                                    Err(e) => handle.append_line(
4545                                        InlineMessageKind::Error,
4546                                        vec![plain_segment(format!(
4547                                            "Failed to save model role '{role}': {e}"
4548                                        ))],
4549                                    ),
4550                                }
4551                            }
4552                            origin @ (SecureInputOrigin::SetKey { .. }
4553                            | SecureInputOrigin::NewlyAdded { .. }) => {
4554                                let provider = match &origin {
4555                                    SecureInputOrigin::SetKey { provider }
4556                                    | SecureInputOrigin::NewlyAdded { provider } => {
4557                                        provider.clone()
4558                                    }
4559                                    _ => unreachable!("auth arm only matches auth origins"),
4560                                };
4561                                let auth = crate::store::auth_storage::shared_auth_storage();
4562                                auth.set_api_key(&provider, text.clone());
4563                                // The agent keeps a constructed provider instance. Saving a
4564                                // key alone is not enough for an already-open session: ask
4565                                // the resolver for a fresh provider immediately so the next
4566                                // message uses this credential without a restart or model
4567                                // switch.
4568                                let refreshed = session.refresh_api_key();
4569                                let msg = match origin {
4570                                    SecureInputOrigin::SetKey { .. } => format!(
4571                                        "Saved API key for '{provider}'. {}",
4572                                        match refreshed {
4573                                            Ok(()) => "Ready to retry your message.",
4574                                            Err(_) => "Restart this session before retrying.",
4575                                        }
4576                                    ),
4577                                    SecureInputOrigin::NewlyAdded { .. } => format!(
4578                                        "Added and configured '{provider}'. {}",
4579                                        match refreshed {
4580                                            Ok(()) =>
4581                                                "Use /models to choose a model, or send a message.",
4582                                            Err(_) => "Restart this session before using it.",
4583                                        }
4584                                    ),
4585                                    SecureInputOrigin::ModelRoleKey
4586                                    | SecureInputOrigin::ModelRoleValue { .. } => {
4587                                        unreachable!("auth branch reached with a model-role origin")
4588                                    }
4589                                    SecureInputOrigin::TextEdit(_) => {
4590                                        unreachable!("auth branch reached with a text-edit origin")
4591                                    }
4592                                };
4593                                handle
4594                                    .append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
4595                            }
4596                        }
4597                    }
4598                    state.overlay_catalog_models.clear();
4599                    state.overlay_providers.clear();
4600                    state.overlay_model_ids.clear();
4601                    if !opened_new_overlay {
4602                        handle.close_overlay();
4603                    }
4604                }
4605                OverlayEvent::Cancelled => {
4606                    handle.close_overlay();
4607                }
4608                OverlayEvent::SelectionChanged(_) => {}
4609            }
4610        }
4611        _ => {
4612            // Other events (overlay, list-selection, etc.) are no-ops in
4613            // this harness — they are handled by the harness overlay
4614            // component, not by the inline protocol.
4615        }
4616    }
4617    LoopOutcome::Continue
4618}
4619
4620// ─────────────────────────────────────────────────────────────────────────
4621// Ctrl+C policy / streaming guard
4622// ─────────────────────────────────────────────────────────────────────────
4623
4624/// RAII guard that clears the streaming flag on drop (normal exit, error,
4625/// or panic cancellation). Wired in [`run_one_prompt`] around each run.
4626struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
4627
4628impl Drop for StreamingGuard<'_> {
4629    fn drop(&mut self) {
4630        use std::sync::atomic::Ordering;
4631        self.0.store(false, Ordering::SeqCst);
4632    }
4633}
4634
4635/// Central Ctrl+C policy.
4636///
4637/// - **Agent streaming** → abort the current run and tell the user to press
4638///   again to quit. The abort is effective because the session hooks installed
4639///   via `App::from_oxicode` → `with_session_hooks` wire the session's
4640///   `should_stop` flag into the agent loop.
4641/// - **Agent idle** → exit the application.
4642///
4643/// Both the input-thread key event (`InlineEvent::Interrupt`) and the OS
4644/// signal handler (`tokio::signal::ctrl_c()`) route through here so
4645/// behavior is identical regardless of how the interrupt arrives.
4646///
4647fn handle_interrupt(
4648    state: &mut RenderState,
4649    session: &crate::app::agent_session::AgentSessionHandle,
4650    _handle: &InlineHandle,
4651) -> LoopOutcome {
4652    // If a confirmation is already open, Ctrl+C acts as confirm (quit).
4653    if state.confirmation.is_some() {
4654        return LoopOutcome::Exit;
4655    }
4656    // A second Ctrl+C (after the first armed a quit during a stream) opens
4657    // the quit confirmation modal instead of exiting outright.
4658    if state.pending_quit {
4659        state.confirmation = Some(quit_confirmation());
4660        state.pending_quit = false;
4661        return LoopOutcome::Continue;
4662    }
4663    // First Ctrl+C. While streaming, abort the run and arm a quit (the next
4664    // press opens the confirmation). When idle, open the confirmation at
4665    // once — no separate quit-arming step needed.
4666    if session.is_streaming() {
4667        let s = session.clone();
4668        tokio::spawn(async move {
4669            s.abort().await;
4670        });
4671        state.pending_quit = true;
4672    } else {
4673        state.confirmation = Some(quit_confirmation());
4674    }
4675    LoopOutcome::Continue
4676}
4677
4678/// Build the standard quit-confirmation dialog.
4679fn quit_confirmation() -> ModalConfirmation {
4680    ModalConfirmation {
4681        title: "Quit oxicode?".into(),
4682        message: "  y \u{2014} quit now     n / x \u{2014} stay".into(),
4683        action: ConfirmationAction::Quit,
4684    }
4685}
4686
4687/// Build a clear-conversation confirmation dialog.
4688pub(super) fn clear_confirmation() -> ModalConfirmation {
4689    ModalConfirmation {
4690        title: "Clear conversation?".into(),
4691        message: "  y \u{2014} clear all     n / x \u{2014} cancel".into(),
4692        action: ConfirmationAction::ClearConversation,
4693    }
4694}
4695
4696// ─────────────────────────────────────────────────────────────────────────
4697// Input thread — polls crossterm, edits the shared buffer, and forwards
4698// lifecycle events (Submit, Cancel, …) over a tokio channel.
4699// ─────────────────────────────────────────────────────────────────────────
4700
4701/// Execute a global shortcut resolved by the [`Keymap`]. The bodies are
4702/// the original hardcoded Ctrl-* handlers from the input loop, unchanged
4703/// — only the trigger condition became keymap-driven. The branch's
4704/// KeyAction set (Submit/ScrollUp/ScrollDown/Clear/Help/ModelPicker/
4705/// ToggleThinking) was folded into this single match via the unified
4706/// `GlobalAction` enum, so a user rebind for any of them dispatches here
4707/// without falling through to the hardcoded arms below.
4708fn apply_global_action(
4709    action: GlobalAction,
4710    state: &Arc<parking_lot::Mutex<RenderState>>,
4711    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4712) {
4713    match action {
4714        // Ctrl+C: even with raw mode enabled some terminals / shells
4715        // fall back to delivering it as a SIGINT. Handle it as an
4716        // explicit interrupt so we don't depend on the OS signal.
4717        GlobalAction::Interrupt => {
4718            let _ = evt_tx.send(InlineEvent::Interrupt);
4719        }
4720        // Ctrl+M: toggle multiline input mode.
4721        GlobalAction::ToggleMultiline => {
4722            let mut s = state.lock();
4723            s.multiline_mode = !s.multiline_mode;
4724        }
4725        // Ctrl+P: open the command palette.
4726        GlobalAction::OpenCommandPalette => {
4727            let mut s = state.lock();
4728            s.overlay = Some(build_command_palette());
4729        }
4730        // Ctrl+;: toggle the interactive queue panel.
4731        GlobalAction::ToggleQueuePanel => {
4732            let mut s = state.lock();
4733            s.queue_panel_open = !s.queue_panel_open;
4734            if s.queue_panel_open {
4735                s.queue_selected = 0;
4736            }
4737        }
4738        // Ctrl+E: fold all blocks (Shift+E expands all).
4739        GlobalAction::FoldAll => {
4740            let mut s = state.lock();
4741            s.fold_all();
4742        }
4743        // Ctrl+Enter: send-now — abort the current run (if any) and submit
4744        // the composed input immediately, bypassing the queue pane.
4745        GlobalAction::SendNow => {
4746            let submitted = harvest_and_clear_input(state);
4747            if !submitted.is_empty() {
4748                let _ = evt_tx.send(InlineEvent::Interrupt);
4749                let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4750            }
4751        }
4752        // Plain Enter (and Shift+Enter, both default Submit bindings):
4753        // harvest and submit the buffer. The muscle-memory carve-out for
4754        // plain Enter in multiline mode (so it inserts a newline) lives
4755        // in `keymap_pre_match` — this arm only fires after that check.
4756        GlobalAction::Submit => {
4757            let submitted = harvest_and_clear_input(state);
4758            let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4759        }
4760        // PageUp: scroll transcript up by a page.
4761        GlobalAction::ScrollUp => {
4762            let _ = evt_tx.send(InlineEvent::ScrollPageUp);
4763        }
4764        // PageDown: scroll transcript down by a page.
4765        GlobalAction::ScrollDown => {
4766            let _ = evt_tx.send(InlineEvent::ScrollPageDown);
4767        }
4768        // Ctrl+L: clear visible scrollback / fold-all (default behavior
4769        // matches Ctrl+E / FoldAll today — the branch wired Clear to
4770        // Ctrl+E; main has Ctrl+E = FoldAll already, so Clear was
4771        // reassigned to Ctrl+L and routed to fold_all()).
4772        GlobalAction::Clear => {
4773            let mut s = state.lock();
4774            s.fold_all();
4775        }
4776        // ?: open the keyboard-shortcuts overlay. The carve-out for `?`
4777        // typed into a non-empty composer (so it inserts the char)
4778        // lives in the Char arm and `keymap_pre_match`'s Help gate.
4779        GlobalAction::Help => {
4780            let mut s = state.lock();
4781            s.overlay = Some(cheatsheet_overlay());
4782        }
4783        // Ctrl+G: model picker shortcut — currently aliased to the
4784        // command palette (the palette has the model switcher as its
4785        // first tab). Future PR can split ModelPicker into its own
4786        // overlay; for now it mirrors OpenCommandPalette.
4787        GlobalAction::ModelPicker => {
4788            let mut s = state.lock();
4789            s.overlay = Some(build_command_palette());
4790        }
4791        // Ctrl+T: toggle the thinking-reasoning channel. Same wiring as
4792        // ToggleMultiline today; the branch introduced this name, main
4793        // had ToggleMultiline on Ctrl+M. Both bindings stay live so a
4794        // user rebinding one doesn't lose the other.
4795        GlobalAction::ToggleThinking => {
4796            let mut s = state.lock();
4797            s.multiline_mode = !s.multiline_mode;
4798        }
4799    }
4800}
4801
4802/// Outcome of the generic keymap pre-match for the four actions that
4803/// historically lived only inside hardcoded dispatch arms (Submit,
4804/// ScrollUp, ScrollDown, Help). [`KeymapDispatch::None`] means "the
4805/// keymap does not bind this key to any of the four" — the hardcoded
4806/// arms below then act as the fallback.
4807#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4808pub(crate) enum KeymapDispatch {
4809    Submit,
4810    ScrollPageUp,
4811    ScrollPageDown,
4812    Help,
4813    None,
4814}
4815
4816/// Consult the keymap for the four actions whose dispatch used to be
4817/// hardcoded. Consuming the key here is what makes a user rebind real:
4818/// `submit: alt+s` fires although no arm in the input thread matches
4819/// Alt+S; previously the capability was "disable at the original key"
4820/// only. After the squash-merge these are `GlobalAction` variants, not
4821/// the branch's `KeyAction`.
4822///
4823/// Two muscle-memory carve-outs keep the pre-keymap behavior intact:
4824/// * Plain Enter in multiline mode inserts a newline even when Enter
4825///   is bound to Submit — only the *send* path is remappable, so the
4826///   key falls through to the Enter arm ([`KeymapDispatch::None`]).
4827/// * A PRINTABLE Help binding (the default `?`) is left to the Char
4828///   arm, which gates Help on the empty composer so typing `?` inside
4829///   text still inserts it. Non-printable Help bindings (function
4830///   keys, …) never reach a Char arm and dispatch here.
4831pub(crate) fn keymap_pre_match(
4832    keymap: &Keymap,
4833    key: &crossterm::event::KeyEvent,
4834    multiline: bool,
4835) -> KeymapDispatch {
4836    let plain_enter_multiline =
4837        key.code == KeyCode::Enter && multiline && !key.modifiers.contains(KeyModifiers::SHIFT);
4838    if keymap.matches(GlobalAction::Submit, key) && !plain_enter_multiline {
4839        return KeymapDispatch::Submit;
4840    }
4841    if keymap.matches(GlobalAction::ScrollUp, key) {
4842        return KeymapDispatch::ScrollPageUp;
4843    }
4844    if keymap.matches(GlobalAction::ScrollDown, key) {
4845        return KeymapDispatch::ScrollPageDown;
4846    }
4847    if keymap.matches(GlobalAction::Help, key) && !matches!(key.code, KeyCode::Char(_)) {
4848        return KeymapDispatch::Help;
4849    }
4850    KeymapDispatch::None
4851}
4852
4853/// Harvest the composer buffer (or the selected slash-popup item) as a
4854/// submit payload: clears the composer and popup, records prompt
4855/// history, and returns the submitted text. Shared by the SendNow
4856/// arm, the Submit arm, and the generic Submit dispatch.
4857fn harvest_and_clear_input(state: &Arc<parking_lot::Mutex<RenderState>>) -> String {
4858    let mut s = state.lock();
4859    let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
4860        format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
4861    } else {
4862        let buf = s.composer.text().to_string();
4863        s.composer.set_text("");
4864        buf
4865    };
4866    s.slash_popup = SlashPopup::default();
4867    s.history_pos = None;
4868    // Record non-empty, non-command prompts in history.
4869    if !buf.is_empty() && !buf.starts_with('/') {
4870        s.prompt_history.insert(0, buf.clone());
4871        s.prompt_history.truncate(100);
4872    }
4873    buf
4874}
4875
4876/// The keyboard-shortcuts overlay. Shared by the generic Help dispatch
4877/// and the printable (`?`) Char-arm check, which gates it on the empty
4878/// composer.
4879fn cheatsheet_overlay() -> OverlayState {
4880    OverlayState {
4881        title: "Keyboard Shortcuts".into(),
4882        lines: cheatsheet_lines(),
4883        items: vec![],
4884        selected: 0,
4885        search: None,
4886        secure_input: None,
4887        ..Default::default()
4888    }
4889}
4890
4891fn spawn_input_thread(
4892    state: Arc<parking_lot::Mutex<RenderState>>,
4893    evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4894    mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
4895    prompt_queue: Arc<PromptQueue>,
4896    issue_action_tx: tokio::sync::mpsc::UnboundedSender<
4897        crate::tui_vt::issues_panel::IssueActionRequest,
4898    >,
4899) -> std::thread::JoinHandle<()> {
4900    std::thread::spawn(move || {
4901        // Poll stdin in a tight loop. `event::poll` returns `Ok(false)` on
4902        // timeout (no key within the window) — that is NOT a reason to exit,
4903        // only to poll again. The previous `while let Ok(true) = poll(...)`
4904        // treated the first timeout as loop termination, killing this thread
4905        // ~50ms after launch, dropping `evt_tx`, and leaving the TUI unable
4906        // to receive keyboard input — a black screen that only redrew on
4907        // Ctrl+C. Exit only on a genuine read error (stdin closed).
4908        loop {
4909            match event::poll(std::time::Duration::from_millis(50)) {
4910                Ok(true) => {}
4911                Ok(false) => continue,
4912                Err(_) => break,
4913            }
4914            let event = match event::read() {
4915                Ok(ev) => ev,
4916                Err(_) => continue,
4917            };
4918
4919            // Bracketed paste arrives as its own event; flatten into a
4920            // string of `Submit` text.
4921            let mut pasted = String::new();
4922            let mut key_event = None;
4923            match event {
4924                Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
4925                Event::Paste(p) => pasted = p,
4926                _ => {}
4927            }
4928
4929            // Modal hierarchy: `/issue` panel owns input while open.
4930            // Bracketed paste must NOT leak into the hidden composer — the
4931            // panel has no paste handler yet, so we absorb the paste here
4932            // (forwarding it into FilterInput/Form is a later enhancement,
4933            // out of scope today — per the controller's ledger note).
4934            if !pasted.is_empty() && state.lock().issues_panel.is_some() {
4935                continue;
4936            }
4937            if !pasted.is_empty() {
4938                // targets the masked input field instead of the main
4939                // composer buffer. Single-line filter (drops non-graphic
4940                // bytes, strips trailing newline) keeps secrets clean.
4941                let routed_to_secure = {
4942                    let mut s = state.lock();
4943                    if let Some(overlay) = s.overlay.as_mut() {
4944                        if let Some(secure) = overlay.secure_input.as_mut() {
4945                            // Bracketed paste ends in `\n`; strip it before
4946                            // filtering so the final newline never reaches
4947                            // the editor.
4948                            let trimmed = pasted.trim_end_matches('\n');
4949                            for ch in trimmed.chars() {
4950                                if ch.is_ascii_graphic() || ch == ' ' {
4951                                    let _ = secure
4952                                        .editor
4953                                        .apply(oxicode_textarea::EditCommand::Insert(ch));
4954                                }
4955                            }
4956                            true
4957                        } else {
4958                            false
4959                        }
4960                    } else {
4961                        false
4962                    }
4963                };
4964                if routed_to_secure {
4965                    continue;
4966                }
4967                let mut s = state.lock();
4968                s.composer.insert_str(&pasted);
4969                // Refresh popups so e.g. a paste that turns the buffer
4970                // into `/sessions <id>` closes the slash autocomplete
4971                // (it deactivates when `buf[1..].contains(' ')`). Without
4972                // this, the popup stays open with stale items and the
4973                // next Enter would replace the buffer with the bare
4974                // command name, dropping the pasted args.
4975                refresh_input_popups(&mut s);
4976                continue;
4977            }
4978            let Some(key) = key_event else { continue };
4979
4980            // Snapshot the live keymap for this keystroke: the settings
4981            // keybindings editor swaps `RenderState::keymap` in place, so
4982            // every key resolves against the current map (same RwLock the
4983            // editor writes). Cheap — the map is a small HashMap and key
4984            // events are human-paced.
4985            let keymap = state.lock().keymap.read().clone();
4986
4987            // Keybinding capture takes precedence over EVERYTHING — the
4988            // whole point is to grab the next combo verbatim, even one
4989            // that currently resolves to a global action (that's how
4990            // you re-examine an existing binding) or lands in the
4991            // overlay/search handling below.
4992            {
4993                let capturing = {
4994                    let s = state.lock();
4995                    s.overlay.as_ref().is_some_and(|o| o.key_capture.is_some())
4996                };
4997                if capturing {
4998                    let mut s = state.lock();
4999                    handle_key_capture(&mut s, key);
5000                    continue;
5001                }
5002            }
5003
5004            // Confirmation modal takes priority over composer keys, but the
5005            // keymap's Interrupt binding (default Ctrl+C) outranks it: the
5006            // event loop's Ctrl+C policy treats "confirmation open" as
5007            // confirm-quit, so the two-press quit path must see the second
5008            // Ctrl+C even with the modal up. Resolving through the keymap
5009            // (not a hardcoded Ctrl+C) keeps user rebinds working. The
5010            // `/issue` panel sits *below* confirmation so that when a
5011            // Ctrl+C-armed quit confirmation pops over the panel, y/n still
5012            // work and the dialog stays visible.
5013            {
5014                let s = state.lock();
5015                if s.confirmation.is_some() {
5016                    drop(s);
5017                    let interrupt = {
5018                        let s = state.lock();
5019                        matches!(s.keymap.read().resolve(key), Some(GlobalAction::Interrupt))
5020                    };
5021                    if interrupt {
5022                        let _ = evt_tx.send(InlineEvent::Interrupt);
5023                        continue;
5024                    }
5025                    handle_confirmation_key(&state, &evt_tx, &issue_action_tx, key.code);
5026                    continue;
5027                }
5028            }
5029
5030            // `/issue` panel — modal: while open it consumes navigation,
5031            // status-toggle, and dismissal keys so nothing leaks into the
5032            // composer underneath. Outranks the global-shortcut resolution
5033            // below so e.g. a remapped palette shortcut cannot open an
5034            // invisible command palette that would then outrank the panel.
5035            {
5036                let s = state.lock();
5037                if s.issues_panel.is_some() {
5038                    drop(s);
5039                    if crate::tui_vt::issues_panel::handle_issues_panel_key(
5040                        &state,
5041                        &issue_action_tx,
5042                        key,
5043                    ) {
5044                        continue;
5045                    }
5046                }
5047            }
5048
5049            // Global shortcuts: resolve through the live keymap. The
5050            // defaults match the historical hardcoded Ctrl-* bindings;
5051            // `settings.keybindings` can rebind any of them and the
5052            // keybindings editor swaps the map in place. The branch's
5053            // hardcoded dispatch was removed; `apply_global_action`
5054            // now handles every unified GlobalAction variant.
5055            {
5056                let action = {
5057                    let s = state.lock();
5058                    s.keymap.read().resolve(key)
5059                };
5060                if let Some(action) = action {
5061                    apply_global_action(action, &state, &evt_tx);
5062                    continue;
5063                }
5064            }
5065
5066            // Overlay key handling takes priority — when an overlay is
5067            // open, Up/Down navigate, Enter submits, Esc cancels, and any
5068            // printable char is captured for the search bar (if any).
5069            // All other keys are swallowed so the composer buffer stays
5070            // frozen while the user is interacting with the overlay.
5071            {
5072                let s = state.lock();
5073                if s.overlay.is_some() {
5074                    drop(s);
5075                    if handle_overlay_key(&state, &evt_tx, key.code) {
5076                        continue;
5077                    }
5078                }
5079            }
5080
5081            // @-file-search dropdown — when the picker is open, intercept
5082            // navigation and accept keys. Regular chars fall through to
5083            // normal buffer insertion so the user can keep typing.
5084            {
5085                let s = state.lock();
5086                if s.file_search.is_some() {
5087                    drop(s);
5088                    if handle_file_search_key(&state, &evt_tx, key.code) {
5089                        continue;
5090                    }
5091                }
5092            }
5093
5094            // Git TUI overlay has absolute key priority when open — keys
5095            // route through `match_git_key` first; commit-mode chars are
5096            // appended to the message; unmatched keys do NOT fall through
5097            // to the composer (the brief: overlay REPLACES the composer).
5098            if state.lock().git_tui.is_some() && handle_git_tui_key(&state, key.code, key.modifiers)
5099            {
5100                continue;
5101            }
5102
5103            // Generic keymap dispatch (final-review finding 6): the
5104            // four actions that historically lived only inside
5105            // hardcoded arms below — Submit, ScrollUp, ScrollDown,
5106            // Help — are consulted BEFORE those arms so a user
5107            // rebind (e.g. `submit: alt+s` in keybindings.yml)
5108            // actually fires. Keys the keymap does NOT bind to these
5109            // actions fall through to the arms, which act as the
5110            // fallback (Enter-as-newline in multiline, `?` on the
5111            // empty composer, …). Placed after the modal handlers
5112            // above so overlay/confirmation/git keys keep priority.
5113            let multiline_mode = state.lock().multiline_mode;
5114            match keymap_pre_match(&keymap, &key, multiline_mode) {
5115                KeymapDispatch::Submit => {
5116                    // Shell mode: submit the buffer as a bash command
5117                    // request.
5118                    if state.lock().shell_mode {
5119                        let submitted = {
5120                            let mut s = state.lock();
5121                            let buf = s.composer.text().to_string();
5122                            s.composer.set_text("");
5123                            s.shell_mode = false;
5124                            s.history_pos = None;
5125                            if !buf.is_empty() {
5126                                s.prompt_history.insert(0, buf.clone());
5127                                s.prompt_history.truncate(100);
5128                            }
5129                            buf
5130                        };
5131                        if !submitted.is_empty() {
5132                            let prompt = format!("Run this shell command: `{submitted}`");
5133                            let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
5134                        }
5135                        continue;
5136                    }
5137                    let submitted = harvest_and_clear_input(&state);
5138                    let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
5139                    continue;
5140                }
5141                KeymapDispatch::ScrollPageUp => {
5142                    let _ = evt_tx.send(InlineEvent::ScrollPageUp);
5143                    continue;
5144                }
5145                KeymapDispatch::ScrollPageDown => {
5146                    let _ = evt_tx.send(InlineEvent::ScrollPageDown);
5147                    continue;
5148                }
5149                KeymapDispatch::Help => {
5150                    let mut s = state.lock();
5151                    s.overlay = Some(cheatsheet_overlay());
5152                    continue;
5153                }
5154                KeymapDispatch::None => {}
5155            }
5156
5157            match key.code {
5158                // Shift+Tab — cycle autonomy mode Default <-> Auto.
5159                KeyCode::BackTab => {
5160                    if let Some(h) = &mode_handle {
5161                        let new_mode = Mode::load(h).toggle();
5162                        h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
5163                        let label = new_mode.label();
5164                        let detail = if new_mode.is_auto() {
5165                            "autonomous — no questions, runs to completion"
5166                        } else {
5167                            "interactive — may ask questions"
5168                        };
5169                        let mut s = state.lock();
5170                        s.autonomy_mode = new_mode;
5171                        s.tip = Some(EphemeralTip {
5172                            text: format!("Mode: {label} — {detail}"),
5173                            born_tick: 0,
5174                            ttl_ticks: 240,
5175                            key: "mode_toggle",
5176                            ambient: false,
5177                        });
5178                    }
5179                    continue;
5180                }
5181                KeyCode::Enter => {
5182                    // Fallback arm (final-review finding 6): reached
5183                    // only when the keymap does NOT bind Submit to
5184                    // this key — the generic dispatch above consumed
5185                    // every Submit-bound keypress (including
5186                    // non-Enter rebinds like `submit: alt+s`). The
5187                    // newline-insert branch stays unconditional:
5188                    // while in multiline mode, plain Enter inserts a
5189                    // real `\n` regardless of how the user has
5190                    // rebound `submit`. Any other Enter is swallowed
5191                    // — submit is disabled at this key.
5192                    let multiline = state.lock().multiline_mode;
5193                    let shift = key
5194                        .modifiers
5195                        .contains(crossterm::event::KeyModifiers::SHIFT);
5196                    if multiline && !shift {
5197                        let mut s = state.lock();
5198                        s.composer.insert_str("\n");
5199                    }
5200                }
5201                KeyCode::Esc => {
5202                    // Esc ladder (grok-build-style):
5203                    // 1. Slash popup open → close popup
5204                    // 2. Input non-empty + 2nd Esc within 800ms → clear buffer
5205                    // 3. Input non-empty + 1st Esc → arm "press again to clear"
5206                    // 4. Empty input → cancel the run (with ~1s post-cancel
5207                    //    grace so mashing Esc doesn't fire repeated cancels)
5208                    let mut s = state.lock();
5209                    if s.shell_mode {
5210                        s.shell_mode = false;
5211                        s.composer.set_text("");
5212                    } else if s.slash_popup.open {
5213                        s.slash_popup = SlashPopup::default();
5214                    } else if !s.composer.is_empty() {
5215                        let now = std::time::Instant::now();
5216                        let is_double = s
5217                            .last_esc_at
5218                            .map(|t| now.duration_since(t).as_millis() < 800)
5219                            .unwrap_or(false);
5220                        if is_double {
5221                            s.composer.set_text("");
5222                            s.last_esc_at = None;
5223                        } else {
5224                            s.last_esc_at = Some(now);
5225                            // Ephemeral hint so the user learns the
5226                            // double-Esc-to-clear gesture.
5227                            s.tip = Some(EphemeralTip {
5228                                text: "Press Esc again to clear input".to_string(),
5229                                born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5230                                ttl_ticks: 120,
5231                                key: "esc_clear",
5232                                ambient: false,
5233                            });
5234                        }
5235                    } else {
5236                        let now = std::time::Instant::now();
5237                        let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
5238                        if in_grace {
5239                            // Swallow — already cancelling.
5240                        } else {
5241                            s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
5242                            s.last_esc_at = None;
5243                            drop(s);
5244                            let _ = evt_tx.send(InlineEvent::Cancel);
5245                        }
5246                    }
5247                }
5248                KeyCode::Tab => {
5249                    // Complete the selected slash command into the buffer
5250                    let mut s = state.lock();
5251                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5252                        let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
5253                        s.composer.set_text(&format!("/{} ", name));
5254                        refresh_input_popups(&mut s);
5255                    }
5256                }
5257                KeyCode::Backspace => {
5258                    let mut s = state.lock();
5259                    s.composer.input(crossterm::event::KeyEvent::new(
5260                        KeyCode::Backspace,
5261                        KeyModifiers::NONE,
5262                    ));
5263                    refresh_input_popups(&mut s);
5264                }
5265                KeyCode::Delete => {
5266                    let mut s = state.lock();
5267                    s.composer.input(crossterm::event::KeyEvent::new(
5268                        KeyCode::Delete,
5269                        KeyModifiers::NONE,
5270                    ));
5271                    refresh_input_popups(&mut s);
5272                }
5273                KeyCode::Left => {
5274                    let mut s = state.lock();
5275                    s.composer.input(crossterm::event::KeyEvent::new(
5276                        KeyCode::Left,
5277                        KeyModifiers::NONE,
5278                    ));
5279                }
5280                KeyCode::Right => {
5281                    let mut s = state.lock();
5282                    s.composer.input(crossterm::event::KeyEvent::new(
5283                        KeyCode::Right,
5284                        KeyModifiers::NONE,
5285                    ));
5286                }
5287                KeyCode::Up => {
5288                    let mut s = state.lock();
5289                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5290                        let len = s.slash_popup.items.len();
5291                        s.slash_popup.selected = if s.slash_popup.selected == 0 {
5292                            len - 1
5293                        } else {
5294                            s.slash_popup.selected - 1
5295                        };
5296                    } else if s.queue_panel_open
5297                        && !s.queued_inputs.is_empty()
5298                        && s.composer.is_empty()
5299                    {
5300                        s.queue_selected = if s.queue_selected == 0 {
5301                            s.queued_inputs.len() - 1
5302                        } else {
5303                            s.queue_selected - 1
5304                        };
5305                    } else if s.composer.is_empty() && !s.prompt_history.is_empty() {
5306                        // History recall: fill the prompt with the previous entry.
5307                        let pos = s.history_pos.unwrap_or(0);
5308                        let next = (pos + 1).min(s.prompt_history.len() - 1);
5309                        s.history_pos = Some(next);
5310                        let entry = s.prompt_history[next].clone();
5311                        s.composer.set_text(&entry);
5312                        drop(s);
5313                        let _ = evt_tx.send(InlineEvent::ScrollLineUp);
5314                    }
5315                }
5316                KeyCode::Down => {
5317                    let mut s = state.lock();
5318                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5319                        let len = s.slash_popup.items.len();
5320                        s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
5321                            0
5322                        } else {
5323                            s.slash_popup.selected + 1
5324                        };
5325                    } else if s.queue_panel_open
5326                        && !s.queued_inputs.is_empty()
5327                        && s.composer.is_empty()
5328                    {
5329                        s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
5330                            0
5331                        } else {
5332                            s.queue_selected + 1
5333                        };
5334                    } else {
5335                        drop(s);
5336                        let _ = evt_tx.send(InlineEvent::ScrollLineDown);
5337                    }
5338                }
5339                // (PageUp/PageDown scroll dispatch moved to the generic
5340                // keymap pre-match above — final-review finding 6. Keys
5341                // not bound to ScrollUp/ScrollDown fall through to the
5342                // catch-all below and are swallowed.)
5343                KeyCode::Char(ch) => {
5344                    let mut s = state.lock();
5345                    // @! hidden-file toggle: when the picker is open and '!'
5346                    // is typed immediately after '@', toggle hidden mode
5347                    // instead of inserting '!'.
5348                    if s.file_search.is_some()
5349                        && ch == '!'
5350                        && s.composer.text()[..s.composer.cursor()].ends_with('@')
5351                    {
5352                        let cwd = s.cwd.clone();
5353                        if let Some(fs) = s.file_search.as_mut() {
5354                            fs.toggle_hidden(&cwd);
5355                        }
5356                        continue;
5357                    }
5358                    if s.agent_hub_open && ch == 'q' {
5359                        s.agent_hub_open = false;
5360                    } else if s.vim_state.enabled() && !s.slash_popup.open {
5361                        // Route through the vim engine. Deref the guard so
5362                        // we can borrow multiple fields simultaneously.
5363                        let s = &mut *s;
5364                        let vkey =
5365                            crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
5366                        let mut editor = InputEditor::new(&mut s.composer);
5367                        let outcome = crate::tui_vt::vim::handle_key(
5368                            &mut s.vim_state,
5369                            &mut editor,
5370                            &mut s.vim_clipboard,
5371                            &vkey,
5372                        );
5373                        if outcome.handled {
5374                            refresh_input_popups(s);
5375                        }
5376                    } else if s.composer.is_empty() && !s.slash_popup.open {
5377                        // Shell mode: `!` on empty buffer enters bash mode.
5378                        if ch == '!' && !s.shell_mode {
5379                            s.shell_mode = true;
5380                            continue;
5381                        }
5382                        // Queue panel interactive mode takes priority when
5383                        // open and the buffer is empty. Keys that don't
5384                        // match fall through to scrollback nav below.
5385                        if s.queue_panel_open && !s.queued_inputs.is_empty() {
5386                            let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
5387                            match ch {
5388                                'x' | 'X' => {
5389                                    let _ = prompt_queue.remove(idx);
5390                                    s.queued_inputs.remove(idx);
5391                                    if s.queue_selected >= s.queued_inputs.len()
5392                                        && !s.queued_inputs.is_empty()
5393                                    {
5394                                        s.queue_selected = s.queued_inputs.len() - 1;
5395                                    }
5396                                    continue;
5397                                }
5398                                'e' => {
5399                                    if let Some(entry) = prompt_queue.remove(idx) {
5400                                        s.queued_inputs.remove(idx);
5401                                        s.composer.set_text(&entry);
5402                                        s.queue_panel_open = false;
5403                                        continue;
5404                                    }
5405                                }
5406                                'J' => {
5407                                    if prompt_queue.move_by(idx, 1)
5408                                        && idx + 1 < s.queued_inputs.len()
5409                                    {
5410                                        s.queued_inputs.swap(idx, idx + 1);
5411                                        s.queue_selected = idx + 1;
5412                                    }
5413                                    continue;
5414                                }
5415                                'K' => {
5416                                    if idx > 0 && prompt_queue.move_by(idx, -1) {
5417                                        s.queued_inputs.swap(idx, idx - 1);
5418                                        s.queue_selected = idx - 1;
5419                                    }
5420                                    continue;
5421                                }
5422                                _ => {} // fall through to scrollback nav
5423                            }
5424                        }
5425                        // When the prompt is empty, intercept scrollback
5426                        // navigation keys (matching grok-build's scrollback-
5427                        // focus semantics). Any other char falls through to
5428                        // normal insertion so the user can start typing.
5429                        // Printable Help bindings keep their historical
5430                        // empty-composer gate here (typing `?` inside
5431                        // text must insert it); non-printable rebinds
5432                        // dispatch via the generic pre-match above.
5433                        if keymap.matches(GlobalAction::Help, &key) {
5434                            s.overlay = Some(cheatsheet_overlay());
5435                        } else if matches!(ch, 'e') {
5436                            s.cycle_block_at_view();
5437                        } else if matches!(ch, 'E') {
5438                            s.expand_all();
5439                        } else if matches!(ch, 'J') {
5440                            s.jump_next_turn();
5441                        } else if matches!(ch, 'K') {
5442                            s.jump_prev_turn();
5443                        } else if matches!(ch, 'n') && s.search.is_some() {
5444                            s.search_next();
5445                        } else if matches!(ch, 'N') && s.search.is_some() {
5446                            s.search_prev();
5447                        } else {
5448                            s.composer.input(crossterm::event::KeyEvent::new(
5449                                KeyCode::Char(ch),
5450                                key.modifiers,
5451                            ));
5452                            refresh_input_popups(&mut s);
5453                        }
5454                    } else {
5455                        s.composer.input(crossterm::event::KeyEvent::new(
5456                            KeyCode::Char(ch),
5457                            key.modifiers,
5458                        ));
5459                        refresh_input_popups(&mut s);
5460                    }
5461                    // plan_nudge: surface /compact when user mentions "plan".
5462                    if s.tip.is_none() && s.composer.text().to_lowercase().contains("plan") {
5463                        s.show_tip(
5464                            "plan_nudge",
5465                            "Try /compact to summarize and plan ahead",
5466                            180,
5467                            true,
5468                        );
5469                    }
5470                }
5471                _ => {}
5472            }
5473        }
5474    })
5475}
5476
5477/// Resolve a keystroke against the active confirmation modal. `y`/Enter
5478/// confirms — dispatches the bound [`ConfirmationAction`]; `n`/`x`/Esc
5479/// cancels. Always consumes the key while a confirmation is open.
5480fn handle_confirmation_key(
5481    state: &Arc<parking_lot::Mutex<RenderState>>,
5482    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5483    issue_action_tx: &tokio::sync::mpsc::UnboundedSender<
5484        crate::tui_vt::issues_panel::IssueActionRequest,
5485    >,
5486    code: KeyCode,
5487) {
5488    let mut s = state.lock();
5489    let Some(confirm) = s.confirmation.clone() else {
5490        return;
5491    };
5492    match code {
5493        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
5494            s.confirmation = None;
5495            drop(s);
5496            match confirm.action {
5497                ConfirmationAction::Quit => {
5498                    let _ = evt_tx.send(InlineEvent::Exit);
5499                }
5500                ConfirmationAction::ClearConversation => {
5501                    // Re-dispatch /clear with --yes so it flows through the
5502                    // normal command pipeline (where `session.reset()` is
5503                    // accessible). The sentinel arg bypasses the dialog.
5504                    let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
5505                }
5506                ConfirmationAction::CloseIssue(id) => {
5507                    let (caller, hash, cwd) = {
5508                        let s = state.lock();
5509                        let hash = s
5510                            .issue_store
5511                            .as_ref()
5512                            .and_then(|store| store.read(id).ok())
5513                            .map(|(_, h)| h);
5514                        (
5515                            oxicode_sdk::liveness::TUI_OWNERSHIP_ID.to_string(),
5516                            hash,
5517                            s.cwd.clone(),
5518                        )
5519                    };
5520                    let _ = cwd; // store is already rooted; kept for clarity/future use
5521                    let _ = issue_action_tx.send(
5522                        crate::tui_vt::issues_panel::IssueActionRequest::Close { id, caller, hash },
5523                    );
5524                    let mut s = state.lock();
5525                    if let Some(panel) = s.issues_panel.as_mut() {
5526                        panel.pending = true;
5527                    }
5528                }
5529                ConfirmationAction::RemoveProviderKey(name) => {
5530                    // Re-dispatch /providers remove <name> --yes so it flows
5531                    // through the normal command pipeline. The sentinel arg
5532                    // bypasses the confirm dialog.
5533                    let _ = evt_tx.send(InlineEvent::Submit(
5534                        format!("/providers remove {name} --yes").into(),
5535                    ));
5536                }
5537            }
5538        }
5539        KeyCode::Char('n')
5540        | KeyCode::Char('N')
5541        | KeyCode::Char('x')
5542        | KeyCode::Char('X')
5543        | KeyCode::Esc => {
5544            s.confirmation = None;
5545        }
5546        _ => {}
5547    }
5548}
5549
5550/// Handle a single keystroke while an overlay is open. Returns `true` if the
5551/// key was consumed (whether it changed state or not). Always returns `false`
5552/// when no overlay is open so the caller can fall through to the regular
5553/// input-thread key dispatch.
5554fn handle_overlay_key(
5555    state: &Arc<parking_lot::Mutex<RenderState>>,
5556    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5557    code: KeyCode,
5558) -> bool {
5559    use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
5560
5561    let mut s = state.lock();
5562    let Some(overlay) = s.overlay.as_mut() else {
5563        return false;
5564    };
5565    // Secure (masked) single-line prompt: takes precedence over list
5566    // navigation. Char / Backspace / Left / Right / Enter / Esc route
5567    if let Some(secure) = overlay.secure_input.as_mut() {
5568        use oxicode_textarea::EditCommand;
5569        match code {
5570            KeyCode::Backspace => {
5571                // Delete the grapheme (or atomic element) immediately before
5572                // the cursor. When the cursor sits at the end of the masked
5573                // element, this removes the whole value in one operation.
5574                if secure.editor.cursor_byte() > 0 {
5575                    let _ = secure.editor.apply(EditCommand::DeleteGraphemeBackward);
5576                }
5577            }
5578            KeyCode::Left => {
5579                let _ = secure.editor.apply(EditCommand::MoveGraphemeLeft);
5580            }
5581            KeyCode::Right => {
5582                let _ = secure.editor.apply(EditCommand::MoveGraphemeRight);
5583            }
5584            KeyCode::Enter => {
5585                // Submit the editor's text — this is the only path that
5586                // reaches the real secret value, and it leaves the editor
5587                // intact for any render that follows before the overlay is
5588                // torn down.
5589                let submission = OverlaySubmission::SecureInput(secure.editor.text().to_string());
5590                drop(s);
5591                state.lock().overlay = None;
5592                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
5593            }
5594            KeyCode::Esc => {
5595                drop(s);
5596                state.lock().overlay = None;
5597                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5598            }
5599            KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
5600                // Single-line ASCII filter; the renderer never paints the
5601                // underlying text, so this just keeps the buffer predictable.
5602                let _ = secure.editor.apply(EditCommand::Insert(ch));
5603            }
5604            _ => {} // ignore other keys while the secure prompt is open
5605        }
5606        return true;
5607    }
5608
5609    // Settings map-editor hotkeys. These operate on `RenderState`
5610    // directly (they persist + rebuild the panel), so the overlay
5611    // borrow from the secure branch must end first. Only active with no
5612    // search filter — while filtering, letters keep typing into the
5613    // search box (the helpers re-check that the tabbed panel is open
5614    // and the selected row is a map row).
5615    let search_empty = s
5616        .overlay
5617        .as_ref()
5618        .and_then(|o| o.search.as_ref())
5619        .is_none_or(|search| search.value.is_empty());
5620    let map_row_consumed = match code {
5621        KeyCode::Enter if try_edit_model_role(&mut s) => true,
5622        KeyCode::Char('d') if search_empty && try_remove_settings_map_row(&mut s) => true,
5623        KeyCode::Char('n') if search_empty && try_start_new_model_role(&mut s) => true,
5624        _ => false,
5625    };
5626    if map_row_consumed {
5627        return true;
5628    }
5629    let Some(overlay) = s.overlay.as_mut() else {
5630        return false;
5631    };
5632
5633    match code {
5634        KeyCode::Esc => {
5635            // Cancel the overlay and notify the harness.
5636            drop(s);
5637            state.lock().overlay = None;
5638            let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5639        }
5640        KeyCode::Enter => {
5641            // Submit the currently selected item. If no item is selected
5642            // (empty list), we still close the overlay with a cancel.
5643            let submission = if let Some(item) = overlay.items.get(overlay.selected) {
5644                match item.selection.clone() {
5645                    Some(sel) => sel,
5646                    None => {
5647                        // Read-only / informational item (no InlineListSelection,
5648                        // e.g. /tools, /mcp, the /settings Model row): Enter is
5649                        // a no-op — keep the overlay open so the user can keep
5650                        // browsing (Esc closes). Avoids polluting the prompt
5651                        // with a synthetic "/overlay:N" command.
5652                        return true;
5653                    }
5654                }
5655            } else {
5656                drop(s);
5657                state.lock().overlay = None;
5658                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5659                return true;
5660            };
5661            let title = overlay.title.clone();
5662            let selected = overlay.selected;
5663            drop(s);
5664            state.lock().overlay = None;
5665            tracing::debug!(
5666                overlay = %title,
5667                selected,
5668                "overlay submitted"
5669            );
5670            let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
5671                OverlaySubmission::Selection(submission),
5672            )));
5673        }
5674        KeyCode::Up => {
5675            let len = overlay_filtered_indices(overlay).len();
5676            if len == 0 {
5677                return true;
5678            }
5679            let pos = overlay_filtered_indices(overlay)
5680                .iter()
5681                .position(|&i| i == overlay.selected)
5682                .unwrap_or(0);
5683            let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
5684            overlay.selected = overlay_filtered_indices(overlay)[new_pos];
5685        }
5686        KeyCode::Down => {
5687            let filtered = overlay_filtered_indices(overlay);
5688            let len = filtered.len();
5689            if len == 0 {
5690                return true;
5691            }
5692            let pos = filtered
5693                .iter()
5694                .position(|&i| i == overlay.selected)
5695                .unwrap_or(0);
5696            let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
5697            overlay.selected = filtered[new_pos];
5698        }
5699        KeyCode::Backspace => {
5700            if let Some(search) = overlay.search.as_mut() {
5701                search.value.pop();
5702                overlay.selected = 0;
5703            }
5704        }
5705        KeyCode::Char(ch) => {
5706            if let Some(search) = overlay.search.as_mut() {
5707                search.value.push(ch);
5708                overlay.selected = 0;
5709            }
5710        }
5711        KeyCode::Left | KeyCode::Right => {
5712            // Tabbed overlays (the settings panel): ←/→ cycle the tab
5713            // bar, rebuilding items/sections for the new tab. The search
5714            // filter survives the switch.
5715            let tab_count = overlay.tabs.len();
5716            if tab_count > 1 {
5717                let next = if code == KeyCode::Right {
5718                    (overlay.active_tab + 1) % tab_count
5719                } else {
5720                    overlay.active_tab.checked_sub(1).unwrap_or(tab_count - 1)
5721                };
5722                switch_settings_tab(&mut s, next);
5723            }
5724        }
5725        _ => {
5726            // Swallow all other keys while an overlay is open.
5727        }
5728    }
5729    true
5730}
5731
5732/// Return the indices of `overlay.items` that match the current search filter.
5733/// When no search is configured (or the search field is empty), returns every
5734/// index. Used by both the renderer and the input thread so they agree on
5735/// which item is "selected" after navigation or filter changes.
5736fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
5737    let needle = overlay
5738        .search
5739        .as_ref()
5740        .map(|s| s.value.to_lowercase())
5741        .unwrap_or_default();
5742    if needle.is_empty() {
5743        return (0..overlay.items.len()).collect();
5744    }
5745    overlay
5746        .items
5747        .iter()
5748        .enumerate()
5749        .filter_map(|(idx, item)| {
5750            let title_hit = item.title.to_lowercase().contains(&needle);
5751            let sv_hit = item
5752                .search_value
5753                .as_deref()
5754                .map(|v| v.to_lowercase().contains(&needle))
5755                .unwrap_or(false);
5756            if title_hit || sv_hit { Some(idx) } else { None }
5757        })
5758        .collect()
5759}
5760
5761/// Handle a single keystroke while the @-file-search dropdown is open.
5762/// Returns `true` if the key was consumed. Up/Down navigate, Tab/Enter
5763/// accept the selection (inserting `@path ` without submitting), Esc
5764/// cancels. Regular chars fall through (`false`) so they enter the buffer
5765/// and trigger `refresh_file_search` to re-filter.
5766fn handle_file_search_key(
5767    state: &Arc<parking_lot::Mutex<RenderState>>,
5768    _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5769    code: KeyCode,
5770) -> bool {
5771    match code {
5772        KeyCode::Up => {
5773            let mut s = state.lock();
5774            if let Some(fs) = s.file_search.as_mut() {
5775                fs.up();
5776                true
5777            } else {
5778                false
5779            }
5780        }
5781        KeyCode::Down => {
5782            let mut s = state.lock();
5783            if let Some(fs) = s.file_search.as_mut() {
5784                fs.down();
5785                true
5786            } else {
5787                false
5788            }
5789        }
5790        KeyCode::Tab | KeyCode::Enter => {
5791            let mut s = state.lock();
5792            if s.file_search
5793                .as_ref()
5794                .and_then(|fs| fs.selected_result())
5795                .is_some()
5796            {
5797                accept_file_search(&mut s, false);
5798                true
5799            } else {
5800                // No results: close the picker, let Enter fall through.
5801                s.file_search = None;
5802                false
5803            }
5804        }
5805        KeyCode::Esc => {
5806            let mut s = state.lock();
5807            s.file_search = None;
5808            true
5809        }
5810        _ => false,
5811    }
5812}
5813
5814/// Route one keystroke through the git TUI overlay. Returns `true` when
5815/// the overlay consumed it (so the caller must `continue` and not fall
5816/// through to the composer), `false` when the overlay wasn't open (or
5817/// when commit-mode refused to handle the key — never happens today).
5818///
5819/// Commit-mode text input is handled here too: printable characters
5820/// append to the message, Backspace pops, Enter commits, Esc cancels.
5821fn handle_git_tui_key(
5822    state: &Arc<parking_lot::Mutex<RenderState>>,
5823    code: KeyCode,
5824    modifiers: KeyModifiers,
5825) -> bool {
5826    use crate::tui_vt::git_tui::{GitKeyAction, match_git_key};
5827    use crossterm::event::KeyEvent;
5828
5829    let mut s = state.lock();
5830    if s.git_tui.is_none() {
5831        return false;
5832    }
5833    let cwd = s.cwd.clone();
5834    let Some(git) = s.git_tui.as_mut() else {
5835        unreachable!("checked above");
5836    };
5837    if git.commit_mode {
5838        match code {
5839            KeyCode::Esc => {
5840                git.commit_mode = false;
5841                git.commit_msg.clear();
5842                return true;
5843            }
5844            KeyCode::Enter => {
5845                if let Err(err) = git.commit(&cwd) {
5846                    tracing::warn!(?err, "git commit failed");
5847                    // Surface as a tip so the user sees the reason.
5848                    s.tip = Some(EphemeralTip {
5849                        text: format!("git commit failed: {err}"),
5850                        born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5851                        ttl_ticks: 240,
5852                        key: "git-commit-error",
5853                        ambient: false,
5854                    });
5855                }
5856                return true;
5857            }
5858            KeyCode::Backspace => {
5859                git.commit_backspace();
5860                return true;
5861            }
5862            KeyCode::Char(c) => {
5863                if !modifiers.contains(KeyModifiers::CONTROL)
5864                    && !modifiers.contains(KeyModifiers::ALT)
5865                {
5866                    git.commit_input_char(c);
5867                }
5868                return true;
5869            }
5870            _ => return true, // swallow anything else while in commit mode
5871        }
5872    }
5873
5874    // Map raw key to an overlay action. Unmatched keys are dropped (do
5875    // NOT fall through to the composer per the brief).
5876    let key = KeyEvent::new(code, modifiers);
5877    let Some(action) = match_git_key(&key) else {
5878        return true;
5879    };
5880    if matches!(action, GitKeyAction::Close) {
5881        // Closing clears the overlay entirely.
5882        s.git_tui = None;
5883        return true;
5884    }
5885    if let Err(err) = git.apply_action(&cwd, action) {
5886        s.tip = Some(EphemeralTip {
5887            text: format!("/git: {err}"),
5888            born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5889            ttl_ticks: 240,
5890            key: "git-action-error",
5891            ambient: false,
5892        });
5893    }
5894    true
5895}
5896
5897// ───────────────────────────────────────────────────────────────────────
5898// Agent worker thread — owns the agent run loop, forwards events to the
5899// session bus, and accepts new prompts from a tokio channel.
5900// ─────────────────────────────────────────────────────────────────────────
5901
5902fn spawn_agent_worker(
5903    session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
5904    prompt_queue: Arc<PromptQueue>,
5905) {
5906    std::thread::spawn(move || {
5907        let runtime = match tokio::runtime::Builder::new_current_thread()
5908            .enable_all()
5909            .build()
5910        {
5911            Ok(rt) => rt,
5912            Err(err) => {
5913                tracing::error!(?err, "failed to build agent worker runtime");
5914                return;
5915            }
5916        };
5917
5918        runtime.block_on(async move {
5919            let local = tokio::task::LocalSet::new();
5920            local
5921                .run_until(async move {
5922                    loop {
5923                        let prompt = prompt_queue.next().await;
5924                        run_one_prompt(&session_swapper.current(), prompt).await;
5925                    }
5926                })
5927                .await;
5928        });
5929    });
5930}
5931
5932async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
5933    let session_for_forward = session.clone();
5934    let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
5935
5936    // Forwarder thread — runs `forward_event_to_extensions` on each event
5937    // so the AgentSession's subscribers (and therefore the main loop)
5938    // observe it.
5939    let forwarder = std::thread::spawn(move || {
5940        while let Ok(event) = event_rx.recv() {
5941            session_for_forward.forward_event_to_extensions(&event);
5942        }
5943    });
5944
5945    // Reset the stop flag (a previous Ctrl+C may have left it set) and
5946    // mark streaming so the Ctrl+C policy can distinguish "interrupt"
5947    // from "quit". The guard clears the flag on any exit path.
5948    use std::sync::atomic::Ordering;
5949    session.reset_should_stop();
5950    let streaming = session.streaming_flag();
5951    streaming.store(true, Ordering::SeqCst);
5952    let _stream_guard = StreamingGuard(&streaming);
5953
5954    let agent = session.agent_ref();
5955    let local = tokio::task::LocalSet::new();
5956    let result = local
5957        .run_until(agent.run_with_channel(prompt, event_tx))
5958        .await;
5959
5960    // Wait for the forwarder to drain the channel (sender dropped when
5961    // `run_with_channel` returns).
5962    let _ = forwarder.join();
5963    if let Err(err) = result {
5964        tracing::warn!(?err, "agent run failed");
5965    }
5966}
5967
5968// ─────────────────────────────────────────────────────────────────────────
5969// Header / AgentSession construction
5970// ─────────────────────────────────────────────────────────────────────────
5971
5972// ─────────────────────────────────────────────────────────────────────────
5973// Header / AgentSession construction
5974// ─────────────────────────────────────────────────────────────────────────
5975
5976fn build_header_context(
5977    app: &App,
5978    cwd: &std::path::Path,
5979    git_branch: Option<&str>,
5980) -> InlineHeaderContext {
5981    let workspace_name = cwd
5982        .file_name()
5983        .map(|n| n.to_string_lossy().into_owned())
5984        .unwrap_or_else(|| "oxicode".to_string());
5985    let model_id = app.model_id();
5986    let provider = model_id
5987        .split_once('/')
5988        .map(|(p, _)| p.to_string())
5989        .unwrap_or_else(|| "Provider".to_string());
5990    let branch = git_branch.unwrap_or("\u{2014}").to_string();
5991    let mut ctx = InlineHeaderContext::default();
5992    ctx.app_name = "oxicode".to_string();
5993    ctx.provider = provider;
5994    ctx.model = model_id.clone();
5995    ctx.git = format!("git: {workspace_name}@{branch}");
5996    ctx.tools = "Tools: ready".to_string();
5997    ctx.search_tools = Some(InlineHeaderStatusBadge {
5998        text: workspace_name,
5999        tone: InlineHeaderStatusTone::Ready,
6000    });
6001    ctx.persistent_memory = Some(InlineHeaderStatusBadge {
6002        text: branch,
6003        tone: InlineHeaderStatusTone::Ready,
6004    });
6005    ctx.editor_context = Some(model_id);
6006    ctx
6007}
6008
6009/// Construct an `AgentSession` for the TUI using the runtime helpers from
6010/// `agent_session_runtime`. Mirrors the wiring in the legacy `tui/` harness.
6011async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
6012    use crate::app::agent_session_runtime::{
6013        CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
6014        create_agent_session_from_services, create_agent_session_services,
6015    };
6016    use crate::store::session::SessionManager;
6017
6018    let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
6019    let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
6020    let services = create_agent_session_services(
6021        CreateAgentSessionServicesOptions::new(cwd.clone()),
6022        Some(hook_runner),
6023    )?;
6024    let services = Arc::new(services);
6025
6026    let model_id = app.model_id();
6027    let tools = app.agent_tools();
6028
6029    let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
6030
6031    let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
6032        services,
6033        session_manager,
6034        model_id: if model_id.is_empty() {
6035            None
6036        } else {
6037            Some(model_id)
6038        },
6039        thinking_level: None,
6040        scoped_models: Vec::new(),
6041        tool_registry: Some(tools),
6042        oxicode: Some(app.oxicode().clone()),
6043        // TUI runtime: share the App's session state so /steer, /follow_up,
6044        // and Ctrl+C continue to take effect across the session.
6045        session_state: Some(app.session_state().clone()),
6046    })
6047    .await?;
6048
6049    if let Some(msg) = result.model_fallback_message {
6050        tracing::warn!(message = %msg, "agent session model fallback");
6051    }
6052    Ok(result.session)
6053}
6054
6055// ─────────────────────────────────────────────────────────────────────────
6056// Rendering
6057// ─────────────────────────────────────────────────────────────────────────
6058
6059/// Lines for the keyboard shortcuts cheatsheet overlay.
6060fn cheatsheet_lines() -> Vec<String> {
6061    vec![
6062        "".into(),
6063        "  Navigation".into(),
6064        "  j / ↓        Scroll down".into(),
6065        "  k / ↑        Scroll up".into(),
6066        "  J (Shift+j)  Next turn".into(),
6067        "  K (Shift+k)  Previous turn".into(),
6068        "  PgDn / PgUp  Page scroll".into(),
6069        "  g / G        Top / bottom".into(),
6070        "".into(),
6071        "  Blocks".into(),
6072        "  e            Cycle block (collapse/truncate/expand)".into(),
6073        "  E            Expand all blocks".into(),
6074        "  Ctrl+E       Collapse all blocks".into(),
6075        "".into(),
6076        "  Search".into(),
6077        "  /find <q>    Search transcript".into(),
6078        "  n / N        Next / previous match".into(),
6079        "".into(),
6080        "  Commands".into(),
6081        "  /theme       Cycle color theme".into(),
6082        "  /model       Pick a model".into(),
6083        "  /vim         Toggle vim mode".into(),
6084        "  /compact     Compact context".into(),
6085        "  /clear       Clear conversation".into(),
6086        "  Ctrl+C       Cancel run (then y to quit)".into(),
6087        "  Ctrl+Enter   Send now (abort + submit)".into(),
6088        "  Ctrl+M       Toggle multiline input".into(),
6089        "  Shift+Tab    Toggle Auto mode (no questions, runs to end)".into(),
6090        "  Ctrl+;       Toggle queue panel".into(),
6091        "".into(),
6092        "  Special Input".into(),
6093        "  @           File picker (fuzzy search)".into(),
6094        "  @!          Toggle hidden files in picker".into(),
6095        "  !           Shell mode (bash command)".into(),
6096    ]
6097}
6098
6099/// Build the command palette overlay — a searchable list of all slash
6100/// commands plus quick actions. Triggered by Ctrl+P.
6101fn build_command_palette() -> OverlayState {
6102    use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
6103
6104    let catalog = SlashRegistry::builtin_commands();
6105    let mut items: Vec<InlineListItem> = catalog
6106        .iter()
6107        .map(|(name, desc, aliases)| {
6108            let title = if aliases.is_empty() {
6109                format!("/{name}")
6110            } else {
6111                format!(
6112                    "/{name} ({})",
6113                    aliases
6114                        .iter()
6115                        .map(|a| format!("/{a}"))
6116                        .collect::<Vec<_>>()
6117                        .join(", ")
6118                )
6119            };
6120            InlineListItem {
6121                title,
6122                subtitle: Some(desc.to_string()),
6123                badge: None,
6124                indent: 0,
6125                selection: Some(InlineListSelection::SlashCommand(name.to_string())),
6126                search_value: Some(format!("{name} {desc}")),
6127            }
6128        })
6129        .collect();
6130    items.sort_by(|a, b| a.title.cmp(&b.title));
6131
6132    OverlayState {
6133        title: "Command Palette".into(),
6134        lines: vec!["Type to filter, Enter to select".into()],
6135        items: items
6136            .into_iter()
6137            .map(|item| OverlayListItem {
6138                title: item.title,
6139                subtitle: item.subtitle,
6140                badge: item.badge,
6141                indent: item.indent,
6142                search_value: item.search_value,
6143                selection: item.selection,
6144            })
6145            .collect(),
6146        selected: 0,
6147        search: Some(OverlaySearchState {
6148            label: "search".into(),
6149            placeholder: Some("filter commands\u{2026}".into()),
6150            value: String::new(),
6151        }),
6152        secure_input: None,
6153        ..Default::default()
6154    }
6155}
6156
6157/// Global frame tick counter for animations (incremented per render).
6158static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
6159/// Animation epoch for time-keyed animation frames. Spinner frames key
6160/// on wall-clock time — NOT the draw count — because event bursts
6161/// during streaming drive many draws per interval and made count-keyed
6162/// spinners visibly race.
6163static ANIMATION_T0: std::sync::LazyLock<std::time::Instant> =
6164    std::sync::LazyLock::new(std::time::Instant::now);
6165
6166/// The animation frame index for a spinner with the given frame period
6167/// (milliseconds). Deterministic in wall-clock time: rapid
6168/// back-to-back draws within one period show the same frame.
6169fn animation_frame(period_ms: u64) -> u64 {
6170    ANIMATION_T0.elapsed().as_millis() as u64 / period_ms.max(1)
6171}
6172/// Tracks whether the terminal title currently shows a running state.
6173static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
6174/// ASCII spinner frames for the tab title. They remain readable in every font.
6175const TITLE_SPINNER: &[&str] = &["-", "\\", "|", "/"];
6176
6177/// Braille spinner frames for the in-TUI run indicator (the row above
6178/// the composer). Braille is plain Unicode (U+2800 block) — no font or
6179/// emoji caveats — and animates on the frame tick.
6180const RUN_SPINNER: &[&str] = &[
6181    "\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
6182    "\u{2807}", "\u{280F}",
6183];
6184
6185/// `59s` under a minute, `2m 05s` beyond it — for the run indicator's
6186/// elapsed readout.
6187fn format_elapsed_secs(secs: u64) -> String {
6188    if secs < 60 {
6189        format!("{secs}s")
6190    } else {
6191        format!("{}m {:02}s", secs / 60, secs % 60)
6192    }
6193}
6194
6195/// Linear-interpolate between two RGB colors. `ratio` 0 = base, 1 = target.
6196fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
6197    match (base, target) {
6198        (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
6199            let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
6200            let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
6201            let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
6202            Color::Rgb(r, g, b)
6203        }
6204        _ => base,
6205    }
6206}
6207
6208/// Accent rail color for a transcript line kind.
6209fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
6210    match kind {
6211        InlineMessageKind::User => color_from_anstyle(styles.user.get_fg_color()),
6212        InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
6213        InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
6214        InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
6215        InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
6216        InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
6217        InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
6218        InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
6219    }
6220}
6221
6222/// Compose one frame using the agent view layout (grok-build-style):
6223/// Scrollback (dominant, top) → Prompt → ShortcutsBar (bottom).
6224/// Chrome geometry and the shortcuts bar are rendered by
6225/// [`render_chrome`](crate::tui_vt::frame_layout::render_chrome); the
6226/// transcript and composer are placed into the returned layout rects.
6227fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
6228    let area = frame.area();
6229    // Paint the theme background across the whole frame first. Without this
6230    // every span renders against the host terminal's transparent default bg,
6231    // so fg-only text can read as invisible when it clashes with that default
6232    // — the user only saw it after drag-selecting (which inverts colors).
6233    let bg = active_styles().background;
6234    frame
6235        .buffer_mut()
6236        .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
6237    let layout = super::frame_layout::compute_chrome(area);
6238    let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6239    // Update terminal tab title: spinner while running, plain when idle.
6240    {
6241        let running = state.active_run.is_some() || state.reasoning_stage.is_some();
6242        let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
6243        if running || was_running {
6244            let title = if running {
6245                let spin = TITLE_SPINNER[(animation_frame(120) as usize) % TITLE_SPINNER.len()];
6246                let model = state
6247                    .header_context
6248                    .editor_context
6249                    .as_deref()
6250                    .unwrap_or("oxicode");
6251                format!("{spin} oxicode \u{2014} {model}")
6252            } else {
6253                "oxicode".to_string()
6254            };
6255            use std::io::Write;
6256            let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
6257            let _ = std::io::stderr().flush();
6258        }
6259    }
6260    // `/issue` panel — full-screen modal overlay (design §5). Short-circuits
6261    // the chat render entirely: nothing underneath is drawn while open.
6262    // Higher-priority modals (overlay, confirmation) still stack on top so
6263    // e.g. Ctrl+C-armed quit confirmation remains visible while its gate
6264    // owns input.
6265    if let Some(panel) = &state.issues_panel {
6266        crate::tui_vt::issues_panel::render_issues_panel(frame, area, panel);
6267        if let Some(overlay) = &state.overlay {
6268            render_overlay(frame, area, overlay);
6269        }
6270        if let Some(confirm) = &state.confirmation {
6271            render_confirmation(frame, area, confirm);
6272        }
6273        return;
6274    }
6275    // Git TUI overlay REPLACES the scrollback + composer region when
6276    // open. Skip both so the transcript doesn't bleed through, then
6277    // draw the overlay across the full frame area.
6278    if let Some(git) = &state.git_tui {
6279        crate::tui_vt::git_tui::render::render_overlay_lines(frame, area, git);
6280    } else {
6281        render_transcript(frame, layout.scrollback, state);
6282        let mut pinned_area = layout.scrollback;
6283        if !state.queued_inputs.is_empty() {
6284            let used = render_queue_pane(frame, pinned_area, state);
6285            pinned_area.y = pinned_area.y.saturating_add(used);
6286            pinned_area.height = pinned_area.height.saturating_sub(used);
6287        }
6288        if !state.todo_phases.is_empty() {
6289            if frame.area().height < TODO_COMPACT_ROWS_THRESHOLD {
6290                let line = render_todo_compact_line(&state.todo_phases);
6291                frame.render_widget(
6292                    Paragraph::new(vec![line]),
6293                    Rect {
6294                        height: 1,
6295                        ..pinned_area
6296                    },
6297                );
6298            } else {
6299                let is_matched = build_matched_closure(state.hub.as_ref());
6300                render_todo_pane(
6301                    frame,
6302                    pinned_area,
6303                    &state.todo_phases,
6304                    state.todo_expanded,
6305                    is_matched,
6306                );
6307            }
6308        }
6309        // The row above the composer has one owner per frame. A live run
6310        // (tracker or stage) takes it — the tracker spans turn boundaries.
6311        if state.active_run.is_some() || state.reasoning_stage.is_some() {
6312            render_reasoning_indicator(frame, layout.prompt, state);
6313        } else if state.pending_quit {
6314            render_pending_quit_hint(frame, layout.prompt);
6315        } else if !state.follow_ups.is_empty() {
6316            render_follow_ups(frame, layout.prompt, &state.follow_ups);
6317        } else {
6318            // Ephemeral tip banner above the composer (auto-dismissed by tick TTL).
6319            let occluded = state.overlay.is_some() || state.confirmation.is_some();
6320            if let Some(tip) = &state.tip
6321                && tip_is_visible(tip, tick)
6322                && !(tip.ambient && occluded)
6323            {
6324                render_tip(frame, layout.prompt, &tip.text);
6325            }
6326        }
6327        render_composer(frame, layout.prompt, state);
6328        if state.slash_popup.open {
6329            render_slash_popup(frame, layout.prompt, state);
6330        }
6331        if state.file_search.is_some() {
6332            render_file_search_dropdown(frame, layout.prompt, state);
6333        }
6334    }
6335    if state.agent_hub_open {
6336        render_agent_hub(frame, area, state);
6337    }
6338    if let Some(overlay) = &state.overlay {
6339        render_overlay(frame, area, overlay);
6340    }
6341    if let Some(confirm) = &state.confirmation {
6342        render_confirmation(frame, area, confirm);
6343    }
6344    // (Git TUI overlay is drawn inside the `if let Some(git)` arm
6345    // above; nothing more to paint here.)
6346}
6347/// Render the y/n/x confirmation modal centered on top of everything else.
6348fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
6349    let styles = active_styles();
6350    let accent = color_from_anstyle(styles.error.get_fg_color());
6351    let inner_w = confirm
6352        .title
6353        .chars()
6354        .count()
6355        .max(confirm.message.chars().count())
6356        .max(36) as u16;
6357    let width = inner_w + 4;
6358    let height = 5;
6359    let x = area.x + area.width.saturating_sub(width) / 2;
6360    let y = area.y + area.height.saturating_sub(height) / 2;
6361    let popup_area = Rect {
6362        x,
6363        y,
6364        width,
6365        height,
6366    };
6367    let block = Block::default()
6368        .borders(Borders::ALL)
6369        .border_type(BorderType::Rounded)
6370        .title(Span::styled(
6371            format!(" {} ", confirm.title),
6372            Style::default().fg(accent).bold(),
6373        ))
6374        .border_style(Style::default().fg(accent));
6375    let msg = Line::styled(
6376        confirm.message.clone(),
6377        Style::default().fg(color_from_anstyle(Some(styles.foreground))),
6378    );
6379    frame.render_widget(
6380        Paragraph::new(vec![Line::default(), msg]).block(block),
6381        popup_area,
6382    );
6383}
6384
6385/// Render the Agent Hub overlay — a centered panel listing every registered
6386/// agent (kind, name, status). Populated from `RenderState::hub_entries`,
6387/// snapshotted when `/agents` fired. `q` (input thread Char arm) closes it.
6388fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6389    let rows = state.hub_entries.len() as u16;
6390    let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
6391    let width = area.width.clamp(30, 80);
6392    let rect = Rect {
6393        x: area.x + (area.width.saturating_sub(width)) / 2,
6394        y: area.y + (area.height.saturating_sub(height)) / 2,
6395        width,
6396        height,
6397    };
6398    frame.render_widget(Clear, rect);
6399
6400    let title = Line::from(Span::styled(
6401        " Agent Hub ",
6402        Style::default().add_modifier(Modifier::BOLD),
6403    ));
6404    let block = Block::default().borders(Borders::ALL).title(title);
6405
6406    let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
6407        vec![ListItem::new(Line::from(Span::raw(
6408            "No agents registered.",
6409        )))]
6410    } else {
6411        state
6412            .hub_entries
6413            .iter()
6414            .map(|(id, e)| {
6415                ListItem::new(Line::from(vec![
6416                    Span::raw(format!("{:?} ", e.kind)),
6417                    Span::raw(e.display_name.clone()),
6418                    Span::raw(format!("  — {:?} ({})", e.status, id)),
6419                ]))
6420            })
6421            .collect()
6422    };
6423    frame.render_widget(List::new(items).block(block), rect);
6424}
6425
6426/// Render an overlay (Modal / List) as a centered, bordered panel. Modals
6427/// show only their title + descriptive lines; lists also render a search bar
6428/// (when configured) and a scrollable item list with the selected item
6429/// marked by a plain-text cursor.
6430fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
6431    let styles = active_styles();
6432    // Secure-input overlays draw a compact frame: title + lines + a single
6433    // masked input box. List overlays take the longer path below.
6434    if let Some(secure) = &overlay.secure_input {
6435        // Reserve the line just below `overlay.lines` for the input box.
6436        let lines_count = overlay.lines.len();
6437        let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); // input row + borders
6438        let height = desired_h.min(area.height.saturating_sub(2));
6439        let width = area.width.clamp(30, 80);
6440        let rect = Rect {
6441            x: area.x + (area.width.saturating_sub(width)) / 2,
6442            y: area.y + (area.height.saturating_sub(height)) / 2,
6443            width,
6444            height,
6445        };
6446        frame.render_widget(Clear, rect);
6447
6448        let title = Line::from(Span::styled(
6449            format!(" {} ", overlay.title),
6450            Style::default()
6451                .fg(color_from_anstyle(styles.primary.get_fg_color()))
6452                .add_modifier(Modifier::BOLD),
6453        ));
6454        let block = Block::default()
6455            .borders(Borders::ALL)
6456            .border_type(BorderType::Plain)
6457            .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6458            .title(title);
6459        let inner = block.inner(rect);
6460        frame.render_widget(&block, rect);
6461
6462        let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6463
6464        let mut row = inner.top();
6465        for line_text in &overlay.lines {
6466            let row_area = Rect {
6467                x: inner.left(),
6468                y: row,
6469                width: inner.width,
6470                height: 1,
6471            };
6472            let line = Line::from(Span::styled(
6473                line_text.clone(),
6474                Style::default().fg(secondary),
6475            ));
6476            frame.render_widget(Paragraph::new(line), row_area);
6477            row = row.saturating_add(1);
6478        }
6479
6480        // Secure input box — paint either the placeholder (empty buffer) or
6481        // a `TextArea` whose whole buffer is a single masked `TextElement`.
6482        // The real value lives only in `secure.editor.text()`; the element
6483        // `display` (one asterisk per char when `mask_input` is on) is what
6484        // actually reaches the terminal — the editor's text never enters a
6485        // rendered `Line` when `mask_input` is true.
6486        let label = &secure.config.label;
6487        let label_prefix = format!("{label}: ");
6488        let prefix_columns = UnicodeWidthStr::width(label_prefix.as_str()) as u16;
6489        let prefix_area = Rect {
6490            x: inner.left(),
6491            y: row,
6492            width: prefix_columns.min(inner.width),
6493            height: 1,
6494        };
6495        frame.render_widget(
6496            Paragraph::new(Line::from(Span::styled(
6497                label_prefix.clone(),
6498                Style::default().fg(secondary),
6499            ))),
6500            prefix_area,
6501        );
6502        let textarea_area = Rect {
6503            x: inner.left().saturating_add(prefix_columns),
6504            y: row,
6505            width: inner.width.saturating_sub(prefix_columns),
6506            height: 1,
6507        };
6508        let inner_left = textarea_area.left();
6509        let inner_right = textarea_area.right().saturating_sub(1);
6510
6511        let value = secure.editor.text();
6512        if value.is_empty() {
6513            // Empty buffer: dim placeholder + caret at column 0 of the
6514            // body area (matches the pre-port look).
6515            if let Some(placeholder) = secure.config.placeholder.as_deref() {
6516                frame.render_widget(
6517                    Paragraph::new(Line::from(Span::styled(
6518                        placeholder.to_string(),
6519                        Style::default().fg(secondary).dim(),
6520                    ))),
6521                    textarea_area,
6522                );
6523            }
6524            if textarea_area.width > 0 {
6525                frame.set_cursor_position(Position::new(inner_left, row));
6526            }
6527            return;
6528        }
6529
6530        // Build a fresh masked TextArea per render. Re-using the editor's
6531        // exact text avoids per-frame bookkeeping of element ids.
6532        let display_line: Line<'static> = if secure.config.mask_input {
6533            Line::from("*".repeat(value.chars().count()))
6534        } else {
6535            // Unmasked mode: the user has opted in to seeing the secret,
6536            // so the element's `display` is the value itself. The element
6537            // still gives atomic cursor navigation, and the editor still
6538            // owns the source of truth.
6539            Line::from(value.to_string())
6540        };
6541        let mut ta = TextArea::new();
6542        ta.set_text(value);
6543        ta.replace_range_with_element(
6544            0..value.len(),
6545            value,
6546            MASKED_ELEMENT_KIND,
6547            Some(display_line),
6548        );
6549        // `set_cursor` snaps to the nearest element boundary. Since the
6550        // masked element covers the whole buffer, the rendered caret lands
6551        // at 0 or `value.len()` — the two atomic positions for the field.
6552        ta.set_cursor(secure.editor.cursor_byte());
6553        frame.render_widget_ref(&ta, textarea_area);
6554        // `cursor_pos_with_state` returns ABSOLUTE coordinates (it already
6555        // adds `textarea_area.x`/`.y`). Do NOT re-add the area origin.
6556        if let Some((cx, cy)) = ta.cursor_pos_with_state(textarea_area, TextAreaState::default()) {
6557            let caret_x = cx.min(inner_right);
6558            frame.set_cursor_position(Position::new(caret_x, cy));
6559        }
6560        return;
6561    }
6562    // Keep space for the title, contextual content, and a stable key-help
6563    // footer. The item viewport itself scrolls around the active item.
6564    let visible_max = (area.height as usize).saturating_sub(7).max(3);
6565
6566    // Filter items by the search value when search is enabled.
6567    let filtered: Vec<usize> = match &overlay.search {
6568        Some(search) if !search.value.is_empty() => {
6569            let needle = search.value.to_lowercase();
6570            overlay
6571                .items
6572                .iter()
6573                .enumerate()
6574                .filter_map(|(idx, item)| {
6575                    let title_match = item.title.to_lowercase().contains(&needle);
6576                    let sv_match = item
6577                        .search_value
6578                        .as_deref()
6579                        .map(|v| v.to_lowercase().contains(&needle))
6580                        .unwrap_or(false);
6581                    if title_match || sv_match {
6582                        Some(idx)
6583                    } else {
6584                        None
6585                    }
6586                })
6587                .collect()
6588        }
6589        _ => (0..overlay.items.len()).collect(),
6590    };
6591
6592    let has_search = overlay.search.is_some();
6593    let has_tabs = overlay.tabs.len() > 1;
6594    let lines_count = overlay.lines.len();
6595    let items_count = filtered.len().min(visible_max);
6596    let height_inner =
6597        (lines_count + items_count + usize::from(has_search) + usize::from(has_tabs)) as u16;
6598    let desired_h = height_inner.saturating_add(3); // borders + key-help footer
6599    let height = desired_h.min(area.height.saturating_sub(2));
6600    let width = area.width.clamp(30, 80);
6601    let rect = Rect {
6602        x: area.x + (area.width.saturating_sub(width)) / 2,
6603        y: area.y + (area.height.saturating_sub(height)) / 2,
6604        width,
6605        height,
6606    };
6607    frame.render_widget(Clear, rect);
6608
6609    let title = Line::from(Span::styled(
6610        format!(" {} ", overlay.title),
6611        Style::default()
6612            .fg(color_from_anstyle(styles.primary.get_fg_color()))
6613            .add_modifier(Modifier::BOLD),
6614    ));
6615    let block = Block::default()
6616        .borders(Borders::ALL)
6617        .border_type(BorderType::Plain)
6618        .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6619        .title(title);
6620    let inner = block.inner(rect);
6621    frame.render_widget(&block, rect);
6622
6623    let primary = color_from_anstyle(styles.primary.get_fg_color());
6624    let fg = color_from_anstyle(Some(styles.foreground));
6625    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6626
6627    // Compute where the selected item is in the filtered list.
6628    let selected_filtered_pos = filtered
6629        .iter()
6630        .position(|&idx| idx == overlay.selected)
6631        .unwrap_or(0);
6632
6633    let mut row = inner.top();
6634
6635    // Tab bar (settings panel): one line of tab names, the active tab
6636    // bold+accent; ←/→ switch tabs.
6637    if has_tabs {
6638        let mut spans: Vec<Span> = Vec::new();
6639        for (i, name) in overlay.tabs.iter().enumerate() {
6640            if i > 0 {
6641                spans.push(Span::raw("  "));
6642            }
6643            let style = if i == overlay.active_tab {
6644                Style::default().fg(primary).add_modifier(Modifier::BOLD)
6645            } else {
6646                Style::default().fg(secondary).add_modifier(Modifier::DIM)
6647            };
6648            spans.push(Span::styled(name.clone(), style));
6649        }
6650        let row_area = Rect {
6651            x: inner.left(),
6652            y: row,
6653            width: inner.width,
6654            height: 1,
6655        };
6656        frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
6657        row = row.saturating_add(1);
6658    }
6659    // Search bar (if present).
6660    if let Some(search) = &overlay.search {
6661        let prompt = format!("{}: {}", search.label, search.value);
6662        let line = Line::from(vec![
6663            Span::styled(
6664                format!("{}: ", search.label),
6665                Style::default().fg(secondary),
6666            ),
6667            Span::styled(
6668                if search.value.is_empty() {
6669                    search
6670                        .placeholder
6671                        .clone()
6672                        .unwrap_or_else(|| "type to filter\u{2026}".to_string())
6673                } else {
6674                    search.value.clone()
6675                },
6676                if search.value.is_empty() {
6677                    Style::default().fg(secondary).add_modifier(Modifier::DIM)
6678                } else {
6679                    Style::default().fg(fg)
6680                },
6681            ),
6682        ]);
6683        let _ = prompt; // suppress unused warning
6684        let row_area = Rect {
6685            x: inner.left(),
6686            y: row,
6687            width: inner.width,
6688            height: 1,
6689        };
6690        frame.render_widget(Paragraph::new(line), row_area);
6691        row = row.saturating_add(1);
6692    }
6693
6694    // Descriptive lines.
6695    for line_text in &overlay.lines {
6696        let row_area = Rect {
6697            x: inner.left(),
6698            y: row,
6699            width: inner.width,
6700            height: 1,
6701        };
6702        let line = Line::from(Span::styled(
6703            line_text.clone(),
6704            Style::default().fg(secondary),
6705        ));
6706        frame.render_widget(Paragraph::new(line), row_area);
6707        row = row.saturating_add(1);
6708    }
6709
6710    // Sidebar split (settings panel): with >= 2 sections and enough
6711    // width, the left column lists section names (active bold+accent)
6712    // and the item list moves to the right column with rows outside the
6713    // active section dimmed. Falls back to the flat list while a filter
6714    // is active (results cross sections) or when narrow.
6715    let searching = overlay.search.as_ref().is_some_and(|s| !s.value.is_empty());
6716    let use_sidebar = overlay.sections.len() >= 2 && inner.width >= 60 && !searching;
6717    let sidebar_w = if use_sidebar {
6718        let longest = overlay
6719            .sections
6720            .iter()
6721            .map(|s| s.chars().count())
6722            .max()
6723            .unwrap_or(0);
6724        (22usize.min(longest) + 4) as u16
6725    } else {
6726        0
6727    };
6728    // The active section tracks the selected item's group, not a stored
6729    // index — selection moves across sections via Up/Down.
6730    let active_section = if use_sidebar {
6731        item_section_idx(overlay, overlay.selected).unwrap_or(overlay.active_section)
6732    } else {
6733        overlay.active_section
6734    };
6735    let list_x = inner.left() + sidebar_w;
6736    let list_w = inner.width.saturating_sub(sidebar_w);
6737    if use_sidebar {
6738        let mut srow = row;
6739        for (i, name) in overlay.sections.iter().enumerate() {
6740            let style = if i == active_section {
6741                Style::default().fg(primary).add_modifier(Modifier::BOLD)
6742            } else {
6743                Style::default().fg(secondary)
6744            };
6745            let marker = if i == active_section { "> " } else { "  " };
6746            let row_area = Rect {
6747                x: inner.left(),
6748                y: srow,
6749                width: sidebar_w,
6750                height: 1,
6751            };
6752            frame.render_widget(
6753                Paragraph::new(Line::from(vec![
6754                    Span::styled(marker, style),
6755                    Span::styled(name.clone(), style),
6756                ])),
6757                row_area,
6758            );
6759            srow = srow.saturating_add(1);
6760        }
6761    }
6762
6763    // Items.
6764    if filtered.is_empty() {
6765        // The key-capture prompt is items-free by design — the prompt
6766        // line above IS the UI; a "(no items)" placeholder would be
6767        // noise.
6768        if overlay.key_capture.is_some() {
6769            return;
6770        }
6771        let row_area = Rect {
6772            x: inner.left(),
6773            y: row,
6774            width: inner.width,
6775            height: 1,
6776        };
6777        let empty_text = if overlay.search.is_some() {
6778            "  (no matches)"
6779        } else {
6780            "  (no items)"
6781        };
6782        frame.render_widget(
6783            Paragraph::new(Line::from(Span::styled(
6784                empty_text,
6785                Style::default().fg(secondary).add_modifier(Modifier::DIM),
6786            ))),
6787            row_area,
6788        );
6789    } else {
6790        let first_visible = selected_filtered_pos
6791            .saturating_sub(visible_max / 2)
6792            .min(filtered.len().saturating_sub(visible_max));
6793        for &item_idx in filtered.iter().skip(first_visible).take(visible_max) {
6794            let item = &overlay.items[item_idx];
6795            let is_selected = item_idx == overlay.selected;
6796            let marker = if is_selected { "> " } else { "  " };
6797            let indent = "  ".repeat(item.indent as usize);
6798            let mut item_style = if is_selected {
6799                Style::default().fg(primary).add_modifier(Modifier::BOLD)
6800            } else {
6801                Style::default().fg(fg)
6802            };
6803            // Rows outside the active section recede while the sidebar
6804            // is up.
6805            if use_sidebar
6806                && item_section_idx(overlay, item_idx).is_some_and(|sec| sec != active_section)
6807            {
6808                item_style = item_style.add_modifier(Modifier::DIM);
6809            }
6810            let mut spans = vec![
6811                Span::styled(marker, item_style),
6812                Span::styled(indent, item_style),
6813                Span::styled(item.title.clone(), item_style),
6814            ];
6815            if let Some(badge) = &item.badge {
6816                spans.push(Span::raw("  "));
6817                spans.push(Span::styled(
6818                    badge.clone(),
6819                    Style::default().fg(secondary).add_modifier(Modifier::DIM),
6820                ));
6821            }
6822            if let Some(subtitle) = &item.subtitle {
6823                spans.push(Span::raw("  "));
6824                spans.push(Span::styled(
6825                    subtitle.clone(),
6826                    Style::default().fg(secondary),
6827                ));
6828            }
6829            let line = Line::from(spans);
6830            let row_area = Rect {
6831                x: list_x,
6832                y: row,
6833                width: list_w,
6834                height: 1,
6835            };
6836            frame.render_widget(Paragraph::new(line), row_area);
6837            row = row.saturating_add(1);
6838        }
6839    }
6840
6841    // A panel should always explain how to leave it and how to commit a
6842    // choice. This avoids hiding essential controls in a separate help view.
6843    if row < inner.bottom() {
6844        let hint = if overlay.items.iter().any(|item| item.selection.is_some()) {
6845            if has_tabs {
6846                "Enter select | Up/Down move | ←/→ tabs | Esc close"
6847            } else {
6848                "Enter select | Up/Down move | Esc close"
6849            }
6850        } else {
6851            "Esc close"
6852        };
6853        frame.render_widget(
6854            Paragraph::new(Line::from(Span::styled(
6855                hint,
6856                Style::default().fg(secondary).add_modifier(Modifier::DIM),
6857            ))),
6858            Rect {
6859                x: inner.left(),
6860                y: inner.bottom().saturating_sub(1),
6861                width: inner.width,
6862                height: 1,
6863            },
6864        );
6865    }
6866}
6867
6868fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6869    if state.transcript.is_empty() {
6870        render_welcome(frame, area, state);
6871        return;
6872    }
6873    let styles = active_styles();
6874    let bg_color = color_from_anstyle(Some(styles.background));
6875
6876    // Plain transcript surface (omp-style): no rail column, no speaker
6877    // chrome, no in-app scrollbar — the host terminal's native
6878    // scrollback owns history now. Content spans the full area.
6879    let content_area = Rect {
6880        x: area.x,
6881        y: area.y,
6882        width: area.width,
6883        height: area.height,
6884    };
6885
6886    let display =
6887        build_transcript_display(state, &styles, state.committed_entries, content_area.width);
6888
6889    // Resolve scroll offset into the display list.
6890    let total = display.len();
6891    let raw_start = if state.scroll_offset == usize::MAX {
6892        total.saturating_sub(content_area.height as usize)
6893    } else {
6894        display
6895            .iter()
6896            .position(|d| d.source_index >= state.scroll_offset)
6897            .unwrap_or(total.saturating_sub(1))
6898    };
6899    let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
6900
6901    // Sticky header (grok-build parity): when the viewport top sits inside a
6902    // block's body (not on its head), pin the block's first line at the top
6903    // so the user can tell which block they are scrolling through.
6904    let sticky_first: Option<usize> = display.get(start).and_then(|d| {
6905        let bid = state.transcript.get(d.source_index)?.block_id;
6906        let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
6907        (first_idx != d.source_index).then_some(first_idx)
6908    });
6909    let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
6910    let body_top = content_area.top() + sticky_h;
6911
6912    // Push/fade (grok-build iOS-style 1D): detect the next block boundary
6913    // within the viewport. As it approaches the sticky row, fade the current
6914    // sticky header toward the background — a smooth handoff to the next
6915    // block's header. FADE_ROWS controls the transition width.
6916    const FADE_ROWS: usize = 5;
6917    let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
6918        let sticky_bid = state.transcript[sidx].block_id;
6919        // Walk display from `start` to find the first visual row belonging to
6920        // a different block.
6921        let next_offset = display.iter().skip(start).position(|d| {
6922            state
6923                .transcript
6924                .get(d.source_index)
6925                .map(|l| l.block_id != sticky_bid)
6926                .unwrap_or(false)
6927        });
6928        match next_offset {
6929            Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
6930            _ => 1.0,
6931        }
6932    } else {
6933        1.0
6934    };
6935
6936    // Sticky header row: head line + faint bg highlight, no rail. Opacity
6937    // fades as the next block pushes in.
6938    if let Some(sidx) = sticky_first {
6939        let tl = &state.transcript[sidx];
6940        let accent_base = accent_color_for_kind(tl.kind, &styles);
6941        let bg_blend = 0.1 * sticky_opacity;
6942        let line =
6943            transcript_line_marked(tl, &styles, false, false, false, true, content_area.width);
6944        let row = Rect {
6945            x: content_area.x,
6946            y: content_area.top(),
6947            width: content_area.width,
6948            height: 1,
6949        };
6950        if bg_blend > 0.01 {
6951            frame.buffer_mut().set_style(
6952                row,
6953                Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
6954            );
6955        }
6956        frame.render_widget(Paragraph::new(line), row);
6957    }
6958    // Pressure-driven allocation ladder (peer parity with omp): when
6959    // there are more visible items than rows in the live region, fold
6960    // older blocks to a glyph row, then a folded card, and finally
6961    // hide them with a banner. The ladder is pure (`allocate_rows`)
6962    // and resolved here once per frame; the render loop below applies
6963    // it per item.
6964    let live_budget = content_area.height.saturating_sub(sticky_h) as usize;
6965    let (alloc_by_block, hidden_count, natural_by_block) =
6966        compute_block_allocations(state, state.committed_entries, live_budget);
6967    // the live region visibly breathes while tools are running.
6968    let pulse = animation_frame(1000).is_multiple_of(2);
6969    // for `… N earlier blocks hidden` whenever any block is hidden.
6970    let banner_row_used = hidden_count > 0;
6971    let banner_y = content_area.bottom().saturating_sub(1);
6972    // Render top-down, wrapping each line into multiple visual rows.
6973    let mut y = body_top;
6974    let width = content_area.width.max(1) as usize;
6975    // Inline image previews: resolve each pending image's transcript row
6976    // to its block and pre-compute the block's visual height (same wrap
6977    // math the commit path uses) so the render loop can anchor a
6978    // placement at the block's top row, sized to the tool box.
6979    // (block_id, image id, block height, fallback-row index)
6980    let image_block_plans: Vec<(usize, u32, u16, usize)> = state
6981        .image_previews
6982        .pending()
6983        .iter()
6984        .filter_map(|p| {
6985            // Resolve the fallback row by its embedded label — the row
6986            // only exists after the append command applied.
6987            let row_index = state
6988                .transcript
6989                .iter()
6990                .position(|l| l.segments.iter().any(|s| s.text.contains(&p.label)))?;
6991            let bid = state.transcript[row_index].block_id;
6992            let mut block_rows: u16 = 0;
6993            for d in &display {
6994                let Some(l) = state.transcript.get(d.source_index) else {
6995                    continue;
6996                };
6997                if l.block_id != bid {
6998                    continue;
6999                }
7000                block_rows = block_rows.saturating_add(match &d.line {
7001                    None => 1,
7002                    Some(line) => {
7003                        let lw = line.width();
7004                        if lw == 0 {
7005                            1
7006                        } else {
7007                            lw.div_ceil(width).max(1) as u16
7008                        }
7009                    }
7010                });
7011            }
7012            (block_rows > 0).then_some((bid, p.id, block_rows, row_index))
7013        })
7014        .collect();
7015    let mut current_block: Option<usize> = None;
7016    let mut skipped_blocks: std::collections::HashSet<usize> = std::collections::HashSet::new();
7017    for d in display.into_iter().skip(start) {
7018        if y >= content_area.bottom() {
7019            break;
7020        }
7021        // Banner reservation: never paint over the reserved banner
7022        // row at the bottom of the live region.
7023        if banner_row_used && y >= banner_y {
7024            break;
7025        }
7026        let d_bid = state.transcript.get(d.source_index).map(|l| l.block_id);
7027        let Some(d_bid) = d_bid else {
7028            continue;
7029        };
7030        // Block transition: pick a ladder policy for the new block.
7031        if current_block != Some(d_bid) {
7032            // Inline image preview: this block is a pending image's tool
7033            // box — record where its top row landed so the post-draw
7034            // step can place the transmitted pixels here. Placement is
7035            // clamped to the visible window.
7036            if y < content_area.bottom()
7037                && let Some((_, pid, prows, row_index)) =
7038                    image_block_plans.iter().find(|(b, _, _, _)| *b == d_bid)
7039            {
7040                let visible_rows = content_area.bottom().saturating_sub(y).max(1);
7041                state.image_previews.record_anchor(
7042                    *pid,
7043                    content_area.x,
7044                    y,
7045                    (*prows).min(visible_rows),
7046                    *row_index,
7047                );
7048            }
7049            current_block = Some(d_bid);
7050            let alloc = alloc_by_block
7051                .get(&d_bid)
7052                .copied()
7053                .unwrap_or(BlockAlloc { rows: 0 });
7054            // The ladder only intervenes when the block is being
7055            // squeezed (alloc.rows < natural). When alloc.rows >=
7056            // natural (roomy), the natural rendering already fits
7057            // — leave the existing wrap logic alone so explicit
7058            // newlines and word-wrap behave the way they always
7059            // did.
7060            let natural = natural_by_block.get(&d_bid).copied().unwrap_or(0);
7061            if alloc.rows < natural {
7062                // Pressure / emergency: ladder overrides the
7063                // natural rendering. Reserve the first row(s) for
7064                // a glyph / folded card; the rest of the block's
7065                // natural items are skipped entirely.
7066                if skipped_blocks.contains(&d_bid) {
7067                    continue;
7068                }
7069                match alloc.rows {
7070                    0 => {
7071                        skipped_blocks.insert(d_bid);
7072                        continue;
7073                    }
7074                    1 => {
7075                        let activity = block_activity(&state.transcript, d_bid);
7076                        render_glyph_row(frame, content_area, y, &activity, &styles, pulse);
7077                        y += 1;
7078                        skipped_blocks.insert(d_bid);
7079                        continue;
7080                    }
7081                    2 => {
7082                        let activity = block_activity(&state.transcript, d_bid);
7083                        y += render_folded_card(frame, content_area, y, &activity, &styles, pulse);
7084                        skipped_blocks.insert(d_bid);
7085                        continue;
7086                    }
7087                    _ => {}
7088                }
7089            }
7090            // Roomy (alloc.rows >= natural): fall through and render
7091            // the natural items — every display item for the block
7092            // gets painted (and ratatui handles wrapping / explicit
7093            // newlines as before).
7094        }
7095        let Some(line) = d.line else {
7096            y += 1;
7097            continue;
7098        };
7099        let text_w = line.width();
7100        let wrapped_h = if text_w == 0 {
7101            1
7102        } else {
7103            text_w.div_ceil(width).max(1) as u16
7104        };
7105        let row = Rect {
7106            x: content_area.x,
7107            y,
7108            width: content_area.width,
7109            height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
7110        };
7111        frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
7112        y += wrapped_h;
7113    }
7114
7115    // Banner: paint the `… N earlier blocks hidden` summary in the
7116    // reserved row at the bottom of the live region (if any block
7117    // was hidden).
7118    if banner_row_used {
7119        render_hidden_banner(frame, content_area, banner_y, hidden_count);
7120    }
7121
7122    let _ = (total, sticky_h);
7123}
7124
7125/// One visible row of the transcript: a rendered line (or a turn
7126/// spacer, `line: None`) plus the transcript entry it belongs to.
7127#[derive(Clone)]
7128struct TranscriptDisplayItem<'a> {
7129    source_index: usize,
7130    /// `None` marks a turn spacer: a blank breathing row.
7131    line: Option<Line<'a>>,
7132}
7133
7134/// Short, present-tense descriptor for a block: the first non-empty
7135/// text in its leading line. Falls back to the block's kind label
7136/// (e.g. "tool", "agent") when nothing is derivable. The ladder
7137/// uses this in the glyph row and folded card so a half-shown
7138/// block still tells the user what it was.
7139fn block_activity(transcript: &[TranscriptLine], block_id: usize) -> String {
7140    let mut activity = String::new();
7141    for line in transcript.iter().filter(|l| l.block_id == block_id) {
7142        for seg in &line.segments {
7143            let text = seg.text.trim();
7144            if !text.is_empty() {
7145                activity.push_str(text);
7146                break;
7147            }
7148        }
7149        if !activity.is_empty() {
7150            break;
7151        }
7152    }
7153    if !activity.is_empty() {
7154        return activity;
7155    }
7156    // Fallback: kind label.
7157    transcript
7158        .iter()
7159        .find(|l| l.block_id == block_id)
7160        .map(|l| kind_label(l.kind))
7161        .unwrap_or_else(|| "block".to_string())
7162}
7163
7164/// Lower-case kind label (e.g. "tool", "agent", "user") used as a
7165/// last-resort activity descriptor.
7166fn kind_label(kind: InlineMessageKind) -> String {
7167    match kind {
7168        InlineMessageKind::Agent => "agent".to_string(),
7169        InlineMessageKind::User => "user".to_string(),
7170        InlineMessageKind::Tool => "tool".to_string(),
7171        InlineMessageKind::Error => "error".to_string(),
7172        InlineMessageKind::Warning => "warning".to_string(),
7173        InlineMessageKind::Info => "info".to_string(),
7174        InlineMessageKind::Policy => "policy".to_string(),
7175        InlineMessageKind::Pty => "pty".to_string(),
7176    }
7177}
7178
7179/// Build per-block natural heights from `visible_items`. The natural
7180/// height is the number of items `visible_items` would surface for
7181/// that block (each `Line` or `Gap` counts as one logical row).
7182/// Blocks with no visible items get height 0.
7183fn block_natural_heights(
7184    transcript: &[TranscriptLine],
7185    mode_for: impl Fn(usize) -> BlockDisplayMode,
7186    from_entry: usize,
7187) -> (Vec<usize>, Vec<usize>) {
7188    let mut block_ids: Vec<usize> = Vec::new();
7189    let mut heights: Vec<usize> = Vec::new();
7190    let mut index_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
7191    for item in visible_items(transcript, mode_for) {
7192        let bid = match item {
7193            VisibleItem::Line { source_index, .. } => transcript[source_index].block_id,
7194            VisibleItem::Gap { source_index, .. } => transcript[source_index].block_id,
7195        };
7196        // Frozen rows (already committed to host scrollback) don't
7197        // participate in live-region budgeting — the live region is
7198        // bounded to what's still in the viewport, not what's already
7199        // gone to scrollback.
7200        let live_source = match item {
7201            VisibleItem::Line { source_index, .. } => source_index,
7202            VisibleItem::Gap { source_index, .. } => source_index,
7203        };
7204        if live_source < from_entry {
7205            continue;
7206        }
7207        let idx = *index_of.entry(bid).or_insert_with(|| {
7208            block_ids.push(bid);
7209            heights.push(0);
7210            block_ids.len() - 1
7211        });
7212        heights[idx] += 1;
7213    }
7214    (block_ids, heights)
7215}
7216
7217/// Resolve a per-block allocation map. The ladder applies only to
7218/// blocks without a manual override; manual `Collapsed` /
7219/// `Truncated` modes override the ladder for that block (manual
7220/// wins — the user already chose how this block should fold).
7221///
7222/// Returns `alloc_by_block_id`, `hidden_count`, `natural_by_block_id`.
7223fn compute_block_allocations(
7224    state: &RenderState,
7225    from_entry: usize,
7226    budget: usize,
7227) -> (
7228    std::collections::HashMap<usize, BlockAlloc>,
7229    usize,
7230    std::collections::HashMap<usize, usize>,
7231) {
7232    let (block_ids, heights) =
7233        block_natural_heights(&state.transcript, |bid| state.block_mode(bid), from_entry);
7234    let total_blocks = block_ids.len();
7235    let allocs = allocate_rows(&heights, budget);
7236    let mut by_block: std::collections::HashMap<usize, BlockAlloc> =
7237        std::collections::HashMap::with_capacity(total_blocks);
7238    let mut natural_by_block: std::collections::HashMap<usize, usize> =
7239        std::collections::HashMap::with_capacity(total_blocks);
7240    let mut hidden = 0usize;
7241    for (i, &bid) in block_ids.iter().enumerate() {
7242        // Manual override wins. The ladder ONLY applies to blocks
7243        // without a manual override; a Collapsed block's natural
7244        // item is a single `[+] <line>` (built by
7245        // `transcript_line_marked(folded=true)`), which is exactly
7246        // what we want for the user's "folded" affordance. Truncated
7247        // / Expanded get the ladder output unchanged — those
7248        // policies already control folding, so the ladder has
7249        // nothing to add.
7250        let alloc = match state.block_mode(bid) {
7251            // Collapsed: skip the ladder and route through the
7252            // natural render. Set `rows = natural` so the roomy
7253            // branch in the render loop paints the single `[+]`
7254            // line that `visible_items(Collapsed)` emitted.
7255            BlockDisplayMode::Collapsed => BlockAlloc { rows: heights[i] },
7256            BlockDisplayMode::Truncated | BlockDisplayMode::Expanded => allocs[i],
7257        };
7258        if alloc.rows == 0 {
7259            hidden += 1;
7260        }
7261        natural_by_block.insert(bid, heights[i]);
7262        by_block.insert(bid, alloc);
7263    }
7264    (by_block, hidden, natural_by_block)
7265}
7266
7267/// Truncate `text` so its unicode display width (after the supplied
7268/// prefix) fits inside `width` cells. When the text overflows, an
7269/// ellipsis replaces the trailing chars. Mirrors the rule that
7270/// `clamp_segments_to_width` enforces on rendered rows: never let a
7271/// single row spill past the terminal width.
7272fn clamp_fold_text(text: &str, prefix_w: usize, width: usize, ellipsis: &str) -> String {
7273    let budget = width.saturating_sub(prefix_w);
7274    if budget == 0 || width == 0 {
7275        return String::new();
7276    }
7277    let text_w = text.width();
7278    if text_w <= budget {
7279        return text.to_string();
7280    }
7281    // Leave room for the ellipsis. Walk char-by-char on display
7282    // width; stop one cell before the budget overflows.
7283    let ell_w = ellipsis.width();
7284    let cap = budget.saturating_sub(ell_w);
7285    let mut out = String::new();
7286    let mut used = 0usize;
7287    for ch in text.chars() {
7288        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
7289        if used + w > cap {
7290            break;
7291        }
7292        out.push(ch);
7293        used += w;
7294    }
7295    out.push_str(ellipsis);
7296    out
7297}
7298
7299/// Render a single folded-card row (2-row form: `╭─ <activity>` /
7300/// `╰─ …`) into `frame` at `(x, y)`, honoring `width`. The activity
7301/// string is clamped to fit the live content width — long
7302/// descriptors never break the box-drawing affordance. Returns the
7303/// number of rows consumed (always 2).
7304fn render_folded_card(
7305    frame: &mut Frame<'_>,
7306    area: Rect,
7307    y: u16,
7308    activity: &str,
7309    styles: &ThemeStyles,
7310    pulse: bool,
7311) -> u16 {
7312    let tool_color = styles
7313        .tool
7314        .get_fg_color()
7315        .or_else(|| styles.secondary.get_fg_color())
7316        .or(styles.response.get_fg_color());
7317    let style = Style::default().fg(color_from_anstyle(tool_color));
7318    let pulse_mark = if pulse { " \u{2022}" } else { "" };
7319    // Box head is `╭─ ` (3 cells) plus an optional pulse mark.
7320    // Clamp the activity to the remaining cells so the row never
7321    // wraps onto a third visual row.
7322    let head_prefix_w = "\u{256D}\u{2500} ".width() + pulse_mark.width();
7323    let head_activity = clamp_fold_text(activity, head_prefix_w, area.width as usize, "\u{2026}");
7324    let head = Line::from(vec![Span::styled(
7325        format!("\u{256D}\u{2500} {head_activity}{pulse_mark}"),
7326        style,
7327    )]);
7328    let tail = Line::from(vec![Span::styled("\u{2570}\u{2500} \u{2026}", style)]);
7329    if y < area.bottom() {
7330        let row = Rect {
7331            x: area.x,
7332            y,
7333            width: area.width,
7334            height: 1,
7335        };
7336        frame.render_widget(Paragraph::new(head), row);
7337    }
7338    let y2 = y.saturating_add(1);
7339    if y2 < area.bottom() {
7340        let row = Rect {
7341            x: area.x,
7342            y: y2,
7343            width: area.width,
7344            height: 1,
7345        };
7346        frame.render_widget(Paragraph::new(tail), row);
7347    }
7348    2
7349}
7350
7351/// Render a single glyph row (`▸ <activity>`) into `frame` at `(x,
7352/// y)`, honoring `width`. The activity is clamped to the live
7353/// content width so a long descriptor never wraps. The shared
7354/// wall-clock pulse animates the trailing `•` on a 1-second period
7355/// so the live region breathes.
7356fn render_glyph_row(
7357    frame: &mut Frame<'_>,
7358    area: Rect,
7359    y: u16,
7360    activity: &str,
7361    styles: &ThemeStyles,
7362    pulse: bool,
7363) -> u16 {
7364    let tool_color = styles
7365        .tool
7366        .get_fg_color()
7367        .or_else(|| styles.secondary.get_fg_color())
7368        .or(styles.response.get_fg_color());
7369    let style = Style::default().fg(color_from_anstyle(tool_color));
7370    let pulse_mark = if pulse { " \u{2022}" } else { "" };
7371    // Glyph prefix is `▸ ` (2 cells) plus an optional pulse mark.
7372    let glyph_prefix_w = "\u{25B8} ".width() + pulse_mark.width();
7373    let glyph_activity = clamp_fold_text(activity, glyph_prefix_w, area.width as usize, "\u{2026}");
7374    let line = Line::from(vec![Span::styled(
7375        format!("\u{25B8} {glyph_activity}{pulse_mark}"),
7376        style,
7377    )]);
7378    if y < area.bottom() {
7379        let row = Rect {
7380            x: area.x,
7381            y,
7382            width: area.width,
7383            height: 1,
7384        };
7385        frame.render_widget(Paragraph::new(line), row);
7386    }
7387    1
7388}
7389/// Render a one-row banner `… N earlier blocks hidden` in the dim
7390/// secondary style. The text is clamped to the live content width
7391/// so a very large `N` never overflows the row.
7392fn render_hidden_banner(frame: &mut Frame<'_>, area: Rect, y: u16, hidden: usize) -> u16 {
7393    let style = Style::default().fg(color_from_anstyle(active_styles().secondary.get_fg_color()));
7394    let text = if hidden == 1 {
7395        "\u{2026} 1 earlier block hidden".to_string()
7396    } else {
7397        format!("\u{2026} {hidden} earlier blocks hidden")
7398    };
7399    let clamped = clamp_fold_text(&text, 0, area.width as usize, "\u{2026}");
7400    let line = Line::from(vec![Span::styled(clamped, style)]);
7401    if y < area.bottom() {
7402        let row = Rect {
7403            x: area.x,
7404            y,
7405            width: area.width,
7406            height: 1,
7407        };
7408        frame.render_widget(Paragraph::new(line), row);
7409    }
7410    1
7411}
7412/// and turn rhythm. Entries below `from_entry` (committed to the host
7413/// scrollback) are skipped — they are frozen and must not render in
7414/// the live viewport again.
7415fn build_transcript_display<'a>(
7416    state: &'a RenderState,
7417    styles: &'a ThemeStyles,
7418    from_entry: usize,
7419    width: u16,
7420) -> Vec<TranscriptDisplayItem<'a>> {
7421    let search_set: std::collections::HashSet<usize> = state
7422        .search
7423        .as_ref()
7424        .map(|s| s.matches.iter().copied().collect())
7425        .unwrap_or_default();
7426    let current_match = state
7427        .search
7428        .as_ref()
7429        .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
7430
7431    let mut display = Vec::with_capacity(state.transcript.len());
7432    let dim_style = Style::default()
7433        .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7434        .add_modifier(Modifier::DIM);
7435    let mut prev_block: Option<usize> = None;
7436    let mut prev_kind: Option<InlineMessageKind> = None;
7437    for item in visible_items(&state.transcript, |block_id| state.block_mode(block_id)) {
7438        match item {
7439            VisibleItem::Line {
7440                source_index,
7441                folded,
7442            } => {
7443                if source_index < from_entry {
7444                    continue;
7445                }
7446                let tl = &state.transcript[source_index];
7447                let is_block_start = prev_block != Some(tl.block_id);
7448                // Turn rhythm: breathe before a user block and after one,
7449                // so a request and its response never glue together.
7450                let needs_spacer = is_block_start
7451                    && prev_block.is_some()
7452                    && (tl.kind == InlineMessageKind::User
7453                        || prev_kind == Some(InlineMessageKind::User));
7454                if needs_spacer {
7455                    display.push(TranscriptDisplayItem {
7456                        source_index,
7457                        line: None,
7458                    });
7459                }
7460                let is_match = search_set.contains(&source_index);
7461                let line = transcript_line_marked(
7462                    tl,
7463                    styles,
7464                    folded,
7465                    is_match,
7466                    current_match == Some(source_index),
7467                    is_block_start,
7468                    width,
7469                );
7470                display.push(TranscriptDisplayItem {
7471                    source_index,
7472                    line: Some(line),
7473                });
7474                prev_block = Some(tl.block_id);
7475                prev_kind = Some(tl.kind);
7476            }
7477            VisibleItem::Gap {
7478                source_index,
7479                hidden_lines,
7480            } => {
7481                if source_index < from_entry {
7482                    continue;
7483                }
7484                let gap = Line::styled(format!("  \u{2026} +{hidden_lines} lines"), dim_style);
7485                display.push(TranscriptDisplayItem {
7486                    source_index,
7487                    line: Some(gap),
7488                });
7489            }
7490        }
7491    }
7492    display
7493}
7494/// Decide whether the host scrollback must be wiped and rebuilt after a
7495/// terminal resize. Only width changes invalidate the frozen transcript
7496/// (rows were printed at the original width and cannot re-wrap). A
7497/// height-only resize leaves the printed history intact — the live
7498/// viewport just grows or shrinks beneath it.
7499///
7500/// `prev_w == 0` is the "never measured" sentinel (no frame has been
7501/// drawn at a known width): there is no stale-width scrollback to
7502/// invalidate, so the answer is always false. Without this, the
7503/// 80-column `RenderState::default()` would fire CSI 3J on the first
7504/// draw of any wider terminal and wipe the user's pre-TUI shell
7505/// scrollback on every launch (final-review finding 1).
7506pub(crate) fn should_rebuild_scrollback(
7507    prev_w: u16,
7508    new_w: u16,
7509    _prev_h: u16,
7510    _new_h: u16,
7511) -> bool {
7512    prev_w != 0 && prev_w != new_w
7513}
7514
7515/// Force-flush boundary — commit the entire finalized prefix regardless
7516/// of viewport fit. Used at exit to land every committable row into the
7517/// host scrollback before raw mode is dropped. Returns the number of
7518/// display rows to commit (= `display_len`). `display_len` itself comes
7519/// from the caller (the same `build_transcript_display` output the live
7520/// commit plan uses) so the boundary stays in lockstep with what the
7521/// user has actually been seeing on screen.
7522pub(crate) fn plan_full_flush(display_len: usize) -> usize {
7523    display_len
7524}
7525
7526/// A planned flush of finalized rows into the host terminal's real
7527/// scrollback.
7528struct ScrollbackCommit {
7529    /// Display rows to print above the viewport.
7530    rows: u16,
7531    /// Display items [0, boundary_item) are the committed chunk.
7532    boundary_item: usize,
7533    /// New `committed_entries`: transcript index of the first live entry.
7534    new_committed_entries: usize,
7535}
7536/// Decide which leading display rows to shed into the host scrollback so
7537/// the live region keeps only `keep_rows` (the viewport). The boundary is
7538/// **block-atomic** (never splits a block) and never touches the anchored
7539/// streaming block or anything below it — those lines are still being
7540/// rewritten by `ReplaceLast`.
7541fn scrollback_commit_plan(
7542    display: &[TranscriptDisplayItem<'_>],
7543    transcript: &[TranscriptLine],
7544    width: usize,
7545    keep_rows: usize,
7546    anchor_entry: Option<usize>,
7547) -> Option<ScrollbackCommit> {
7548    let width = width.max(1);
7549    // Cumulative display rows through each item (spacers cost 1 row;
7550    // lines wrap to ceil(width / content width)).
7551    let mut ends: Vec<usize> = Vec::with_capacity(display.len());
7552    let mut y = 0usize;
7553    for d in display {
7554        let h = match &d.line {
7555            None => 1,
7556            Some(line) => {
7557                let w = line.width();
7558                if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7559            }
7560        };
7561        y += h;
7562        ends.push(y);
7563    }
7564    let total_rows = y;
7565    if total_rows <= keep_rows || display.is_empty() {
7566        return None;
7567    }
7568    let limit = total_rows - keep_rows;
7569
7570    // Everything whose last row ends at/below the keep window stays live.
7571    let mut boundary_item = ends.iter().rposition(|&e| e <= limit)? + 1;
7572
7573    // The anchored streaming block (and everything after it) never
7574    // commits: its lines are still being rewritten in place.
7575    if let Some(anchor) = anchor_entry
7576        && let Some(anchor_item) = display.iter().position(|d| d.source_index >= anchor)
7577    {
7578        boundary_item = boundary_item.min(anchor_item);
7579    }
7580
7581    // Block-atomic: shrink until the boundary sits between blocks.
7582    boundary_item = boundary_item.min(display.len().saturating_sub(1));
7583    let bid_of = |i: usize| transcript.get(display[i].source_index).map(|t| t.block_id);
7584    while boundary_item > 0 {
7585        let last = display[boundary_item - 1].source_index;
7586        let next = display[boundary_item].source_index;
7587        let same_block = matches!(
7588            (transcript.get(last), transcript.get(next)),
7589            (Some(a), Some(b)) if a.block_id == b.block_id
7590        );
7591        if !same_block {
7592            break;
7593        }
7594        // Block-atomic by default — but a FINALIZED block taller than
7595        // the viewport can never fit the live region; committing its
7596        // head at a line boundary is the only way it reaches the host
7597        // scrollback (long messages; Claude Code / Ink print the same
7598        // way). The anchor cap above already keeps the streaming block
7599        // out, so anything this split touches is final.
7600        let block_bid = bid_of(boundary_item);
7601        let block_start = (0..boundary_item)
7602            .rev()
7603            .find(|&i| bid_of(i) != block_bid)
7604            .map_or(0, |i| i + 1);
7605        let block_end = (boundary_item..display.len())
7606            .find(|&i| bid_of(i) != block_bid)
7607            .unwrap_or(display.len());
7608        let before_rows = if block_start == 0 {
7609            0
7610        } else {
7611            ends[block_start - 1]
7612        };
7613        if ends[block_end - 1] - before_rows > keep_rows {
7614            break; // oversized: keep the line boundary inside it
7615        }
7616        boundary_item -= 1;
7617    }
7618    if boundary_item == 0 {
7619        return None;
7620    }
7621
7622    // Committed entries run to the first live row: normally the START of
7623    // the next block (a folded block's gap row can point inside its
7624    // block), but an oversized split commits at line granularity.
7625    let first_live = display[boundary_item].source_index;
7626    let last_committed = display[boundary_item - 1].source_index;
7627    let new_committed_entries = if matches!(
7628        (transcript.get(last_committed), transcript.get(first_live)),
7629        (Some(a), Some(b)) if a.block_id == b.block_id
7630    ) {
7631        first_live
7632    } else {
7633        let live_bid = transcript.get(first_live)?.block_id;
7634        transcript.iter().position(|t| t.block_id == live_bid)?
7635    };
7636
7637    let rows = ends[boundary_item - 1].min(u16::MAX as usize) as u16;
7638    Some(ScrollbackCommit {
7639        rows,
7640        boundary_item,
7641        new_committed_entries,
7642    })
7643}
7644
7645/// Render the committed chunk into the `insert_before` buffer. Mirrors
7646/// the viewport's wrapping math so the frozen rows match what the live
7647/// region showed.
7648fn render_committed_chunk(
7649    buf: &mut Buffer,
7650    items: &[TranscriptDisplayItem<'_>],
7651    x: u16,
7652    width: u16,
7653) {
7654    use ratatui::widgets::Widget;
7655    let width = width.max(1);
7656    let mut y = 0u16;
7657    for item in items {
7658        let Some(line) = &item.line else {
7659            y += 1;
7660            continue;
7661        };
7662        let text_w = line.width();
7663        let wrapped_h = if text_w == 0 {
7664            1
7665        } else {
7666            text_w.div_ceil(width as usize).max(1) as u16
7667        };
7668        let area = Rect {
7669            x,
7670            y,
7671            width,
7672            height: wrapped_h,
7673        };
7674        Paragraph::new(line.clone())
7675            .wrap(Wrap { trim: false })
7676            .render(area, buf);
7677        y += wrapped_h;
7678    }
7679}
7680
7681/// Flush finalized transcript rows into the host terminal's real
7682/// scrollback (inline-viewport pattern — peer parity with Claude Code /
7683/// pi). Runs only when the live content overflows the viewport and the
7684/// user is not browsing: streaming buffers must be empty (the anchored
7685/// block is still being rewritten otherwise) and manual scrolling /
7686/// overlays / search pause committing so the live region stays put.
7687/// Committed blocks are frozen — block-mode cycling applies to live
7688/// blocks only (Claude Code behaves the same way).
7689fn commit_scrollback(
7690    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
7691    state: &mut RenderState,
7692    force_all: bool,
7693) {
7694    if state.scroll_offset != usize::MAX
7695        || !state.message_buffer.is_empty()
7696        || !state.thinking_buffer.is_empty()
7697        || state.overlay.is_some()
7698        || state.confirmation.is_some()
7699        || state.agent_hub_open
7700        || state.slash_popup.open
7701        || state.file_search.is_some()
7702        || state.search.is_some()
7703        || state.transcript.is_empty()
7704    {
7705        return;
7706    }
7707    let Ok(size) = terminal.size() else {
7708        return;
7709    };
7710    let area = Rect {
7711        x: 0,
7712        y: 0,
7713        width: size.width,
7714        height: size.height,
7715    };
7716    let keep_rows = super::frame_layout::scrollback_height(area) as usize;
7717    if keep_rows == 0 && !force_all {
7718        return;
7719    }
7720    let styles = active_styles();
7721    let (gutter_x, scrollback_w) = super::frame_layout::scrollback_geometry(area);
7722    let content_w = scrollback_w as usize;
7723    let display =
7724        build_transcript_display(state, &styles, state.committed_entries, content_w as u16);
7725    if force_all {
7726        // On exit: commit the entire committable prefix in one shot.
7727        // Streaming buffers are already empty at this point; unfinalized
7728        // stream content simply stays in the live viewport. The boundary
7729        // comes from `plan_full_flush` (trivial: display length).
7730        let boundary_item = plan_full_flush(display.len()).min(display.len());
7731        if boundary_item == 0 {
7732            return;
7733        }
7734        // Cumulative display rows through `boundary_item` — needed for
7735        // `insert_before`'s height hint.
7736        let mut total_rows = 0usize;
7737        let width = content_w.max(1);
7738        for d in &display[..boundary_item] {
7739            total_rows += match &d.line {
7740                None => 1,
7741                Some(line) => {
7742                    let w = line.width();
7743                    if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7744                }
7745            };
7746        }
7747        let rows = total_rows.min(u16::MAX as usize) as u16;
7748        let chunk = &display[..boundary_item];
7749        let res = terminal.insert_before(rows, |buf| {
7750            render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7751        });
7752        if res.is_ok() {
7753            // After a force-flush, every committed row is in scrollback;
7754            // advance the marker to the end of the transcript so the
7755            // final draw pass doesn't try to re-commit anything.
7756            state.committed_entries = state.transcript.len();
7757        }
7758        return;
7759    }
7760    let Some(plan) = scrollback_commit_plan(
7761        &display,
7762        &state.transcript,
7763        content_w,
7764        keep_rows,
7765        state.stream_anchor,
7766    ) else {
7767        return;
7768    };
7769    let chunk = &display[..plan.boundary_item];
7770    let res = terminal.insert_before(plan.rows, |buf| {
7771        render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7772    });
7773    if res.is_ok() {
7774        state.committed_entries = plan.new_committed_entries;
7775    }
7776}
7777
7778/// Build a ratatui `Line` from a transcript line, with optional fold marker
7779/// and search-match highlighting.
7780///
7781/// Plain transcript (omp-style): speaker identity is weight and color, not
7782/// chrome. There is no rail column, no speaker label, and no prefix glyph —
7783/// the user's input is the only bold body text, in the primary color, and
7784/// the agent's response reads in the default ink. System severities keep a
7785/// short colored label on the block's first line because severity is data.
7786fn transcript_line_marked<'a>(
7787    line: &'a TranscriptLine,
7788    styles: &'a ThemeStyles,
7789    folded: bool,
7790    is_match: bool,
7791    is_current: bool,
7792    is_block_start: bool,
7793    width: u16,
7794) -> Line<'a> {
7795    let kind_style = match line.kind {
7796        InlineMessageKind::Agent => {
7797            Style::default().fg(color_from_anstyle(styles.response.get_fg_color()))
7798        }
7799        InlineMessageKind::User => {
7800            Style::default().fg(color_from_anstyle(styles.user.get_fg_color()))
7801        }
7802        InlineMessageKind::Tool => {
7803            Style::default().fg(color_from_anstyle(styles.tool.get_fg_color()))
7804        }
7805        InlineMessageKind::Error => {
7806            Style::default().fg(color_from_anstyle(styles.error.get_fg_color()))
7807        }
7808        InlineMessageKind::Warning => {
7809            Style::default().fg(color_from_anstyle(styles.status.get_fg_color()))
7810        }
7811        InlineMessageKind::Info => {
7812            Style::default().fg(color_from_anstyle(styles.info.get_fg_color()))
7813        }
7814        InlineMessageKind::Policy => {
7815            Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color()))
7816        }
7817        InlineMessageKind::Pty => {
7818            Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color()))
7819        }
7820    };
7821
7822    // Severity labels appear on the block's first line only; folded heads
7823    // always show the marker so a collapsed block stays identifiable.
7824    let severity_label = match line.kind {
7825        InlineMessageKind::Error => Some("error: "),
7826        InlineMessageKind::Warning => Some("warning: "),
7827        InlineMessageKind::Info => Some("info: "),
7828        InlineMessageKind::Policy => Some("policy: "),
7829        _ => None,
7830    };
7831    let mut prefix = String::new();
7832    if folded {
7833        prefix.push_str("[+] ");
7834    }
7835    if let Some(label) = severity_label
7836        && (folded || is_block_start)
7837    {
7838        prefix.push_str(label);
7839    }
7840
7841    // Highlight background for search matches.
7842    let highlight = if is_current {
7843        Some(Style::default().reversed())
7844    } else if is_match {
7845        Some(Style::default().add_modifier(Modifier::UNDERLINED))
7846    } else {
7847        None
7848    };
7849    // Write-path width invariant (omp tui-core-renderer.md §4): every row
7850    // must fit inside the terminal width. Reserve the prefix's display
7851    // width first so the segments never push the row past `width`. The
7852    // prefix is always ASCII (`"[+] "`, `"error: "`, ...) so `.len()` is
7853    // a faithful display-width measure here.
7854    let prefix_w = prefix.len() as u16;
7855    let budget = width.saturating_sub(prefix_w);
7856    let clamped = clamp_segments_to_width(&line.segments, budget);
7857    let mut spans = Vec::with_capacity(clamped.len() + 1);
7858    if !prefix.is_empty() {
7859        spans.push(Span::styled(prefix, kind_style));
7860    }
7861    for segment in &clamped {
7862        let mut style = segment_style(segment, kind_style, styles);
7863        // Weight-led hierarchy: user input is the only bold body text.
7864        if line.kind == InlineMessageKind::User {
7865            style = style.add_modifier(Modifier::BOLD);
7866        }
7867        if let Some(h) = highlight {
7868            style = style.patch(h);
7869        }
7870        spans.push(Span::styled(segment.text.clone(), style));
7871    }
7872    Line::from(spans)
7873}
7874
7875pub(crate) fn segment_style(
7876    segment: &InlineSegment,
7877    fallback: Style,
7878    _styles: &ThemeStyles,
7879) -> Style {
7880    let mut style = fallback;
7881    let inline = segment.style.as_ref();
7882    if let Some(color) = inline.color {
7883        style = style.fg(color_from_anstyle(Some(color)));
7884    }
7885    // No inline color: keep the kind fallback (`fallback`). Overriding
7886    // with a fixed `response` ink made user turns indistinguishable from
7887    // agent output — the kind color is the speaker signal in plain style.
7888    if inline.effects.contains(anstyle::Effects::BOLD) {
7889        style = style.add_modifier(Modifier::BOLD);
7890    }
7891    if inline.effects.contains(anstyle::Effects::ITALIC) {
7892        style = style.add_modifier(Modifier::ITALIC);
7893    }
7894    if inline.effects.contains(anstyle::Effects::UNDERLINE) {
7895        style = style.add_modifier(Modifier::UNDERLINED);
7896    }
7897    if inline.effects.contains(anstyle::Effects::DIMMED) {
7898        style = style.add_modifier(Modifier::DIM);
7899    }
7900    style
7901}
7902fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
7903    let styles = active_styles();
7904    let prefix_style = Style::default()
7905        .fg(color_from_anstyle(styles.primary.get_fg_color()))
7906        .bold();
7907
7908    let prefix = state.prompt_prefix.clone();
7909    let placeholder = state.placeholder.clone();
7910
7911    // Build prefix spans. The prefix lives in a static leading region of the
7912    // composer box; the textarea renders the editable body in the
7913    // remaining area (right of `prefix_w`). The textarea's own
7914    // `cursor_pos_with_state` reports the cursor relative to that area.
7915    // All prefix segments are ASCII-only today (">[auto] ", "[vim] ", "! ");
7916    // using UnicodeWidthStr keeps the math correct if any of them grows a
7917    // wide glyph in the future (e.g. a status emoji in the vim label).
7918    let mut prefix_w: u16 = 0;
7919    let mut line_spans = Vec::new();
7920    if let Some(label) = state.vim_state.status_label() {
7921        let seg = format!("[{label}] ");
7922        prefix_w = prefix_w.saturating_add(seg.width() as u16);
7923        line_spans.push(Span::styled(
7924            seg,
7925            Style::default()
7926                .fg(color_from_anstyle(styles.tool.get_fg_color()))
7927                .add_modifier(Modifier::BOLD),
7928        ));
7929    }
7930    if state.autonomy_mode.is_auto() {
7931        let seg = "[auto] ";
7932        prefix_w = prefix_w.saturating_add(seg.width() as u16);
7933        line_spans.push(Span::styled(
7934            seg,
7935            Style::default()
7936                .fg(Color::Yellow)
7937                .add_modifier(Modifier::BOLD),
7938        ));
7939    }
7940    prefix_w = prefix_w.saturating_add(UnicodeWidthStr::width(prefix.as_str()) as u16);
7941    line_spans.push(Span::styled(prefix, prefix_style));
7942    if state.shell_mode {
7943        let seg = "! ";
7944        prefix_w = prefix_w.saturating_add(seg.width() as u16);
7945        line_spans.push(Span::styled(
7946            seg,
7947            Style::default()
7948                .fg(Color::Yellow)
7949                .add_modifier(Modifier::BOLD),
7950        ));
7951    }
7952
7953    let context_line = composer_context_line(state, area.width);
7954    let used: usize = context_line.spans.iter().map(|s| s.width()).sum();
7955    let mut block = Block::default()
7956        .borders(Borders::ALL)
7957        .border_type(BorderType::Plain)
7958        .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
7959        // The top border is useful real estate. It carries the active session
7960        // context instead of spending a full row on a generic "MESSAGE"
7961        // label, while the border still makes the input target unmistakable.
7962        .title(context_line);
7963    if let Some(chip) = composer_brain_chip(state, area.width, used) {
7964        block = block.title(chip);
7965    }
7966
7967    // Place the prefix in a leading line, then render the textarea in the
7968    // remaining width. When the body is empty AND a placeholder is
7969    // configured, render the placeholder as dimmed text (preserving the
7970    // pre-port look) and put the caret at the placeholder start.
7971    let inner = area.inner(Margin::new(1, 1));
7972    let textarea_area = Rect {
7973        x: inner.left().saturating_add(prefix_w),
7974        y: inner.top(),
7975        width: inner.width.saturating_sub(prefix_w),
7976        height: inner.height,
7977    };
7978    if state.composer.is_empty()
7979        && let Some(ph) = placeholder.as_deref()
7980    {
7981        // Prefix + placeholder as a single paragraph (no body).
7982        line_spans.push(Span::styled(
7983            ph.to_string(),
7984            Style::default()
7985                .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7986                .dim(),
7987        ));
7988        let paragraph = Paragraph::new(Line::from(line_spans))
7989            .block(block)
7990            .wrap(Wrap { trim: false });
7991        frame.render_widget(paragraph, area);
7992        if state.input_enabled {
7993            // Caret sits at the start of the placeholder so the user sees
7994            // where typing will land — same behavior as before the port.
7995            frame.set_cursor_position(Position::new(
7996                inner.left().saturating_add(prefix_w),
7997                area.top().saturating_add(1),
7998            ));
7999        }
8000        return;
8001    }
8002    // Paint the prefix in the first `prefix_w` columns of the inner box,
8003    // then the textarea paints the editable body. The textarea reports
8004    // its caret position relative to `textarea_area`; we add the
8005    // area origin at the end.
8006    let prefix_area = Rect {
8007        x: inner.left(),
8008        y: inner.top(),
8009        width: prefix_w,
8010        height: inner.height,
8011    };
8012    // Render the bordered box (with no body content) and the prefix
8013    // spans inside it.
8014    let frame_paragraph = Paragraph::new(Line::from(Vec::<Span>::new()))
8015        .block(block)
8016        .wrap(Wrap { trim: false });
8017    frame.render_widget(frame_paragraph, area);
8018    frame.render_widget(Paragraph::new(Line::from(line_spans)), prefix_area);
8019    frame.render_widget_ref(&state.composer, textarea_area);
8020
8021    if state.input_enabled
8022        && let Some((cx, cy)) = state
8023            .composer
8024            .cursor_pos_with_state(textarea_area, TextAreaState::default())
8025    {
8026        // `cursor_pos_with_state` returns the ABSOLUTE screen position:
8027        // it already adds `area.x` and `area.y` to the cursor's column/row
8028        // inside the area (see oxicode-textarea `cursor_pos_with_state`:
8029        // `Some((area.x + col, area.y + screen_row))`). Do NOT add the
8030        // area origin again — that double-offset pushed the caret off the
8031        // frame (e.g. row 38 on a 24-row terminal).
8032        frame.set_cursor_position(Position::new(cx, cy));
8033    }
8034}
8035
8036/// Compact session facts embedded in the composer's top border.
8037///
8038/// The field order is deliberately task-oriented: model and reasoning first,
8039/// then place/version-control context, then the capacity signal.
8040/// At narrower widths lower-priority facts disappear as complete fields
8041/// rather than being clipped halfway through a path or branch name.
8042fn composer_context_line<'a>(state: &'a RenderState, width: u16) -> Line<'a> {
8043    let styles = active_styles();
8044    let primary = color_from_anstyle(styles.primary.get_fg_color());
8045    let fg = color_from_anstyle(Some(styles.foreground));
8046    let muted = color_from_anstyle(styles.secondary.get_fg_color());
8047    let info = color_from_anstyle(styles.info.get_fg_color());
8048
8049    let model = state
8050        .header_context
8051        .model
8052        .strip_prefix(&format!("{}/", state.header_context.provider))
8053        .unwrap_or(&state.header_context.model);
8054    let workspace = state
8055        .cwd
8056        .file_name()
8057        .map(|name| name.to_string_lossy().into_owned())
8058        .filter(|name| !name.is_empty())
8059        .unwrap_or_else(|| "workspace".to_string());
8060    let branch = state
8061        .header_context
8062        .persistent_memory
8063        .as_ref()
8064        .map(|badge| badge.text.as_str())
8065        .filter(|branch| !branch.is_empty())
8066        .unwrap_or("—");
8067    let context = match state.context_tokens {
8068        Some(used) => {
8069            let percent = used.saturating_mul(100) / state.context_window.max(1);
8070            format!(
8071                "{}/{} {percent}%",
8072                compact_token_count(used),
8073                compact_token_count(state.context_window)
8074            )
8075        }
8076        None => format!("0/{}", compact_token_count(state.context_window)),
8077    };
8078
8079    // (label, value, value style, minimum width). The first surviving field
8080    // renders without a leading separator — there is no app badge. With
8081    // `glyph_set = "nerd"`, labels become Nerd Font icons (never emoji).
8082    use crate::symbols::nerd as icons;
8083    let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8084    let label =
8085        |text: &'static str, icon: &'static str| -> &'static str { if nerd { icon } else { text } };
8086    let mut fields: Vec<(&str, String, Style, u16)> = vec![
8087        (
8088            label("MODEL ", icons::MODEL),
8089            model.to_string(),
8090            Style::default().fg(fg).add_modifier(Modifier::BOLD),
8091            0,
8092        ),
8093        (
8094            label("THINK ", icons::THINK),
8095            state.thinking_level.clone(),
8096            Style::default().fg(info),
8097            58,
8098        ),
8099        (
8100            label("DIR ", icons::DIR),
8101            workspace,
8102            Style::default().fg(fg),
8103            82,
8104        ),
8105        (
8106            label("GIT ", icons::GIT),
8107            branch.to_string(),
8108            Style::default().fg(fg),
8109            104,
8110        ),
8111        (
8112            label("CTX ", icons::CTX),
8113            context,
8114            Style::default().fg(info),
8115            124,
8116        ),
8117    ];
8118    if state.active_run.is_some() || state.reasoning_stage.is_some() {
8119        fields.push((
8120            label("RUN ", icons::RUN),
8121            state
8122                .reasoning_stage
8123                .clone()
8124                .unwrap_or_else(|| "working\u{2026}".to_string()),
8125            Style::default().fg(primary).add_modifier(Modifier::BOLD),
8126            148,
8127        ));
8128    }
8129
8130    let mut spans = Vec::new();
8131    for (i, (label, value, value_style, min_width)) in fields.into_iter().enumerate() {
8132        if width < min_width {
8133            break;
8134        }
8135        if i > 0 {
8136            spans.push(Span::styled(" | ", Style::default().fg(muted)));
8137        }
8138        spans.push(Span::styled(
8139            (*label).to_string(),
8140            Style::default().fg(muted),
8141        ));
8142        spans.push(Span::styled(value, value_style));
8143    }
8144    Line::from(spans)
8145}
8146
8147/// Right-aligned oxibrain health chip rendered as its own border title
8148/// (moved off the removed shortcuts bar): healthy reads info, unreachable
8149/// error, absent when memory is disabled. Nerd mode swaps the prefix for
8150/// the brain glyph.
8151///
8152/// The chip must NOT be space-padded into [`composer_context_line`]: a
8153/// title overwrites the border row for its full width, and the padding
8154/// would erase the `─` rule between the facts and the chip (it did —
8155/// see `brain_chip_does_not_erase_the_border_rule`). A separate
8156/// right-aligned title covers only the chip's own cells.
8157fn composer_brain_chip<'a>(state: &'a RenderState, width: u16, used: usize) -> Option<Line<'a>> {
8158    let styles = active_styles();
8159    let (chip_label, healthy) = state.brain.chip_label()?;
8160    let chip_color = if healthy {
8161        color_from_anstyle(styles.info.get_fg_color())
8162    } else {
8163        color_from_anstyle(styles.error.get_fg_color())
8164    };
8165    let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8166    let text = if nerd {
8167        let state_word = chip_label.trim_start_matches("brain\u{b7}");
8168        format!("{} {}", crate::symbols::nerd::BRAIN, state_word)
8169    } else {
8170        chip_label.to_string()
8171    };
8172    let chip = format!(" {text} ");
8173    // The border's title row is two cells narrower than the block.
8174    let usable = width.saturating_sub(2) as usize;
8175    (used + chip.width() < usable)
8176        .then(|| Line::from(Span::styled(chip, Style::default().fg(chip_color))).right_aligned())
8177}
8178
8179fn compact_token_count(tokens: usize) -> String {
8180    if tokens >= 1_000 {
8181        let whole = tokens / 1_000;
8182        let decimal = (tokens % 1_000) / 100;
8183        if decimal == 0 {
8184            format!("{whole}K")
8185        } else {
8186            format!("{whole}.{decimal}K")
8187        }
8188    } else {
8189        tokens.to_string()
8190    }
8191}
8192
8193/// Render a compact onboarding card when the transcript is empty.
8194///
8195/// The card answers the three questions a fresh terminal should answer at a
8196/// glance: where am I, which model will answer, and what can I do next.
8197fn render_welcome(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
8198    let styles = active_styles();
8199    let primary = color_from_anstyle(styles.primary.get_fg_color());
8200    let fg = color_from_anstyle(Some(styles.foreground));
8201    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8202    let workspace = state
8203        .cwd
8204        .file_name()
8205        .map(|name| name.to_string_lossy().into_owned())
8206        .filter(|name| !name.is_empty())
8207        .unwrap_or_else(|| "workspace".to_string());
8208    let lines = vec![
8209        Line::from(Span::styled(
8210            "OXICODE",
8211            Style::default().fg(primary).add_modifier(Modifier::BOLD),
8212        )),
8213        Line::from(Span::styled(
8214            "Terminal coding assistant",
8215            Style::default().fg(secondary).add_modifier(Modifier::DIM),
8216        )),
8217        Line::from(""),
8218        Line::from(vec![
8219            Span::styled("WORKSPACE  ", Style::default().fg(secondary)),
8220            Span::styled(
8221                workspace,
8222                Style::default().fg(fg).add_modifier(Modifier::BOLD),
8223            ),
8224        ]),
8225        Line::from(vec![
8226            Span::styled("MODEL      ", Style::default().fg(secondary)),
8227            Span::styled(
8228                format!(
8229                    "{} / {}",
8230                    state.header_context.provider, state.header_context.model
8231                ),
8232                Style::default().fg(fg),
8233            ),
8234        ]),
8235        Line::from(""),
8236        Line::from(Span::styled(
8237            "Enter  send     /  commands     @  attach a file",
8238            Style::default().fg(fg),
8239        )),
8240        Line::from(Span::styled(
8241            "?  shortcuts     /model  change model     /help  all commands",
8242            Style::default().fg(secondary),
8243        )),
8244    ];
8245    let height = lines.len().min(area.height as usize) as u16;
8246    let card = Rect {
8247        x: area.x,
8248        y: area.y + area.height.saturating_sub(height) / 2,
8249        width: area.width,
8250        height,
8251    };
8252    frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), card);
8253}
8254
8255/// Render a 1-row run indicator just above the composer.
8256///
8257/// While a run is live this row is continuously owned by the indicator:
8258/// turn boundaries clear `reasoning_stage` but the run tracker keeps the
8259/// row up (falling back to `working…`), so it never flickers to the idle
8260/// row mid-loop. The spinner animates on the frame tick and the suffix
8261/// carries progress facts (turn count, tool calls, elapsed time).
8262fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8263    let styles = active_styles();
8264    let indicator_area = Rect {
8265        x: composer_area.x,
8266        y: composer_area.top().saturating_sub(1),
8267        width: composer_area.width,
8268        height: 1,
8269    };
8270    let primary = color_from_anstyle(styles.primary.get_fg_color());
8271    let muted = color_from_anstyle(styles.secondary.get_fg_color());
8272    // 12.5 fps: lively, but keyed on wall-clock so draw-count bursts
8273    // during streaming can't make it race.
8274    let spin = RUN_SPINNER[(animation_frame(80) as usize) % RUN_SPINNER.len()];
8275    let stage = state
8276        .reasoning_stage
8277        .as_deref()
8278        .unwrap_or("working\u{2026}");
8279    let mut spans = vec![
8280        Span::styled(spin, Style::default().fg(primary)),
8281        Span::styled(
8282            " RUNNING",
8283            Style::default().fg(primary).add_modifier(Modifier::BOLD),
8284        ),
8285        Span::styled(" | ", Style::default().fg(muted)),
8286        Span::styled(
8287            stage.to_string(),
8288            Style::default().fg(muted).add_modifier(Modifier::DIM),
8289        ),
8290    ];
8291    if let Some(run) = &state.active_run {
8292        let elapsed = format_elapsed_secs(run.started_at.elapsed().as_secs());
8293        let facts = if run.turn > 0 {
8294            format!(
8295                " \u{b7} turn {} \u{b7} {} tool call{} \u{b7} {elapsed}",
8296                run.turn,
8297                run.tool_calls,
8298                if run.tool_calls == 1 { "" } else { "s" },
8299            )
8300        } else {
8301            format!(" \u{b7} {elapsed}")
8302        };
8303        spans.push(Span::styled(
8304            facts,
8305            Style::default().fg(muted).add_modifier(Modifier::DIM),
8306        ));
8307    }
8308    // Contextual abort hint (Claude Code pattern): shown only while
8309    // a run is live — the static shortcuts bar is gone.
8310    spans.push(Span::styled(
8311        "  Esc abort \u{b7} Ctrl+C quit",
8312        Style::default().fg(muted).add_modifier(Modifier::DIM),
8313    ));
8314    frame.render_widget(Paragraph::new(Line::from(spans)), indicator_area);
8315}
8316
8317/// Pending-quit hint: shown in the row above the composer after the
8318/// first Ctrl+C aborted a stream — the next press opens the quit
8319/// confirmation. Submitting a new prompt cancels it.
8320fn render_pending_quit_hint(frame: &mut Frame<'_>, composer_area: Rect) {
8321    let styles = active_styles();
8322    let hint_area = Rect {
8323        x: composer_area.x,
8324        y: composer_area.top().saturating_sub(1),
8325        width: composer_area.width,
8326        height: 1,
8327    };
8328    let line = Line::from(Span::styled(
8329        "press Ctrl+C again to quit",
8330        Style::default()
8331            .fg(color_from_anstyle(styles.error.get_fg_color()))
8332            .add_modifier(Modifier::BOLD),
8333    ));
8334    frame.render_widget(Paragraph::new(line), hint_area);
8335}
8336
8337/// Render queued input prompts as a compact pane at the top of the scrollback.
8338fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) -> u16 {
8339    let styles = active_styles();
8340    let entries = &state.queued_inputs;
8341    let interactive = state.queue_panel_open;
8342    let selected = state.queue_selected.min(entries.len().saturating_sub(1));
8343    let height = if interactive {
8344        entries.len() as u16 + 1
8345    } else {
8346        1
8347    };
8348    let area = Rect {
8349        x: scrollback.x,
8350        y: scrollback.y,
8351        width: scrollback.width,
8352        height,
8353    };
8354    let info = color_from_anstyle(styles.info.get_fg_color());
8355    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8356    let primary = color_from_anstyle(styles.primary.get_fg_color());
8357    if !interactive {
8358        frame.render_widget(
8359            Paragraph::new(Line::from(vec![
8360                Span::styled(
8361                    format!("QUEUED {}", entries.len()),
8362                    Style::default().fg(primary).add_modifier(Modifier::BOLD),
8363                ),
8364                Span::styled(
8365                    " | Ctrl+; manage",
8366                    Style::default().fg(secondary).add_modifier(Modifier::DIM),
8367                ),
8368            ])),
8369            area,
8370        );
8371        return height;
8372    }
8373    let items: Vec<Line<'_>> = entries
8374        .iter()
8375        .enumerate()
8376        .map(|(i, e)| {
8377            let prefix = format!("#{} ", i + 1);
8378            let prefix_style = if i == selected {
8379                Style::default().fg(primary).add_modifier(Modifier::BOLD)
8380            } else {
8381                Style::default().fg(info)
8382            };
8383            let text_style = if i == selected {
8384                Style::default().fg(primary).add_modifier(Modifier::BOLD)
8385            } else {
8386                Style::default().fg(secondary)
8387            };
8388            let marker = if i == selected { "> " } else { "  " };
8389            Line::from(vec![
8390                Span::styled(prefix, prefix_style),
8391                Span::styled(marker, prefix_style),
8392                Span::styled(e.clone(), text_style),
8393            ])
8394        })
8395        .collect();
8396    frame.render_widget(
8397        Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
8398            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8399        )),
8400        area,
8401    );
8402    height
8403}
8404
8405/// Format one todo row: marker + content + status-specific suffix + notes
8406/// marker. Ports omp's `#formatTodoLine` (`interactive-mode.ts:2326-2341`).
8407fn format_todo_line(todo: &TodoItem, matched: bool, styles: &ThemeStyles) -> Line<'static> {
8408    let notes_marker = match todo.notes.as_ref().map(|n| n.len()).unwrap_or(0) {
8409        0 => String::new(),
8410        n => format!(" ·{n}"),
8411    };
8412    let (marker, color, strike, suffix) = match todo.status {
8413        TodoStatus::Completed => ("✓", styles.foreground, true, String::new()),
8414        TodoStatus::InProgress => (
8415            "▸",
8416            styles.primary.get_fg_color().unwrap_or(styles.foreground),
8417            false,
8418            String::new(),
8419        ),
8420        TodoStatus::Abandoned => (
8421            "☐",
8422            styles.error.get_fg_color().unwrap_or(styles.foreground),
8423            true,
8424            String::new(),
8425        ),
8426        TodoStatus::Blocked => {
8427            let reason = todo
8428                .block_reason
8429                .as_deref()
8430                .map(|r| format!(" (blocked: {r})"))
8431                .unwrap_or_else(|| " (blocked)".to_string());
8432            (
8433                "☐",
8434                styles.info.get_fg_color().unwrap_or(styles.foreground),
8435                false,
8436                reason,
8437            )
8438        }
8439        TodoStatus::Pending if matched => (
8440            "☐",
8441            styles.primary.get_fg_color().unwrap_or(styles.foreground),
8442            false,
8443            String::new(),
8444        ),
8445        TodoStatus::Pending => (
8446            "☐",
8447            styles.secondary.get_fg_color().unwrap_or(styles.foreground),
8448            false,
8449            String::new(),
8450        ),
8451    };
8452    let mut text_style = Style::default().fg(color_from_anstyle(Some(color)));
8453    if strike {
8454        text_style = text_style.add_modifier(Modifier::CROSSED_OUT);
8455    }
8456    Line::from(vec![
8457        Span::styled(
8458            format!("{marker} "),
8459            Style::default().fg(color_from_anstyle(Some(color))),
8460        ),
8461        Span::styled(
8462            format!("{}{}{}", todo.content, suffix, notes_marker),
8463            text_style,
8464        ),
8465    ])
8466}
8467
8468const TREE_BRANCH: &str = "├─";
8469const TREE_VERTICAL: &str = "│ ";
8470const TREE_HOOK: &str = "└";
8471const SUBSEQUENT_STAGE_CAP: usize = 4;
8472const ACTIVE_TASK_CAP: usize = 5;
8473
8474/// Index of the first phase with pending/in-progress work; falls back to the
8475/// last phase. Ports omp's `#getActivePhase` (`interactive-mode.ts:2489`).
8476fn active_phase_index(phases: &[&TodoPhase]) -> usize {
8477    phases
8478        .iter()
8479        .position(|p| {
8480            p.tasks
8481                .iter()
8482                .any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
8483        })
8484        .unwrap_or_else(|| phases.len().saturating_sub(1))
8485}
8486
8487/// Closed = completed or abandoned (the collapsed window hides both).
8488fn closed_count(tasks: &[TodoItem]) -> usize {
8489    tasks
8490        .iter()
8491        .filter(|t| matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned))
8492        .count()
8493}
8494
8495/// "I. Foundation", "II. Auth", … Reuses `roman_numeral` from `todo.rs`.
8496fn phase_display_name(name: &str, one_based: usize) -> String {
8497    format!(
8498        "{}. {name}",
8499        oxicode_agent::tools::todo::roman_numeral(one_based)
8500    )
8501}
8502
8503/// Render the sticky todo HUD: phase tree + progress spine. Ports omp's
8504/// `#renderTodoList` (`interactive-mode.ts:2529-2643`). Returns rows used so
8505/// callers can reserve the space (mirrors `render_queue_pane`).
8506fn render_todo_pane(
8507    frame: &mut Frame<'_>,
8508    area: Rect,
8509    phases: &[TodoPhase],
8510    expanded: bool,
8511    is_matched: impl Fn(&TodoItem) -> bool,
8512) -> u16 {
8513    let phases: Vec<&TodoPhase> = phases.iter().filter(|p| !p.tasks.is_empty()).collect();
8514    if phases.is_empty() {
8515        return 0;
8516    }
8517    let styles = active_styles();
8518    let multi_phase = phases.len() > 1;
8519    let active_idx = active_phase_index(&phases);
8520
8521    let render_tasks = |phase: &TodoPhase| -> Vec<Line<'static>> {
8522        if expanded {
8523            phase
8524                .tasks
8525                .iter()
8526                .map(|t| format_todo_line(t, is_matched(t), &styles))
8527                .collect()
8528        } else {
8529            let sel = oxicode_agent::tools::todo::select_collapsed_todos(
8530                &phase.tasks,
8531                &is_matched,
8532                ACTIVE_TASK_CAP,
8533            );
8534            let mut lines: Vec<Line<'static>> = sel
8535                .items
8536                .iter()
8537                .map(|t| format_todo_line(t, is_matched(t), &styles))
8538                .collect();
8539            if let Some(summary) = sel.summary {
8540                lines.push(Line::from(Span::styled(
8541                    summary,
8542                    Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8543                )));
8544            }
8545            lines
8546        }
8547    };
8548
8549    let base_idx = if expanded { 0 } else { active_idx };
8550    let phase_slice: &[&TodoPhase] = if expanded {
8551        &phases[base_idx..]
8552    } else {
8553        &phases[base_idx..(base_idx + 1 + SUBSEQUENT_STAGE_CAP).min(phases.len())]
8554    };
8555    let hidden_stages = phases.len() - base_idx - phase_slice.len();
8556
8557    let mut content_lines: Vec<Line<'static>> = Vec::new();
8558    for (i, phase) in phase_slice.iter().enumerate() {
8559        let one_based = base_idx + i + 1;
8560        let is_active = base_idx + i == active_idx;
8561        let done = closed_count(&phase.tasks);
8562        let header_text = if multi_phase {
8563            format!(
8564                "{} · {done}/{}",
8565                phase_display_name(&phase.name, one_based),
8566                phase.tasks.len()
8567            )
8568        } else {
8569            phase.name.clone()
8570        };
8571        let header_style = if is_active {
8572            Style::default()
8573                .fg(color_from_anstyle(styles.primary.get_fg_color()))
8574                .add_modifier(Modifier::BOLD)
8575        } else {
8576            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))
8577        };
8578        content_lines.push(Line::from(Span::styled(header_text, header_style)));
8579        if is_active || expanded {
8580            content_lines.extend(render_tasks(phase));
8581        }
8582    }
8583    if hidden_stages > 0 {
8584        content_lines.push(Line::from(Span::styled(
8585            format!(
8586                "… {hidden_stages} more stage{}",
8587                if hidden_stages == 1 { "" } else { "s" }
8588            ),
8589            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8590        )));
8591    }
8592
8593    // Progress spine: `closed / total` across every phase fills the tree path
8594    // (content rows + 1 closing-hook row) in accent, clamped so a partial
8595    // plan lights at least one cell and a closed plan never overfills.
8596    let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8597    let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8598    let path_len = content_lines.len() + 1;
8599    let mut filled = (closed * path_len).checked_div(total).unwrap_or(0);
8600    if closed > 0 {
8601        filled = filled.max(1);
8602    }
8603    if closed < total {
8604        filled = filled.min(path_len.saturating_sub(1));
8605    }
8606
8607    let mut lines: Vec<Line<'static>> = vec![Line::from(Span::styled(
8608        "TODO",
8609        Style::default()
8610            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8611            .add_modifier(Modifier::BOLD),
8612    ))];
8613    for (i, content) in content_lines.into_iter().enumerate() {
8614        let glyph = if i == 0 { TREE_BRANCH } else { TREE_VERTICAL };
8615        let glyph_color = if i < filled {
8616            styles.primary.get_fg_color()
8617        } else {
8618            styles.secondary.get_fg_color()
8619        };
8620        let mut spans = vec![Span::styled(
8621            format!(" {glyph}"),
8622            Style::default().fg(color_from_anstyle(glyph_color)),
8623        )];
8624        spans.extend(content.spans);
8625        lines.push(Line::from(spans));
8626    }
8627    // path_len = content rows + 1 hook row; the hook fills only when every
8628    // cell (including it) is lit, i.e. the whole list is closed.
8629    let hook_color = if filled >= path_len {
8630        styles.primary.get_fg_color()
8631    } else {
8632        styles.secondary.get_fg_color()
8633    };
8634    lines.push(Line::from(Span::styled(
8635        format!(" {TREE_HOOK}"),
8636        Style::default().fg(color_from_anstyle(hook_color)),
8637    )));
8638
8639    let height = lines.len() as u16;
8640    frame.render_widget(
8641        Paragraph::new(lines),
8642        Rect {
8643            x: area.x,
8644            y: area.y,
8645            width: area.width,
8646            height,
8647        },
8648    );
8649    height
8650}
8651
8652const TODO_COMPACT_ROWS_THRESHOLD: u16 = 18;
8653
8654/// First in-progress task, else the first pending task, else `None`. Ports
8655/// omp's `nextActionableTask` (`todo.ts:164-172`).
8656fn next_actionable_task(phases: &[TodoPhase]) -> Option<&TodoItem> {
8657    let mut first_pending = None;
8658    for phase in phases {
8659        for task in &phase.tasks {
8660            if task.status == TodoStatus::InProgress {
8661                return Some(task);
8662            }
8663            if first_pending.is_none() && task.status == TodoStatus::Pending {
8664                first_pending = Some(task);
8665            }
8666        }
8667    }
8668    first_pending
8669}
8670
8671/// Single-line HUD used on short terminals (< 18 rows): "TODO N/M · <task>".
8672/// Ports omp's `renderCompactStatusLine` (`interactive-mode.ts:2645+`).
8673fn render_todo_compact_line(phases: &[TodoPhase]) -> Line<'static> {
8674    let styles = active_styles();
8675    let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8676    let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8677    let mut spans = vec![Span::styled(
8678        format!("TODO {closed}/{total} "),
8679        Style::default()
8680            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8681            .add_modifier(Modifier::BOLD),
8682    )];
8683    match next_actionable_task(phases) {
8684        Some(task) => spans.extend(format_todo_line(task, false, &styles).spans),
8685        None => spans.push(Span::styled(
8686            "✓ done",
8687            Style::default().fg(color_from_anstyle(Some(styles.foreground))),
8688        )),
8689    }
8690    Line::from(spans)
8691}
8692
8693/// Pull the latest todo phases, auto-reconciling against the hub's *idle*
8694/// sub-agents (a transition Running → Idle is a successful completion) and
8695/// committing the reconciled result back when it changed. Ports omp's
8696/// `#reconcileTodosWithSubagents` (`interactive-mode.ts:2369-2404`).
8697fn refresh_todo_phases(
8698    provider: &std::sync::Arc<dyn TodoStateProvider>,
8699    hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8700) -> Vec<TodoPhase> {
8701    let phases = provider.get_phases();
8702    let Some(hub) = hub else {
8703        return phases;
8704    };
8705    let completed: Vec<String> = hub
8706        .snapshot()
8707        .into_iter()
8708        .filter(|(_, e)| {
8709            e.kind == oxicode_sdk::HubKind::Subagent && e.status == oxicode_sdk::HubStatus::Idle
8710        })
8711        .filter_map(|(_, e)| e.current_task)
8712        .collect();
8713    let (updated, mutated) =
8714        oxicode_agent::tools::todo::reconcile_with_subagents(&phases, &completed);
8715    if mutated {
8716        provider.set_phases_sync(updated.clone());
8717    }
8718    updated
8719}
8720
8721/// Whether every task in the list is closed (`Completed`/`Abandoned`) and at
8722/// least one task exists. A list with zero phases or zero tasks is not
8723/// "settled" — there's nothing meaningful to auto-clear.
8724fn is_todo_list_settled(phases: &[TodoPhase]) -> bool {
8725    let mut seen_task = false;
8726    for phase in phases {
8727        for task in &phase.tasks {
8728            if !matches!(task.status, TodoStatus::Completed | TodoStatus::Abandoned) {
8729                return false;
8730            }
8731            seen_task = true;
8732        }
8733    }
8734    seen_task
8735}
8736
8737/// HUD-only auto-clear: does not touch the underlying `TodoState`, so a
8738/// `/todo` or `todo` tool call after clearing still sees the historical
8739/// phases. `delay_secs < 0` disables clearing entirely. Called every frame
8740/// after `refresh_todo_phases`, so a settled list stays visually cleared.
8741fn sync_todo_clear_timer(state: &mut RenderState, delay_secs: i64) {
8742    if delay_secs < 0 || !is_todo_list_settled(&state.todo_phases) {
8743        state.todo_clear_deadline = None;
8744        return;
8745    }
8746    if delay_secs == 0 {
8747        state.todo_phases.clear();
8748        state.todo_clear_deadline = None;
8749        return;
8750    }
8751    let deadline = state.todo_clear_deadline.get_or_insert_with(|| {
8752        std::time::Instant::now() + std::time::Duration::from_secs(delay_secs as u64)
8753    });
8754    if std::time::Instant::now() >= *deadline {
8755        state.todo_phases.clear();
8756        state.todo_clear_deadline = None;
8757    }
8758}
8759
8760/// Closure that lights a pending todo up (accent) when a *running* sub-agent
8761/// is executing it, matched by normalized content overlap. Ports omp's
8762/// `isMatched` (`interactive-mode.ts:2543`).
8763fn build_matched_closure(
8764    hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8765) -> impl Fn(&TodoItem) -> bool + '_ {
8766    let active_descs: Vec<String> = hub
8767        .map(|h| {
8768            h.snapshot()
8769                .into_iter()
8770                .filter(|(_, e)| {
8771                    e.kind == oxicode_sdk::HubKind::Subagent
8772                        && e.status == oxicode_sdk::HubStatus::Running
8773                })
8774                .filter_map(|(_, e)| e.current_task)
8775                .collect()
8776        })
8777        .unwrap_or_default();
8778    move |t| {
8779        !active_descs.is_empty()
8780            && oxicode_agent::tools::todo::todo_matches_any_description(&t.content, &active_descs)
8781    }
8782}
8783
8784/// Render follow-up suggestion chips just above the composer.
8785fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
8786    let styles = active_styles();
8787    let area = Rect {
8788        x: composer_area.x,
8789        y: composer_area.top().saturating_sub(1),
8790        width: composer_area.width,
8791        height: 1,
8792    };
8793    let mut spans = vec![Span::styled(
8794        "Suggestions: ",
8795        Style::default()
8796            .fg(color_from_anstyle(styles.secondary.get_fg_color()))
8797            .add_modifier(Modifier::DIM),
8798    )];
8799    for (i, chip) in chips.iter().enumerate() {
8800        if i > 0 {
8801            spans.push(Span::raw("  "));
8802        }
8803        spans.push(Span::styled(
8804            format!("[{}]", chip),
8805            Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
8806        ));
8807    }
8808    frame.render_widget(Paragraph::new(Line::from(spans)), area);
8809}
8810
8811/// Whether an ephemeral tip is still within its visible TTL window.
8812fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
8813    now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
8814}
8815
8816/// Render the ephemeral tip banner one row above the composer.
8817fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
8818    let styles = active_styles();
8819    let area = Rect {
8820        x: composer_area.x,
8821        y: composer_area.top().saturating_sub(1),
8822        width: composer_area.width,
8823        height: 1,
8824    };
8825    let line = Line::styled(
8826        format!(" note: {text}"),
8827        Style::default()
8828            .fg(color_from_anstyle(styles.info.get_fg_color()))
8829            .add_modifier(Modifier::DIM),
8830    );
8831    frame.render_widget(Paragraph::new(line), area);
8832}
8833
8834/// Render the slash-command autocomplete popup as a floating panel above the
8835/// composer. Anchored to the composer's left edge, grows upward.
8836fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8837    let styles = active_styles();
8838    let items = &state.slash_popup.items;
8839    if items.is_empty() {
8840        return;
8841    }
8842
8843    let max_visible = 7usize;
8844    let visible = items.len().min(max_visible);
8845    let popup_h = visible as u16 + 3; // borders + persistent key-help row
8846    let width = composer_area.width.min(64);
8847    let popup_area = Rect {
8848        x: composer_area.left(),
8849        y: composer_area.top().saturating_sub(popup_h),
8850        width,
8851        height: popup_h,
8852    };
8853    frame.render_widget(Clear, popup_area);
8854
8855    let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8856    let title = Line::from(Span::styled(
8857        " COMMANDS ",
8858        Style::default()
8859            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8860            .add_modifier(Modifier::BOLD),
8861    ));
8862    let block = Block::default()
8863        .borders(Borders::ALL)
8864        .border_type(BorderType::Plain)
8865        .border_style(Style::default().fg(border_color))
8866        .title(title);
8867    let inner = block.inner(popup_area);
8868    frame.render_widget(&block, popup_area);
8869
8870    // Column-align labels by padding to the widest visible label.
8871    let max_label = items
8872        .iter()
8873        .take(visible)
8874        .map(|i| i.label.chars().count())
8875        .max()
8876        .unwrap_or(0);
8877
8878    let primary = color_from_anstyle(styles.primary.get_fg_color());
8879    let fg = color_from_anstyle(Some(styles.foreground));
8880    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8881
8882    for (i, item) in items.iter().take(visible).enumerate() {
8883        let is_selected = i == state.slash_popup.selected;
8884        let y = inner.top() + i as u16;
8885        let row_area = Rect {
8886            x: inner.left(),
8887            y,
8888            width: inner.width,
8889            height: 1,
8890        };
8891
8892        let marker = if is_selected { "> " } else { "  " };
8893        let label_style = if is_selected {
8894            Style::default().fg(primary).add_modifier(Modifier::BOLD)
8895        } else {
8896            Style::default().fg(fg)
8897        };
8898        let label_padded = format!("{:<width$}", item.label, width = max_label);
8899        let line = Line::from(vec![
8900            Span::styled(marker, label_style),
8901            Span::styled(label_padded, label_style),
8902            Span::raw("  "),
8903            Span::styled(&item.description, Style::default().fg(secondary)),
8904        ]);
8905        frame.render_widget(Paragraph::new(line), row_area);
8906    }
8907    frame.render_widget(
8908        Paragraph::new(Line::from(Span::styled(
8909            "Enter insert | Up/Down move | Esc close",
8910            Style::default().fg(secondary).add_modifier(Modifier::DIM),
8911        ))),
8912        Rect {
8913            x: inner.left(),
8914            y: inner.bottom().saturating_sub(1),
8915            width: inner.width,
8916            height: 1,
8917        },
8918    );
8919}
8920
8921/// Render the @-file-search dropdown as a floating panel above the
8922/// composer, mirroring `render_slash_popup`'s geometry. Shows up to 10
8923/// fuzzy-matched file paths with the selected one highlighted.
8924fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8925    let styles = active_styles();
8926    let Some(fs) = &state.file_search else {
8927        return;
8928    };
8929    let items = &fs.results;
8930    if items.is_empty() {
8931        return;
8932    }
8933
8934    let max_visible = 10usize;
8935    let visible = items.len().min(max_visible);
8936    let popup_h = visible as u16 + 3; // borders + persistent key-help row
8937    let width = composer_area.width.min(72);
8938    let popup_area = Rect {
8939        x: composer_area.left(),
8940        y: composer_area.top().saturating_sub(popup_h),
8941        width,
8942        height: popup_h,
8943    };
8944    frame.render_widget(Clear, popup_area);
8945
8946    let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8947    let title_str = if fs.hidden_mode {
8948        " FILES: HIDDEN "
8949    } else {
8950        " FILES "
8951    };
8952    let title = Line::from(Span::styled(
8953        title_str,
8954        Style::default()
8955            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8956            .add_modifier(Modifier::BOLD),
8957    ));
8958    let block = Block::default()
8959        .borders(Borders::ALL)
8960        .border_type(BorderType::Plain)
8961        .border_style(Style::default().fg(border_color))
8962        .title(title);
8963    let inner = block.inner(popup_area);
8964    frame.render_widget(&block, popup_area);
8965
8966    let primary = color_from_anstyle(styles.primary.get_fg_color());
8967    let fg = color_from_anstyle(Some(styles.foreground));
8968    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8969
8970    for (i, result) in items.iter().take(visible).enumerate() {
8971        let is_selected = i == fs.selected;
8972        let y = inner.top() + i as u16;
8973        let row_area = Rect {
8974            x: inner.left(),
8975            y,
8976            width: inner.width,
8977            height: 1,
8978        };
8979
8980        let marker = if is_selected { "> " } else { "  " };
8981        let path_style = if is_selected {
8982            Style::default().fg(primary).add_modifier(Modifier::BOLD)
8983        } else {
8984            Style::default().fg(fg)
8985        };
8986        let line = Line::from(vec![
8987            Span::styled(marker, path_style),
8988            Span::styled(&result.path, path_style),
8989        ]);
8990        frame.render_widget(Paragraph::new(line), row_area);
8991    }
8992
8993    // Footer hint: show result count + key bindings.
8994    if popup_h >= 4 {
8995        let hint_y = inner.bottom().saturating_sub(1);
8996        let hint_area = Rect {
8997            x: inner.left(),
8998            y: hint_y,
8999            width: inner.width,
9000            height: 1,
9001        };
9002        let count = items.len();
9003        let hint = format!("{count} files | Tab accept | Esc cancel");
9004        let _ = secondary; // suppress unused warning
9005        frame.render_widget(
9006            Paragraph::new(Line::from(Span::styled(
9007                hint,
9008                Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
9009            )))
9010            .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
9011            hint_area,
9012        );
9013    }
9014}
9015
9016// ─────────────────────────────────────────────────────────────────────────
9017// Vim mode — Editor adapter for the input buffer
9018// ─────────────────────────────────────────────────────────────────────────
9019
9020/// Adapter that lets the vim engine operate on the composer's [`TextArea`].
9021///
9022/// The host TUI keeps a single [`TextArea`](oxicode_textarea::TextArea)
9023/// (the composer) as the source of truth for editable text; the vim engine
9024/// still wants a `&str` + byte-cursor handle. This adapter forwards each
9025/// trait call to the textarea so cursor math, grapheme boundaries, and
9026/// undo history are owned by the textarea.
9027struct InputEditor<'a> {
9028    composer: &'a mut oxicode_textarea::TextArea,
9029}
9030
9031impl<'a> InputEditor<'a> {
9032    fn new(composer: &'a mut oxicode_textarea::TextArea) -> Self {
9033        Self { composer }
9034    }
9035}
9036
9037impl<'a> crate::tui_vt::vim::Editor for InputEditor<'a> {
9038    fn content(&self) -> &str {
9039        self.composer.text()
9040    }
9041    fn cursor(&self) -> usize {
9042        self.composer.cursor()
9043    }
9044    fn set_cursor(&mut self, pos: usize) {
9045        self.composer.set_cursor(pos);
9046    }
9047    fn move_left(&mut self) {
9048        // The textarea's `set_cursor` clamps to the nearest grapheme
9049        // boundary, so we just step back one byte and let it clean up.
9050        let new_pos = self.composer.cursor().saturating_sub(1);
9051        self.composer.set_cursor(new_pos);
9052    }
9053    fn move_right(&mut self) {
9054        let new_pos = self.composer.cursor().saturating_add(1);
9055        self.composer.set_cursor(new_pos);
9056    }
9057    fn delete_char_forward(&mut self) {
9058        self.composer.input(crossterm::event::KeyEvent::new(
9059            crossterm::event::KeyCode::Delete,
9060            crossterm::event::KeyModifiers::NONE,
9061        ));
9062    }
9063    fn insert_text(&mut self, text: &str) {
9064        self.composer.insert_str(text);
9065    }
9066    fn replace(&mut self, content: String, cursor: usize) {
9067        self.composer.set_text(&content);
9068        self.composer.set_cursor(cursor);
9069    }
9070    fn replace_range(&mut self, start: usize, end: usize, text: &str) {
9071        self.composer.replace_range(start..end, text);
9072    }
9073}
9074
9075// ─────────────────────────────────────────────────────────────────────────
9076// Small helpers
9077// ─────────────────────────────────────────────────────────────────────────
9078
9079pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
9080    InlineSegment {
9081        text: text.into(),
9082        style: Arc::new(InlineTextStyle::default()),
9083    }
9084}
9085
9086pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
9087    if offset == usize::MAX {
9088        return total.saturating_sub(viewport);
9089    }
9090    // Clamp into [0, total.saturating_sub(viewport)].
9091    let max_start = total.saturating_sub(viewport);
9092    offset.min(max_start)
9093}
9094
9095// Slash-command autocomplete popup
9096// ─────────────────────────────────────────────────────────────────────────
9097/// Filter slash commands by `token` (the text after `/`). An empty token
9098/// returns every command. Matching is prefix-based against the canonical
9099/// name and all aliases.
9100///
9101/// Built-in commands are listed first; user-defined file commands are
9102/// appended afterwards. Any file command whose name shadows a built-in is
9103/// dropped — built-ins always win, so file commands cannot redefine
9104/// `/quit`, `/clear`, etc.
9105fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
9106    let builtins = SlashRegistry::builtin_commands();
9107    let builtin_names: std::collections::HashSet<&str> =
9108        builtins.iter().map(|(n, _, _)| *n).collect();
9109
9110    let mut items: Vec<SlashPopupItem> = builtins
9111        .into_iter()
9112        .filter(|(name, _, aliases)| {
9113            token.is_empty()
9114                || name.starts_with(token)
9115                || aliases.iter().any(|a| a.starts_with(token))
9116        })
9117        .map(|(name, desc, aliases)| {
9118            let mut label = format!("/{name}");
9119            for a in &aliases {
9120                label.push_str(&format!(", /{a}"));
9121            }
9122            SlashPopupItem {
9123                label,
9124                description: desc.to_string(),
9125                name: name.to_string(),
9126            }
9127        })
9128        .collect();
9129
9130    // Append file commands (skip names shadowed by builtins).
9131    for fc in file_commands {
9132        if builtin_names.contains(fc.name.as_str())
9133            || fc
9134                .aliases
9135                .iter()
9136                .any(|alias| builtin_names.contains(alias.as_str()))
9137        {
9138            continue;
9139        }
9140        if token.is_empty()
9141            || fc.name.starts_with(token)
9142            || fc.aliases.iter().any(|a| a.starts_with(token))
9143        {
9144            let mut label = format!("/{}", fc.name);
9145            for a in &fc.aliases {
9146                label.push_str(&format!(", /{a}"));
9147            }
9148            items.push(SlashPopupItem {
9149                label,
9150                description: fc.description.clone(),
9151                name: fc.name.clone(),
9152            });
9153        }
9154    }
9155
9156    items
9157}
9158
9159/// Recompute the slash popup from the current input buffer. The popup is
9160/// active when the buffer starts with `/` and has no space yet (the user is
9161/// still composing the command token, not its arguments). Called after every
9162/// buffer mutation in the input thread.
9163fn refresh_slash_popup(state: &mut RenderState) {
9164    let buf = state.composer.text();
9165    let active = buf.starts_with('/') && !buf[1..].contains(' ');
9166    if !active {
9167        state.slash_popup.open = false;
9168        state.slash_popup.items.clear();
9169        state.slash_popup.selected = 0;
9170        return;
9171    }
9172    let token = &buf[1..];
9173    let items = slash_filter(token, &state.file_commands);
9174    state.slash_popup.open = !items.is_empty();
9175    if items.is_empty() {
9176        state.slash_popup.selected = 0;
9177    } else {
9178        state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
9179    }
9180    state.slash_popup.items = items;
9181}
9182/// Combined popup refresher — calls both the slash-command popup and the
9183/// @-file-search picker. Called after every input buffer mutation in the
9184/// input thread so both popups stay in sync with the cursor position.
9185fn refresh_input_popups(state: &mut RenderState) {
9186    refresh_slash_popup(state);
9187    refresh_file_search(state);
9188}
9189
9190/// Recompute the @-file-search dropdown from the current input buffer.
9191/// Called after every buffer mutation in the input thread. The filesystem
9192/// walk (building the index) happens only on the `None → Some` transition
9193/// (when `@` is first typed); subsequent keystrokes just re-filter the
9194/// cached index via [`FileSearchState::refresh`](crate::tui_vt::file_search::FileSearchState::refresh).
9195fn refresh_file_search(state: &mut RenderState) {
9196    use crate::tui_vt::file_search;
9197    // Never open the file picker while a slash command is being composed.
9198    if state.slash_popup.open {
9199        state.file_search = None;
9200        return;
9201    }
9202    match file_search::parse_at_cursor(state.composer.text(), state.composer.cursor()) {
9203        Some(token) => match &mut state.file_search {
9204            None => {
9205                let cwd = state.cwd.clone();
9206                state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
9207            }
9208            Some(fs) => {
9209                if fs.query != token.path_query {
9210                    fs.refresh(&token.path_query);
9211                }
9212            }
9213        },
9214        None => state.file_search = None,
9215    }
9216}
9217
9218/// Accept the currently-selected file-search result: replace the `@query`
9219/// token in the buffer with the canonical `@path ` (or `@path:N-M ` in
9220/// line mode), advance the cursor past it, and close the picker.
9221/// Returns `true` if a result was accepted.
9222fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
9223    use crate::tui_vt::file_search;
9224    let Some(fs) = &state.file_search else {
9225        return false;
9226    };
9227    let Some(result) = fs.selected_result().cloned() else {
9228        return false;
9229    };
9230    let at_offset = fs.at_offset;
9231    let text = file_search::insertion_text(&result.path, None, line_mode);
9232    let cursor_end = state.composer.cursor();
9233    // Replace everything from `@` to the current cursor with the insertion.
9234    state.composer.replace_range(
9235        at_offset..cursor_end.min(state.composer.text().len()),
9236        &text,
9237    );
9238    state.composer.set_cursor(at_offset + text.len());
9239    state.file_search = None;
9240    true
9241}
9242
9243fn preview_tool_result(content: &str) -> String {
9244    const MAX: usize = 500;
9245    if content.chars().count() <= MAX {
9246        return content.to_string();
9247    }
9248    let truncated: String = content.chars().take(MAX).collect();
9249    format!("{truncated}\u{2026}")
9250}
9251
9252/// Extract the first embedded PNG from a `generate_image` tool result.
9253///
9254/// The tool's output embeds images as
9255/// `Image N (<bytes> bytes, base64):\n<base64>`. Returns the decoded
9256/// bytes of the first image, or `None` when no marker/base64 payload is
9257/// present or the payload does not decode.
9258fn extract_generated_png(content: &str) -> Option<Vec<u8>> {
9259    use base64::{Engine, engine::general_purpose};
9260    const MARKER: &str = "base64):";
9261    let rest = &content[content.find(MARKER)? + MARKER.len()..];
9262    // The base64 blob is the first non-empty line after the marker.
9263    let blob = rest.lines().map(str::trim).find(|l| !l.is_empty())?;
9264    if blob.is_empty() {
9265        return None;
9266    }
9267    let bytes = general_purpose::STANDARD.decode(blob).ok()?;
9268    // Sanity floor: a real PNG header is 8 bytes. Shorter payloads are
9269    // parse noise, not an image.
9270    (bytes.len() >= 8).then_some(bytes)
9271}
9272
9273fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
9274    match color {
9275        Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
9276        Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
9277        Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
9278        None => Color::Reset,
9279    }
9280}
9281fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
9282    use anstyle::AnsiColor as A;
9283    match color {
9284        A::Black => Color::Black,
9285        A::Red => Color::Red,
9286        A::Green => Color::Green,
9287        A::Yellow => Color::Yellow,
9288        A::Blue => Color::Blue,
9289        A::Magenta => Color::Magenta,
9290        A::Cyan => Color::Cyan,
9291        A::White => Color::Gray,
9292        A::BrightBlack => Color::DarkGray,
9293        A::BrightRed => Color::LightRed,
9294        A::BrightGreen => Color::LightGreen,
9295        A::BrightYellow => Color::LightYellow,
9296        A::BrightBlue => Color::LightBlue,
9297        A::BrightMagenta => Color::LightMagenta,
9298        A::BrightCyan => Color::LightCyan,
9299        A::BrightWhite => Color::White,
9300    }
9301}
9302
9303// Suppress the unused-import warning while keeping the AtomicBool/Ordering
9304// available for future control flags (e.g. SIGINT safety net).
9305#[allow(dead_code, clippy::declare_interior_mutable_const)]
9306const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
9307
9308#[cfg(test)]
9309mod slash_popup_tests {
9310    use super::*;
9311
9312    #[test]
9313    fn empty_token_lists_all_commands() {
9314        let items = slash_filter("", &[]);
9315        // 7 built-in commands.
9316        assert!(items.len() >= 7);
9317        assert!(items.iter().any(|i| i.name == "quit"));
9318        assert!(items.iter().any(|i| i.name == "clear"));
9319        assert!(items.iter().any(|i| i.name == "model"));
9320    }
9321
9322    #[test]
9323    fn prefix_filter_matches_name() {
9324        let items = slash_filter("qu", &[]);
9325        assert_eq!(items.len(), 1);
9326        assert_eq!(items[0].name, "quit");
9327        assert!(items[0].label.contains("/quit"));
9328    }
9329
9330    #[test]
9331    fn prefix_filter_matches_alias() {
9332        // "cl" should match "clear" (alias "cls") and "compact".
9333        let items = slash_filter("cl", &[]);
9334        let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
9335        assert!(names.contains(&"clear"));
9336    }
9337
9338    #[test]
9339    fn file_commands_appear_in_filter() {
9340        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9341            "review",
9342            "---\ndescription: proj cmd\naliases: cr\n---\nbody",
9343        );
9344        let items = slash_filter("", &[fc]);
9345        assert!(items.iter().any(|i| i.name == "review"));
9346        assert!(items.iter().any(|i| i.name == "quit")); // builtins still present
9347    }
9348
9349    #[test]
9350    fn file_commands_filtered_by_prefix() {
9351        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9352            "review",
9353            "---\ndescription: x\n---\nbody",
9354        );
9355        let items = slash_filter("rev", &[fc]);
9356        assert!(items.iter().any(|i| i.name == "review"));
9357    }
9358
9359    #[test]
9360    fn file_commands_shadowed_by_builtins_are_dropped() {
9361        // A file command whose name collides with a built-in must be dropped —
9362        // built-ins always win. Without this guarantee the popup could surface
9363        // two items for the same prefix and the dispatch layer would pick the
9364        // wrong one.
9365        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9366            "quit",
9367            "---\ndescription: hijack\n---\nbody",
9368        );
9369        let items = slash_filter("", &[fc]);
9370        let quit_count = items.iter().filter(|i| i.name == "quit").count();
9371        assert_eq!(quit_count, 1, "shadowed file command must not appear");
9372        // And it must be the built-in description, not the file one.
9373        assert!(
9374            items
9375                .iter()
9376                .any(|i| i.name == "quit" && !i.description.contains("hijack"))
9377        );
9378    }
9379
9380    #[test]
9381    fn file_commands_with_builtin_aliases_are_dropped() {
9382        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9383            "review",
9384            "---\ndescription: hijack\naliases: quit\n---\nbody",
9385        );
9386        let items = slash_filter("", &[fc]);
9387        assert!(!items.iter().any(|item| item.name == "review"));
9388    }
9389
9390    #[test]
9391    fn popup_opens_on_slash() {
9392        let mut state = RenderState::default();
9393        state.composer.set_text("/");
9394        refresh_input_popups(&mut state);
9395        assert!(state.slash_popup.open);
9396        assert!(!state.slash_popup.items.is_empty());
9397    }
9398
9399    #[test]
9400    fn popup_closes_on_space() {
9401        let mut state = RenderState::default();
9402        state.composer.set_text("/quit ");
9403        refresh_input_popups(&mut state);
9404        assert!(!state.slash_popup.open);
9405    }
9406
9407    #[test]
9408    fn popup_closes_on_non_slash() {
9409        let mut state = RenderState::default();
9410        state.composer.set_text("hello");
9411        refresh_input_popups(&mut state);
9412        assert!(!state.slash_popup.open);
9413    }
9414
9415    #[test]
9416    fn popup_filters_as_user_types() {
9417        let mut state = RenderState::default();
9418        state.composer.set_text("/m");
9419        refresh_input_popups(&mut state);
9420        assert!(state.slash_popup.open);
9421        // Every item's canonical name must start with 'm' (model is the
9422        // only command matching the "m" prefix).
9423        assert!(
9424            state
9425                .slash_popup
9426                .items
9427                .iter()
9428                .all(|i| i.name.starts_with('m'))
9429        );
9430    }
9431
9432    #[test]
9433    fn popup_selection_clamps_on_shrink() {
9434        let mut state = RenderState::default();
9435        state.composer.set_text("/");
9436        refresh_input_popups(&mut state);
9437        let full_count = state.slash_popup.items.len();
9438        state.slash_popup.selected = full_count - 1;
9439        // Narrow the filter so fewer items remain.
9440        state.composer.set_text("/qu");
9441        refresh_input_popups(&mut state);
9442        assert!(state.slash_popup.selected < state.slash_popup.items.len());
9443    }
9444}
9445
9446#[cfg(test)]
9447mod keymap_dispatch_tests {
9448    use super::*;
9449    use crate::tui_vt::keymap::Keymap;
9450    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9451
9452    fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
9453        KeyEvent::new(code, mods)
9454    }
9455
9456    /// Build a keymap from (action-name, combo-string) override pairs —
9457    /// the unified replacement for the branch's keybindings.yml overlay
9458    /// (settings-backed overrides go through the same
9459    /// `Keymap::from_settings` path as the real settings editor).
9460    fn keymap_with(overrides: &[(&str, &str)]) -> Keymap {
9461        let mut o = std::collections::HashMap::new();
9462        for (action, combo) in overrides {
9463            o.insert(action.to_string(), vec![combo.to_string()]);
9464        }
9465        Keymap::from_settings(&o)
9466    }
9467
9468    fn default_keymap() -> Keymap {
9469        Keymap::from_settings(&std::collections::HashMap::new())
9470    }
9471
9472    #[test]
9473    fn rebound_submit_fires_through_generic_dispatch() {
9474        // Final-review finding 6: `submit: alt+s` must actually fire.
9475        // Previously Submit was consulted only inside the hardcoded
9476        // Enter arm, so a rebind could only disable submit at Enter,
9477        // never move it to another key.
9478        let km = keymap_with(&[("Submit", "Alt+s")]);
9479        let alt_s = press(KeyCode::Char('s'), KeyModifiers::ALT);
9480        // Multiline is irrelevant for the rebind: the carve-out only
9481        // protects plain Enter.
9482        assert_eq!(keymap_pre_match(&km, &alt_s, false), KeymapDispatch::Submit);
9483        assert_eq!(keymap_pre_match(&km, &alt_s, true), KeymapDispatch::Submit);
9484        // Enter no longer submits (replaced wholesale) — and in
9485        // multiline it still falls through for the newline insert.
9486        let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9487        assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::None);
9488        assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9489    }
9490
9491    #[test]
9492    fn default_submit_dispatch_preserves_muscle_memory() {
9493        let km = default_keymap();
9494        let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9495        let shift_enter = press(KeyCode::Enter, KeyModifiers::SHIFT);
9496        // Non-multiline: plain Enter submits.
9497        assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::Submit);
9498        // Multiline: plain Enter falls through (the Enter arm inserts
9499        // a newline), Shift+Enter submits.
9500        assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9501        assert_eq!(
9502            keymap_pre_match(&km, &shift_enter, true),
9503            KeymapDispatch::Submit
9504        );
9505    }
9506
9507    #[test]
9508    fn scroll_and_help_dispatch_via_keymap() {
9509        let km = default_keymap();
9510        assert_eq!(
9511            keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::NONE), false),
9512            KeymapDispatch::ScrollPageUp
9513        );
9514        assert_eq!(
9515            keymap_pre_match(&km, &press(KeyCode::PageDown, KeyModifiers::NONE), false),
9516            KeymapDispatch::ScrollPageDown
9517        );
9518        let km = keymap_with(&[("ScrollUp", "Ctrl+u")]);
9519        assert_eq!(
9520            keymap_pre_match(
9521                &km,
9522                &press(KeyCode::Char('u'), KeyModifiers::CONTROL),
9523                false
9524            ),
9525            KeymapDispatch::ScrollPageUp
9526        );
9527        // Printable Help bindings stay with the Char arm (empty-
9528        // composer gate); non-printable ones dispatch here.
9529        let km = default_keymap();
9530        assert_eq!(
9531            keymap_pre_match(&km, &press(KeyCode::Char('?'), KeyModifiers::NONE), false),
9532            KeymapDispatch::None
9533        );
9534        let km = keymap_with(&[("Help", "Ctrl+PageUp")]);
9535        assert_eq!(
9536            keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::CONTROL), false),
9537            KeymapDispatch::Help
9538        );
9539        // Everything else falls through.
9540        assert_eq!(
9541            keymap_pre_match(&km, &press(KeyCode::Char('x'), KeyModifiers::NONE), false),
9542            KeymapDispatch::None
9543        );
9544    }
9545}
9546
9547#[cfg(test)]
9548mod render_tests {
9549    use super::*;
9550    use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
9551    use ratatui::{Terminal, backend::TestBackend};
9552    use tokio::sync::mpsc;
9553
9554    /// Render `render_frame` into a TestBackend and return the concatenated
9555    /// cell text. This catches regressions like a missing render_composer
9556    /// call — `#![allow(dead_code)]` in lib.rs suppresses the unused-fn lint,
9557    /// so only a render assertion can prove the composer is painted.
9558    fn render_frame_to_string(state: &RenderState) -> String {
9559        let backend = TestBackend::new(80, 24);
9560        let mut terminal = Terminal::new(backend).expect("backend");
9561        let (tx, _rx) = mpsc::unbounded_channel();
9562        let handle = InlineHandle::new_for_tests(tx);
9563        terminal
9564            .draw(|f| render_frame(f, state, &handle))
9565            .expect("draw");
9566        let buf = terminal.backend().buffer();
9567        let area = buf.area();
9568        let mut out = String::new();
9569        for y in 0..area.height {
9570            for x in 0..area.width {
9571                if let Some(cell) = buf.cell((x, y)) {
9572                    out.push_str(cell.symbol());
9573                }
9574            }
9575            out.push('\n');
9576        }
9577        out
9578    }
9579
9580    /// Render the full frame at the requested size. Mirrors
9581    /// `render_frame_to_string` but at the documented width so PTY-style
9582    /// snapshot tests can assert on a representative viewport.
9583    #[allow(dead_code)]
9584    fn render_frame_to_string_at(state: &RenderState, width: u16, height: u16) -> String {
9585        let backend = TestBackend::new(width, height);
9586        let mut terminal = Terminal::new(backend).expect("backend");
9587        let (tx, _rx) = mpsc::unbounded_channel();
9588        let handle = InlineHandle::new_for_tests(tx);
9589        terminal
9590            .draw(|f| render_frame(f, state, &handle))
9591            .expect("draw");
9592        let buf = terminal.backend().buffer();
9593        let area = buf.area();
9594        let mut out = String::new();
9595        for y in 0..area.height {
9596            for x in 0..area.width {
9597                if let Some(cell) = buf.cell((x, y)) {
9598                    out.push_str(cell.symbol());
9599                }
9600            }
9601            out.push('\n');
9602        }
9603        out
9604    }
9605
9606    /// Diagnostic helper: render the full frame and return the terminal
9607    /// caret position (where render_composer set it).
9608    fn terminal_caret(state: &RenderState) -> Option<(u16, u16)> {
9609        let backend = TestBackend::new(80, 24);
9610        let mut terminal = Terminal::new(backend).expect("backend");
9611        let (tx, _rx) = mpsc::unbounded_channel();
9612        let handle = InlineHandle::new_for_tests(tx);
9613        terminal
9614            .draw(|f| render_frame(f, state, &handle))
9615            .expect("draw");
9616        terminal
9617            .get_cursor_position()
9618            .ok()
9619            .map(|position| (position.x, position.y))
9620    }
9621
9622    #[test]
9623    fn composer_caret_aligns_after_ascii() {
9624        let mut state = RenderState::default();
9625        state.prompt_prefix = "> ".to_string();
9626        state.input_enabled = true;
9627        let mut composer = oxicode_textarea::TextArea::new();
9628        composer.set_text("hello");
9629        composer.set_cursor(5);
9630        state.composer = composer;
9631        let caret = terminal_caret(&state);
9632        // The dense chat layout leaves a 1-column side gutter and no outer
9633        // vertical padding: prompt = Rect{x:1,y:21,w:78,h:3}; inner starts at
9634        // (2, 21), and the 2-column prefix puts the body at x=4.
9635        assert_eq!(
9636            caret,
9637            Some((9, 22)),
9638            "ASCII caret must sit right after '> hello'"
9639        );
9640    }
9641
9642    #[test]
9643    fn composer_caret_aligns_after_cjk_display_columns() {
9644        let mut state = RenderState::default();
9645        state.prompt_prefix = "> ".to_string();
9646        state.input_enabled = true;
9647        let body = "안녕";
9648        let mut composer = oxicode_textarea::TextArea::new();
9649        composer.set_text(body);
9650        composer.set_cursor(body.len()); // 6 bytes (end), 4 display cols
9651        state.composer = composer;
9652        let caret = terminal_caret(&state);
9653        // textarea_area.x = 4, col = 4 -> (4 + 4, 21) = (8, 21).
9654        assert_eq!(
9655            caret,
9656            Some((8, 22)),
9657            "CJK caret must sit after 4 display columns (not 6 bytes)"
9658        );
9659    }
9660
9661    #[test]
9662    fn composer_caret_aligns_after_mixed_ascii_cjk() {
9663        let body = "hi안녕";
9664        let mut state = RenderState::default();
9665        state.prompt_prefix = "> ".to_string();
9666        state.input_enabled = true;
9667        let mut composer = oxicode_textarea::TextArea::new();
9668        composer.set_text(body);
9669        composer.set_cursor(body.len()); // 8 bytes, 6 display cols
9670        state.composer = composer;
9671        let caret = terminal_caret(&state);
9672        // textarea_area.x = 4, col = 6 -> (4 + 6, 21) = (10, 21).
9673        assert_eq!(
9674            caret,
9675            Some((10, 22)),
9676            "Mixed caret must sit after 6 display columns"
9677        );
9678    }
9679
9680    #[test]
9681    fn agent_session_event_reaches_the_transcript_bridge() {
9682        let (tx, mut rx) = mpsc::unbounded_channel();
9683        let handle = InlineHandle::new_for_tests(tx);
9684        let mut state = RenderState::default();
9685
9686        handle_session_event(
9687            &mut state,
9688            &handle,
9689            &SessionEvent::Agent(Box::new(AgentEvent::TextChunk {
9690                text: "streamed reply".to_string(),
9691            })),
9692            None,
9693        );
9694
9695        let command = rx
9696            .try_recv()
9697            .expect("an agent event must produce a render command");
9698        apply_command(&mut state, command);
9699        assert_eq!(state.transcript.len(), 1);
9700        assert_eq!(state.transcript[0].kind, InlineMessageKind::Agent);
9701        assert_eq!(state.transcript[0].segments[0].text, "streamed reply");
9702    }
9703
9704    #[test]
9705    fn missing_key_errors_are_distinguished_from_other_provider_failures() {
9706        assert!(is_missing_api_key_error(
9707            "Provider stream error: Missing API key — configure a credential"
9708        ));
9709        assert!(!is_missing_api_key_error("Provider returned HTTP 429"));
9710        assert_eq!(
9711            provider_from_model_id("deepseek/deepseek-v4-flash"),
9712            "deepseek"
9713        );
9714    }
9715
9716    #[test]
9717    fn prompt_queue_mutations_change_the_execution_queue() {
9718        let queue = PromptQueue::default();
9719        queue.enqueue("first".to_string());
9720        queue.enqueue("second".to_string());
9721        queue.enqueue("third".to_string());
9722
9723        assert!(queue.move_by(2, -1));
9724        assert_eq!(queue.remove(0).as_deref(), Some("first"));
9725        let pending: Vec<_> = queue.pending.lock().iter().cloned().collect();
9726        assert_eq!(pending, ["third", "second"]);
9727    }
9728
9729    #[test]
9730    fn welcome_screen_shown_when_transcript_empty() {
9731        let state = RenderState::default();
9732        let rendered = render_frame_to_string(&state);
9733        assert!(
9734            rendered.contains("OXICODE") && rendered.contains("WORKSPACE"),
9735            "welcome banner must appear when transcript is empty"
9736        );
9737    }
9738
9739    #[test]
9740    fn composer_is_painted() {
9741        // Regression guard: the composer prompt prefix must appear in the
9742        // rendered output. This would have caught the missing
9743        // render_composer call (advisory 2026-08-04).
9744        let mut state = RenderState::default();
9745        state.input_enabled = true;
9746        state.prompt_prefix = "> ".to_string();
9747        let rendered = render_frame_to_string(&state);
9748        assert!(
9749            rendered.contains('>'),
9750            "composer prompt prefix must be painted"
9751        );
9752    }
9753
9754    #[test]
9755    fn slash_popup_renders_command_list() {
9756        let mut state = RenderState::default();
9757        state.slash_popup.open = true;
9758        state.slash_popup.items = slash_filter("", &[]);
9759        let rendered = render_frame_to_string(&state);
9760        assert!(rendered.contains("COMMANDS"), "popup title must render");
9761        assert!(rendered.contains("/quit"), "popup must list /quit");
9762    }
9763
9764    #[test]
9765    fn composer_and_popup_render_together() {
9766        let mut state = RenderState::default();
9767        state.prompt_prefix = "> ".to_string();
9768        state.composer.set_text("/qu");
9769        state.slash_popup.open = true;
9770        state.slash_popup.items = slash_filter("qu", &[]);
9771        let rendered = render_frame_to_string(&state);
9772        assert!(rendered.contains("COMMANDS"), "popup must render");
9773        assert!(rendered.contains("/quit"), "popup must list /quit");
9774        assert!(rendered.contains('>'), "composer must still render");
9775    }
9776
9777    #[test]
9778    fn transcript_wraps_long_lines() {
9779        // Write-path width invariant (Task 2 / omp tui-core-renderer.md §4):
9780        // the transcript MUST never paint past the content width — even if
9781        // an agent response would naturally wrap to several rows, we hard-clip
9782        // to the viewport width so a malformed table can never overflow a
9783        // narrow terminal. The visible row stays at exactly the content width
9784        // and content past that column is dropped at the boundary (never
9785        // wrapped into a second visual row).
9786        let mut state = RenderState::default();
9787        state.transcript.push(TranscriptLine {
9788            kind: InlineMessageKind::Agent,
9789            segments: vec![plain_segment(
9790                "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
9791            )],
9792            block_id: 0,
9793        });
9794        let backend = TestBackend::new(40, 24);
9795        let mut terminal = Terminal::new(backend).expect("backend");
9796        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
9797        let handle = InlineHandle::new_for_tests(tx);
9798        terminal
9799            .draw(|f| render_frame(f, &state, &handle))
9800            .expect("draw");
9801        let buf = terminal.backend().buffer();
9802        // Walk every cell: the transcript row never paints past the content
9803        // width (no cell beyond col 40 should carry the clipped text).
9804        let mut full = String::new();
9805        for y in 0..buf.area.height {
9806            for x in 0..buf.area.width {
9807                if let Some(cell) = buf.cell((x, y)) {
9808                    full.push_str(cell.symbol());
9809                }
9810            }
9811            full.push('\n');
9812        }
9813        assert!(
9814            !full.contains("wrap"),
9815            "long line is clamped at the viewport edge — content past col 40 must be dropped, not wrapped"
9816        );
9817        // The truncated prefix is still visible: the leading word "This" lands
9818        // at the top-left of the transcript.
9819        assert!(
9820            full.contains("This"),
9821            "the truncated prefix of the clamped line is visible: {full:?}"
9822        );
9823    }
9824
9825    // ─── overlay tests ────────────────────────────────────────────────────
9826
9827    fn sample_overlay_items() -> Vec<OverlayListItem> {
9828        vec![
9829            OverlayListItem {
9830                title: "model-a".to_string(),
9831                subtitle: Some("first".to_string()),
9832                badge: Some("ready".to_string()),
9833                indent: 0,
9834                search_value: None,
9835                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
9836            },
9837            OverlayListItem {
9838                title: "model-b".to_string(),
9839                subtitle: None,
9840                badge: None,
9841                indent: 0,
9842                search_value: None,
9843                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
9844            },
9845            OverlayListItem {
9846                title: "model-c".to_string(),
9847                subtitle: None,
9848                badge: None,
9849                indent: 0,
9850                search_value: None,
9851                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
9852            },
9853        ]
9854    }
9855
9856    #[test]
9857    fn overlay_renders_title_and_items() {
9858        let mut state = RenderState::default();
9859        state.overlay = Some(OverlayState {
9860            title: "Select model".to_string(),
9861            lines: vec!["Pick one".to_string()],
9862            items: sample_overlay_items(),
9863            selected: 0,
9864            search: None,
9865            secure_input: None,
9866            ..Default::default()
9867        });
9868        let rendered = render_frame_to_string(&state);
9869        assert!(
9870            rendered.contains("Select model"),
9871            "overlay title must render"
9872        );
9873        assert!(rendered.contains("model-a"), "first item must render");
9874        assert!(rendered.contains("model-b"), "second item must render");
9875        assert!(rendered.contains("model-c"), "third item must render");
9876        assert!(
9877            rendered.contains("Pick one"),
9878            "descriptive line must render"
9879        );
9880    }
9881
9882    #[test]
9883    fn overlay_search_filters_items() {
9884        let mut state = RenderState::default();
9885        state.overlay = Some(OverlayState {
9886            title: "Select".to_string(),
9887            lines: Vec::new(),
9888            items: sample_overlay_items(),
9889            selected: 0,
9890            search: Some(OverlaySearchState {
9891                label: "filter".to_string(),
9892                placeholder: Some("type".to_string()),
9893                value: "model-b".to_string(),
9894            }),
9895            secure_input: None,
9896            ..Default::default()
9897        });
9898        let rendered = render_frame_to_string(&state);
9899        assert!(rendered.contains("model-b"), "matching item must render");
9900        assert!(
9901            !rendered.contains("model-a"),
9902            "non-matching item must not render (got: {})",
9903            rendered
9904        );
9905        assert!(
9906            !rendered.contains("model-c"),
9907            "non-matching item must not render"
9908        );
9909    }
9910
9911    #[test]
9912    fn overlay_keyboard_nav_moves_selection() {
9913        let mut state = RenderState::default();
9914        state.overlay = Some(OverlayState {
9915            title: "Select".to_string(),
9916            lines: Vec::new(),
9917            items: sample_overlay_items(),
9918            selected: 0,
9919            search: None,
9920            secure_input: None,
9921            ..Default::default()
9922        });
9923        let state_arc = Arc::new(parking_lot::Mutex::new(state));
9924        let (tx, mut _rx) = mpsc::unbounded_channel();
9925
9926        // Initial: index 0 selected.
9927        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9928
9929        // Down: index 1 selected.
9930        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9931        assert!(consumed, "Down must be consumed while overlay is open");
9932        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
9933
9934        // Down: index 2 selected.
9935        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9936        assert!(consumed);
9937        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9938
9939        // Down: wraps to index 0.
9940        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9941        assert!(consumed);
9942        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9943
9944        // Up: wraps to last (index 2).
9945        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
9946        assert!(consumed);
9947        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9948
9949        // Enter: closes overlay and emits a Submission event.
9950        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
9951        assert!(consumed);
9952        assert!(
9953            state_arc.lock().overlay.is_none(),
9954            "overlay must be cleared after Enter"
9955        );
9956        let evt = _rx.try_recv().expect("submit event must arrive");
9957        match evt {
9958            InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
9959            other => panic!("expected Submitted overlay event, got {other:?}"),
9960        }
9961    }
9962
9963    #[test]
9964    fn overlay_esc_closes_and_emits_cancelled() {
9965        let mut state = RenderState::default();
9966        state.overlay = Some(OverlayState {
9967            title: "Select".to_string(),
9968            lines: Vec::new(),
9969            items: sample_overlay_items(),
9970            selected: 0,
9971            search: None,
9972            secure_input: None,
9973            ..Default::default()
9974        });
9975        let state_arc = Arc::new(parking_lot::Mutex::new(state));
9976        let (tx, mut rx) = mpsc::unbounded_channel();
9977
9978        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
9979        assert!(consumed);
9980        assert!(
9981            state_arc.lock().overlay.is_none(),
9982            "overlay must be cleared after Esc"
9983        );
9984        let evt = rx.try_recv().expect("cancel event must arrive");
9985        assert!(
9986            matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
9987            "expected Cancelled overlay event"
9988        );
9989    }
9990
9991    #[test]
9992    fn overlay_enter_on_readonly_item_is_noop() {
9993        // A read-only item (selection: None — /tools, /mcp, the /settings
9994        // Model row) must NOT submit a synthetic selection or pollute the
9995        // prompt with "/overlay:N". Enter is a no-op: overlay stays open.
9996        let mut state = RenderState::default();
9997        state.overlay = Some(OverlayState {
9998            title: "Tools".to_string(),
9999            lines: Vec::new(),
10000            items: vec![OverlayListItem {
10001                title: "read".to_string(),
10002                subtitle: Some("Read a file".to_string()),
10003                badge: None,
10004                indent: 0,
10005                search_value: None,
10006                selection: None,
10007            }],
10008            selected: 0,
10009            search: None,
10010            secure_input: None,
10011            ..Default::default()
10012        });
10013        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10014        let (tx, mut rx) = mpsc::unbounded_channel();
10015
10016        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10017        assert!(consumed, "Enter must be consumed even on read-only items");
10018        assert!(
10019            state_arc.lock().overlay.is_some(),
10020            "overlay must stay open when Enter hits a read-only item"
10021        );
10022        assert!(
10023            rx.try_recv().is_err(),
10024            "no overlay event must be emitted for a read-only Enter"
10025        );
10026    }
10027
10028    #[test]
10029    fn overlay_chars_route_to_search_field() {
10030        let mut state = RenderState::default();
10031        state.overlay = Some(OverlayState {
10032            title: "Select".to_string(),
10033            lines: Vec::new(),
10034            items: sample_overlay_items(),
10035            selected: 0,
10036            search: Some(OverlaySearchState {
10037                label: "filter".to_string(),
10038                placeholder: None,
10039                value: String::new(),
10040            }),
10041            secure_input: None,
10042            ..Default::default()
10043        });
10044        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10045        let (tx, _rx) = mpsc::unbounded_channel();
10046
10047        handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
10048        handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
10049        handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
10050        let value = state_arc
10051            .lock()
10052            .overlay
10053            .as_ref()
10054            .unwrap()
10055            .search
10056            .as_ref()
10057            .unwrap()
10058            .value
10059            .clone();
10060        assert_eq!(value, "m", "Backspace should drop last char");
10061    }
10062
10063    #[test]
10064    fn overlay_key_no_op_when_no_overlay_open() {
10065        let state = RenderState::default();
10066        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10067        let (tx, _rx) = mpsc::unbounded_channel();
10068        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10069        assert!(
10070            !consumed,
10071            "handle_overlay_key must return false when no overlay is open"
10072        );
10073    }
10074
10075    #[test]
10076    fn apply_command_show_overlay_populates_state() {
10077        use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
10078        let mut state = RenderState::default();
10079        let items = vec![
10080            InlineListItem {
10081                title: "alpha".to_string(),
10082                subtitle: None,
10083                badge: None,
10084                indent: 0,
10085                selection: None,
10086                search_value: None,
10087            },
10088            InlineListItem {
10089                title: "beta".to_string(),
10090                subtitle: None,
10091                badge: None,
10092                indent: 0,
10093                selection: None,
10094                search_value: None,
10095            },
10096        ];
10097        let request = OverlayRequest::List(ListOverlayRequest {
10098            title: "Pick".to_string(),
10099            lines: vec!["desc".to_string()],
10100            footer_hint: None,
10101            items,
10102            selected: None,
10103            search: None,
10104            hotkeys: Vec::new(),
10105        });
10106        let shutdown = apply_command(
10107            &mut state,
10108            InlineCommand::ShowOverlay {
10109                request: Box::new(request),
10110            },
10111        );
10112        assert!(!shutdown, "ShowOverlay must not request shutdown");
10113        let overlay = state.overlay.as_ref().expect("overlay must be Some");
10114        assert_eq!(overlay.title, "Pick");
10115        assert_eq!(overlay.items.len(), 2);
10116        assert_eq!(overlay.items[0].title, "alpha");
10117        assert_eq!(overlay.items[1].title, "beta");
10118        assert_eq!(overlay.lines.len(), 1);
10119
10120        // CloseOverlay clears it.
10121        apply_command(&mut state, InlineCommand::CloseOverlay);
10122        assert!(state.overlay.is_none(), "CloseOverlay must clear state");
10123    }
10124
10125    #[test]
10126    fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
10127        use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
10128        let request = OverlayRequest::Modal(ModalOverlayRequest {
10129            title: "API key".into(),
10130            lines: vec!["Paste your key".into()],
10131            secure_prompt: Some(SecurePromptConfig {
10132                label: "Key".into(),
10133                placeholder: Some("sk-...".into()),
10134                mask_input: true,
10135            }),
10136        });
10137        let state = materialize_overlay(request);
10138        let secure = state
10139            .secure_input
10140            .expect("secure_input must be Some when secure_prompt is Some");
10141        assert_eq!(secure.config.label, "Key");
10142        assert!(secure.config.mask_input);
10143        assert_eq!(secure.editor.text(), "");
10144        assert_eq!(secure.editor.cursor_byte(), 0);
10145    }
10146
10147    #[test]
10148    fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
10149        use oxicode_vtui::tui::core::ModalOverlayRequest;
10150        let request = OverlayRequest::Modal(ModalOverlayRequest {
10151            title: "Confirm".into(),
10152            lines: vec!["y/n".into()],
10153            secure_prompt: None,
10154        });
10155        let state = materialize_overlay(request);
10156        assert!(
10157            state.secure_input.is_none(),
10158            "secure_input must be None when secure_prompt is None"
10159        );
10160    }
10161
10162    // ─── fold / grace tests ─────────────────────────────────────────────
10163
10164    fn three_block_transcript() -> Vec<TranscriptLine> {
10165        // Three distinct blocks: user(0), agent(1), user(2).
10166        vec![
10167            TranscriptLine {
10168                kind: InlineMessageKind::User,
10169                segments: vec![plain_segment("hi")],
10170                block_id: 0,
10171            },
10172            TranscriptLine {
10173                kind: InlineMessageKind::Agent,
10174                segments: vec![plain_segment("hello")],
10175                block_id: 1,
10176            },
10177            TranscriptLine {
10178                kind: InlineMessageKind::Agent,
10179                segments: vec![plain_segment("world")],
10180                block_id: 1,
10181            },
10182            TranscriptLine {
10183                kind: InlineMessageKind::User,
10184                segments: vec![plain_segment("bye")],
10185                block_id: 2,
10186            },
10187        ]
10188    }
10189
10190    #[test]
10191    fn fold_all_collapses_every_block() {
10192        let mut state = RenderState::default();
10193        state.transcript = three_block_transcript();
10194        state.fold_all();
10195        assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
10196        assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10197        assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
10198        assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
10199    }
10200
10201    #[test]
10202    fn expand_all_after_fold_all_shows_expanded() {
10203        let mut state = RenderState::default();
10204        state.transcript = three_block_transcript();
10205        state.fold_all();
10206        state.expand_all();
10207        assert!(
10208            state.block_display.is_empty(),
10209            "Expanded is the default — no overrides"
10210        );
10211        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10212        assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
10213    }
10214
10215    #[test]
10216    fn truncate_all_sets_explicit_truncated() {
10217        let mut state = RenderState::default();
10218        state.transcript = three_block_transcript();
10219        state.fold_all();
10220        state.truncate_all();
10221        assert_eq!(state.block_display.len(), 3);
10222        assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
10223    }
10224
10225    #[test]
10226    fn default_block_mode_is_expanded() {
10227        let state = RenderState::default();
10228        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10229        assert!(state.block_display.is_empty(), "default needs no map entry");
10230    }
10231
10232    #[test]
10233    fn cycle_block_advances_through_three_states() {
10234        let mut state = RenderState::default();
10235        state.transcript = three_block_transcript();
10236        state.scroll_offset = 0; // view on block 0
10237        // Expanded (default) → Collapsed
10238        state.cycle_block_at_view();
10239        assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10240        // Collapsed → Truncated
10241        state.cycle_block_at_view();
10242        assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
10243        // Truncated → Expanded (default — removed from the map)
10244        state.cycle_block_at_view();
10245        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10246        assert!(!state.block_display.contains_key(&0));
10247    }
10248
10249    #[test]
10250    fn cancel_grace_field_defaults_none() {
10251        let state = RenderState::default();
10252        assert!(
10253            state.cancel_grace_until.is_none(),
10254            "cancel_grace_until must default to None"
10255        );
10256    }
10257
10258    #[test]
10259    fn cancel_routes_to_interrupt_when_streaming() {
10260        assert_eq!(
10261            route_cancel(true),
10262            CancelRoute::Interrupt,
10263            "Esc while streaming must route through the interrupt path"
10264        );
10265    }
10266
10267    #[test]
10268    fn cancel_routes_to_exit_when_idle() {
10269        assert_eq!(
10270            route_cancel(false),
10271            CancelRoute::Exit,
10272            "Esc while idle must exit immediately (one-press quit)"
10273        );
10274    }
10275    #[test]
10276    fn no_scrollbar_even_when_content_overflows() {
10277        // The in-app scrollbar is gone — native terminal scrollback owns
10278        // history and finalized rows commit above the viewport. Even a
10279        // 40-block transcript overflowing the viewport must not paint a
10280        // rail or thumb.
10281        let mut state = RenderState::default();
10282        for i in 0..40u32 {
10283            state.transcript.push(TranscriptLine {
10284                kind: InlineMessageKind::Agent,
10285                segments: vec![plain_segment(format!("line {i}"))],
10286                block_id: i as usize,
10287            });
10288        }
10289        let rendered = render_frame_to_string(&state);
10290        assert!(
10291            !rendered.contains('\u{2588}'),
10292            "no scrollbar thumb (█): native scrollback owns history"
10293        );
10294    }
10295
10296    // ─── confirmation modal tests ───────────────────────────────────────
10297
10298    #[test]
10299    fn confirmation_modal_renders_title() {
10300        let mut state = RenderState::default();
10301        state.confirmation = Some(quit_confirmation());
10302        let rendered = render_frame_to_string(&state);
10303        assert!(
10304            rendered.contains("Quit oxicode?"),
10305            "confirmation title must render"
10306        );
10307    }
10308
10309    #[test]
10310    fn confirmation_yes_sends_exit_and_closes() {
10311        let mut state = RenderState::default();
10312        state.confirmation = Some(quit_confirmation());
10313        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10314        let (tx, mut rx) = mpsc::unbounded_channel();
10315        let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10316        handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('y'));
10317        assert!(
10318            state_arc.lock().confirmation.is_none(),
10319            "yes must close the modal"
10320        );
10321        let ev = rx.try_recv().expect("yes must send an event");
10322        assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
10323    }
10324
10325    #[test]
10326    fn confirmation_no_closes_without_event() {
10327        let mut state = RenderState::default();
10328        state.confirmation = Some(quit_confirmation());
10329        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10330        let (tx, mut rx) = mpsc::unbounded_channel();
10331        let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10332        handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('n'));
10333        assert!(
10334            state_arc.lock().confirmation.is_none(),
10335            "no must close the modal"
10336        );
10337        assert!(rx.try_recv().is_err(), "no must not send an event");
10338    }
10339    // ─── ephemeral tip tests ───────────────────────────────────────────
10340
10341    #[test]
10342    fn tip_banner_renders_when_active() {
10343        let mut state = RenderState::default();
10344        let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
10345        state.tip = Some(EphemeralTip {
10346            text: "hello-tip-marker".to_string(),
10347            born_tick: now_tick,
10348            ttl_ticks: 100,
10349            key: "test",
10350            ambient: false,
10351        });
10352        let rendered = render_frame_to_string(&state);
10353        assert!(
10354            rendered.contains("hello-tip-marker"),
10355            "active tip must render above the composer"
10356        );
10357    }
10358
10359    #[test]
10360    fn tip_visible_within_ttl_window() {
10361        let tip = EphemeralTip {
10362            text: "x".to_string(),
10363            born_tick: 10,
10364            ttl_ticks: 5,
10365            key: "test",
10366            ambient: false,
10367        };
10368        assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
10369        assert!(
10370            !tip_is_visible(&tip, 15),
10371            "at TTL boundary (born + ttl) must expire"
10372        );
10373        assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
10374    }
10375
10376    // ─── sticky header tests ───────────────────────────────────────────
10377
10378    #[test]
10379    fn sticky_header_pins_block_head_when_scrolled_into_body() {
10380        // One big block (40 same-block lines); scroll the viewport into the
10381        // body. The sticky header must pin the block's first line at the top.
10382        let mut state = RenderState::default();
10383        for i in 0..40u32 {
10384            state.transcript.push(TranscriptLine {
10385                kind: InlineMessageKind::Agent,
10386                segments: vec![plain_segment(format!("body-line-{i:02}"))],
10387                block_id: 0,
10388            });
10389        }
10390        state.scroll_offset = 10;
10391        let rendered = render_frame_to_string(&state);
10392        assert!(
10393            rendered.contains("body-line-00"),
10394            "sticky header must pin the block head when scrolled into the body"
10395        );
10396    }
10397
10398    #[test]
10399    fn sticky_header_absent_when_viewport_at_block_head() {
10400        // Viewport top is the block head itself — no sticky pin needed.
10401        let mut state = RenderState::default();
10402        for i in 0..40u32 {
10403            state.transcript.push(TranscriptLine {
10404                kind: InlineMessageKind::Agent,
10405                segments: vec![plain_segment(format!("head-line-{i:02}"))],
10406                block_id: 0,
10407            });
10408        }
10409        state.scroll_offset = 0;
10410        let rendered = render_frame_to_string(&state);
10411        // head-line-00 is the viewport top already; it renders exactly once
10412        // (no separate sticky row). Just assert it is present.
10413        assert!(rendered.contains("head-line-00"));
10414    }
10415
10416    // ─── prompt queue tests ─────────────────────────────────────────────
10417
10418    #[test]
10419    fn turn_end_drains_queue_head() {
10420        let mut state = RenderState::default();
10421        state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
10422        state.drain_queue_head();
10423        assert_eq!(
10424            state.queued_inputs.len(),
10425            1,
10426            "drain_queue_head must drop the head (now running)"
10427        );
10428        assert_eq!(state.queued_inputs[0], "queued-2");
10429    }
10430
10431    // ─── render_frame integration ──────────────────────────────────────
10432
10433    #[test]
10434    fn render_frame_paints_transcript_content() {
10435        // Guard against render_frame losing its render_transcript call
10436        // (which only a content assertion through render_frame can catch —
10437        // render_transcript unit tests bypass render_frame entirely).
10438        let mut state = RenderState::default();
10439        state.transcript.push(TranscriptLine {
10440            kind: InlineMessageKind::Agent,
10441            segments: vec![plain_segment("frame-content-marker-xyz")],
10442            block_id: 0,
10443        });
10444        let rendered = render_frame_to_string(&state);
10445        assert!(
10446            rendered.contains("frame-content-marker-xyz"),
10447            "render_frame must paint transcript content"
10448        );
10449    }
10450
10451    #[test]
10452    fn user_turns_get_one_blank_spacer_row() {
10453        let mut state = RenderState::default();
10454        state.transcript = vec![
10455            TranscriptLine {
10456                kind: InlineMessageKind::Agent,
10457                segments: vec![plain_segment("agent-answer")],
10458                block_id: 0,
10459            },
10460            TranscriptLine {
10461                kind: InlineMessageKind::User,
10462                segments: vec![plain_segment("next-question")],
10463                block_id: 1,
10464            },
10465        ];
10466        let rendered = render_frame_to_string(&state);
10467        let rows: Vec<&str> = rendered.split('\n').collect();
10468        let agent_row = rows
10469            .iter()
10470            .position(|r| r.contains("agent-answer"))
10471            .expect("agent row");
10472        assert!(
10473            rows[agent_row + 1].trim().is_empty(),
10474            "blank spacer between turns: {:?}",
10475            &rows[agent_row..agent_row + 3]
10476        );
10477        assert!(
10478            rows[agent_row + 2].contains("next-question"),
10479            "user line follows the spacer"
10480        );
10481    }
10482
10483    #[test]
10484    fn transcript_snapshot_at_120_cols_matches_role_layout() {
10485        let mut state = RenderState::default();
10486        state.brain = BrainChip::Ok;
10487        state.append_line(
10488            InlineMessageKind::User,
10489            vec![plain_segment("intro message\nsecond line")],
10490        );
10491        state.append_line(
10492            InlineMessageKind::Agent,
10493            vec![plain_segment("answer paragraph line one\nline two")],
10494        );
10495        state.append_line(
10496            InlineMessageKind::User,
10497            vec![plain_segment("follow-up question")],
10498        );
10499        let rendered = render_frame_to_string_at(&state, 120, 24);
10500        let rows: Vec<&str> = rendered.split('\n').collect();
10501        // User rows carry no glyph — bold primary text only.
10502        let first_user = rows
10503            .iter()
10504            .position(|row| row.contains("intro message"))
10505            .expect("intro user row");
10506        let continuation = rows
10507            .iter()
10508            .position(|row| row.contains("second line"))
10509            .expect("user continuation visible");
10510        assert_eq!(
10511            continuation,
10512            first_user + 1,
10513            "user continuation on next row"
10514        );
10515        assert!(
10516            !rows[first_user].contains("> "),
10517            "plain style has no prompt glyph: {rows:?}"
10518        );
10519
10520        // Turn rhythm: a blank row breathes between the request and the
10521        // response, and again before the next user turn.
10522        let agent_row = rows
10523            .iter()
10524            .position(|row| row.contains("answer paragraph"))
10525            .expect("agent row");
10526        assert_eq!(
10527            agent_row,
10528            continuation + 2,
10529            "one blank row separates request from response: {:?}",
10530            &rows[continuation..=agent_row]
10531        );
10532        assert!(
10533            !rows[agent_row].trim_start().starts_with('>'),
10534            "agent rows carry no prompt glyph: {rows:?}"
10535        );
10536
10537        let next_user = rows
10538            .iter()
10539            .position(|row| row.contains("follow-up question"))
10540            .expect("second user row");
10541        assert_eq!(
10542            next_user,
10543            agent_row + 3,
10544            "answer (2 rows) + one blank + next user turn: {:?}",
10545            &rows[agent_row..=next_user]
10546        );
10547
10548        // Brain chip lives on the shortcuts bar, not the composer border.
10549        let shortcuts_row = rows
10550            .iter()
10551            .position(|row| row.contains("brain·ok"))
10552            .expect("brain chip on shortcuts row");
10553        assert!(shortcuts_row > next_user, "chip below the chat surface");
10554    }
10555
10556    #[test]
10557    fn response_breathes_after_the_user_request() {
10558        let mut state = RenderState::default();
10559        state.append_line(InlineMessageKind::User, vec![plain_segment("the request")]);
10560        state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10561        let rendered = render_frame_to_string(&state);
10562        let rows: Vec<&str> = rendered.split('\n').collect();
10563        let request_row = rows
10564            .iter()
10565            .position(|r| r.contains("the request"))
10566            .expect("request row");
10567        let answer_row = rows
10568            .iter()
10569            .position(|r| r.contains("the answer"))
10570            .expect("answer row");
10571        assert_eq!(
10572            answer_row,
10573            request_row + 2,
10574            "one blank row must separate request from response: {:?}",
10575            &rows[request_row..=answer_row]
10576        );
10577    }
10578
10579    #[test]
10580    fn assistant_tool_flow_stays_contiguous() {
10581        let mut state = RenderState::default();
10582        state.append_line(InlineMessageKind::Tool, vec![plain_segment("[tool] read")]);
10583        state.append_line(InlineMessageKind::Tool, vec![plain_segment("[done] ok")]);
10584        state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10585        let rendered = render_frame_to_string(&state);
10586        let rows: Vec<&str> = rendered.split('\n').collect();
10587        let tool_row = rows
10588            .iter()
10589            .position(|r| r.contains("[tool] read"))
10590            .expect("tool row");
10591        let answer_row = rows
10592            .iter()
10593            .position(|r| r.contains("the answer"))
10594            .expect("answer row");
10595        assert_eq!(
10596            answer_row,
10597            tool_row + 2,
10598            "tool → answer is one assistant turn — no blank inside it: {:?}",
10599            &rows[tool_row..=answer_row]
10600        );
10601    }
10602
10603    #[test]
10604    fn no_spacer_above_the_first_transcript_line() {
10605        let mut state = RenderState::default();
10606        state.transcript = vec![TranscriptLine {
10607            kind: InlineMessageKind::User,
10608            segments: vec![plain_segment("opening-question")],
10609            block_id: 0,
10610        }];
10611        let rendered = render_frame_to_string(&state);
10612        let rows: Vec<&str> = rendered.split('\n').collect();
10613        let user_row = rows
10614            .iter()
10615            .position(|r| r.contains("opening-question"))
10616            .expect("user row");
10617        assert!(
10618            rows[..user_row].iter().all(|r| r.trim().is_empty()),
10619            "transcript starts at the top with no spacer"
10620        );
10621    }
10622
10623    #[test]
10624    fn long_response_renders_every_line_by_default() {
10625        let mut state = RenderState::default();
10626        let mut segments = Vec::new();
10627        for i in 0..8 {
10628            if i > 0 {
10629                segments.push(plain_segment("\n"));
10630            }
10631            segments.push(plain_segment(format!("line-{i}")));
10632        }
10633        state.append_line(InlineMessageKind::Agent, segments);
10634        let rendered = render_frame_to_string(&state);
10635        assert!(
10636            !rendered.contains("lines"),
10637            "no elision gap by default — full text scrolls instead: {rendered}"
10638        );
10639        for i in 0..8 {
10640            assert!(
10641                rendered.contains(&format!("line-{i}")),
10642                "line-{i} must be reachable by scrolling: {rendered}"
10643            );
10644        }
10645    }
10646    #[test]
10647    fn multiline_user_input_renders_every_explicit_line() {
10648        let mut state = RenderState::default();
10649        state.append_line(
10650            InlineMessageKind::User,
10651            vec![plain_segment("first line\nsecond line")],
10652        );
10653        let rendered = render_frame_to_string(&state);
10654        let rows: Vec<&str> = rendered.split('\n').collect();
10655        let first_row = rows
10656            .iter()
10657            .position(|row| row.contains("first line"))
10658            .expect("first user row");
10659        let second_row = rows
10660            .iter()
10661            .position(|row| row.contains("second line"))
10662            .expect("explicit continuation line is visible");
10663        assert_eq!(
10664            second_row,
10665            first_row + 1,
10666            "explicit newline must occupy the following visual row"
10667        );
10668
10669        assert!(
10670            !rows[second_row].contains("> second line"),
10671            "continuation row must not look like a second user turn"
10672        );
10673    }
10674
10675    #[test]
10676    fn streaming_agent_delta_renders_every_explicit_line() {
10677        let mut state = RenderState::default();
10678        state.inline_segment(
10679            InlineMessageKind::Agent,
10680            plain_segment("first answer\nsecond answer"),
10681        );
10682        let rendered = render_frame_to_string(&state);
10683        let rows: Vec<&str> = rendered.split('\n').collect();
10684        let first_row = rows
10685            .iter()
10686            .position(|row| row.contains("first answer"))
10687            .expect("first agent row");
10688        let second_row = rows
10689            .iter()
10690            .position(|row| row.contains("second answer"))
10691            .expect("second agent row");
10692        assert_eq!(
10693            second_row,
10694            first_row + 1,
10695            "streamed newline must occupy the following visual row"
10696        );
10697    }
10698
10699    #[test]
10700    fn file_search_dropdown_renders_results() {
10701        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10702        let mut state = RenderState::default();
10703        state.input_enabled = true;
10704        state.file_search = Some(FileSearchState {
10705            query: "main".into(),
10706            at_offset: 0,
10707            hidden_mode: false,
10708            results: vec![
10709                FileSearchResult {
10710                    path: "src/main.rs".into(),
10711                    score: 100,
10712                },
10713                FileSearchResult {
10714                    path: "tests/main.rs".into(),
10715                    score: 50,
10716                },
10717            ],
10718            selected: 0,
10719            index: vec![],
10720            line_mode: false,
10721        });
10722        let rendered = render_frame_to_string(&state);
10723        assert!(rendered.contains("FILES"), "dropdown title must render");
10724        assert!(
10725            rendered.contains("src/main.rs"),
10726            "dropdown must show file paths"
10727        );
10728    }
10729
10730    #[test]
10731    fn file_search_dropdown_hidden_mode_title() {
10732        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10733        let mut state = RenderState::default();
10734        state.input_enabled = true;
10735        state.file_search = Some(FileSearchState {
10736            query: "".into(),
10737            at_offset: 0,
10738            hidden_mode: true,
10739            results: vec![FileSearchResult {
10740                path: ".env".into(),
10741                score: 0,
10742            }],
10743            selected: 0,
10744            index: vec![],
10745            line_mode: false,
10746        });
10747        let rendered = render_frame_to_string(&state);
10748        assert!(
10749            rendered.contains("HIDDEN"),
10750            "hidden mode must be indicated in title"
10751        );
10752    }
10753
10754    #[test]
10755    fn file_search_and_composer_render_together() {
10756        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10757        let mut state = RenderState::default();
10758        state.input_enabled = true;
10759        state.prompt_prefix = "> ".into();
10760        state.composer.set_text("@main");
10761        state.file_search = Some(FileSearchState {
10762            query: "main".into(),
10763            at_offset: 0,
10764            hidden_mode: false,
10765            results: vec![FileSearchResult {
10766                path: "src/main.rs".into(),
10767                score: 100,
10768            }],
10769            selected: 0,
10770            index: vec![],
10771            line_mode: false,
10772        });
10773        let rendered = render_frame_to_string(&state);
10774        // Both the composer text and the dropdown must appear.
10775        assert!(rendered.contains('>'), "composer must still render");
10776        assert!(
10777            rendered.contains("src/main.rs"),
10778            "dropdown must render alongside composer"
10779        );
10780    }
10781
10782    #[test]
10783    fn format_todo_line_shows_block_reason_and_notes_marker() {
10784        let styles = active_styles();
10785        let todo = TodoItem {
10786            content: "Wire OAuth".into(),
10787            status: TodoStatus::Blocked,
10788            notes: Some(vec!["waiting on vendor".into()]),
10789            block_reason: Some("vendor sandbox pending".into()),
10790        };
10791        let line = format_todo_line(&todo, false, &styles);
10792        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10793        assert!(text.contains("Wire OAuth"));
10794        assert!(text.contains("blocked: vendor sandbox pending"));
10795        assert!(text.contains("·1"));
10796    }
10797
10798    #[test]
10799    fn format_todo_line_abandoned_is_strikethrough() {
10800        let styles = active_styles();
10801        let todo = TodoItem {
10802            content: "Drop this".into(),
10803            status: TodoStatus::Abandoned,
10804            notes: None,
10805            block_reason: None,
10806        };
10807        let line = format_todo_line(&todo, false, &styles);
10808        assert!(
10809            line.spans
10810                .iter()
10811                .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
10812        );
10813    }
10814
10815    #[test]
10816    fn render_todo_pane_multi_phase_shows_roman_header_and_progress() {
10817        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10818        let mut state = RenderState::default();
10819        state.todo_phases = vec![
10820            TodoPhase {
10821                name: "Foundation".into(),
10822                tasks: vec![
10823                    TodoItem {
10824                        content: "a".into(),
10825                        status: TodoStatus::Completed,
10826                        notes: None,
10827                        block_reason: None,
10828                    },
10829                    TodoItem {
10830                        content: "b".into(),
10831                        status: TodoStatus::Completed,
10832                        notes: None,
10833                        block_reason: None,
10834                    },
10835                ],
10836            },
10837            TodoPhase {
10838                name: "Auth".into(),
10839                tasks: vec![
10840                    TodoItem {
10841                        content: "c".into(),
10842                        status: TodoStatus::Completed,
10843                        notes: None,
10844                        block_reason: None,
10845                    },
10846                    TodoItem {
10847                        content: "d".into(),
10848                        status: TodoStatus::InProgress,
10849                        notes: None,
10850                        block_reason: None,
10851                    },
10852                    TodoItem {
10853                        content: "e".into(),
10854                        status: TodoStatus::Pending,
10855                        notes: None,
10856                        block_reason: None,
10857                    },
10858                ],
10859            },
10860        ];
10861        let rendered = render_frame_to_string(&state);
10862        assert!(
10863            rendered.contains("II. Auth"),
10864            "multi-phase HUD must show the roman-numeral phase header"
10865        );
10866        assert!(
10867            rendered.contains("1/3"),
10868            "active phase must show its done/total progress"
10869        );
10870    }
10871
10872    #[test]
10873    fn todo_auto_clear_fires_after_delay_when_all_closed() {
10874        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10875        let mut state = RenderState::default();
10876        state.todo_phases = vec![TodoPhase {
10877            name: "Auth".into(),
10878            tasks: vec![TodoItem {
10879                content: "a".into(),
10880                status: TodoStatus::Completed,
10881                notes: None,
10882                block_reason: None,
10883            }],
10884        }];
10885        sync_todo_clear_timer(&mut state, 0); // 0-second delay = instant
10886        assert!(state.todo_phases.is_empty());
10887    }
10888
10889    #[test]
10890    fn todo_auto_clear_does_not_fire_while_open_tasks_remain() {
10891        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10892        let mut state = RenderState::default();
10893        let phases = vec![TodoPhase {
10894            name: "Auth".into(),
10895            tasks: vec![TodoItem {
10896                content: "a".into(),
10897                status: TodoStatus::Pending,
10898                notes: None,
10899                block_reason: None,
10900            }],
10901        }];
10902        state.todo_phases = phases.clone();
10903        sync_todo_clear_timer(&mut state, 0);
10904        assert_eq!(state.todo_phases.len(), phases.len());
10905        assert_eq!(state.todo_phases[0].name, "Auth");
10906    }
10907
10908    #[test]
10909    fn todo_auto_clear_negative_delay_disables_clearing() {
10910        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10911        let mut state = RenderState::default();
10912        let phases = vec![TodoPhase {
10913            name: "Auth".into(),
10914            tasks: vec![TodoItem {
10915                content: "a".into(),
10916                status: TodoStatus::Completed,
10917                notes: None,
10918                block_reason: None,
10919            }],
10920        }];
10921        state.todo_phases = phases.clone();
10922        sync_todo_clear_timer(&mut state, -1);
10923        assert_eq!(state.todo_phases.len(), phases.len());
10924    }
10925
10926    #[test]
10927    fn render_todo_pane_single_phase_has_no_roman_header() {
10928        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10929        let mut state = RenderState::default();
10930        state.todo_phases = vec![TodoPhase {
10931            name: "Todos".into(),
10932            tasks: vec![TodoItem {
10933                content: "a".into(),
10934                status: TodoStatus::Pending,
10935                notes: None,
10936                block_reason: None,
10937            }],
10938        }];
10939        let rendered = render_frame_to_string(&state);
10940        assert!(
10941            !rendered.contains("I. Todos"),
10942            "single phase must skip roman header"
10943        );
10944        assert!(rendered.contains("Todos"), "single-phase name must render");
10945    }
10946
10947    #[test]
10948    fn render_todo_compact_line_shows_counts_and_active_task() {
10949        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10950        let phases = vec![TodoPhase {
10951            name: "Auth".into(),
10952            tasks: vec![
10953                TodoItem {
10954                    content: "a".into(),
10955                    status: TodoStatus::Completed,
10956                    notes: None,
10957                    block_reason: None,
10958                },
10959                TodoItem {
10960                    content: "b".into(),
10961                    status: TodoStatus::InProgress,
10962                    notes: None,
10963                    block_reason: None,
10964                },
10965            ],
10966        }];
10967        let line = render_todo_compact_line(&phases);
10968        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10969        assert!(text.contains("TODO 1/2"));
10970        assert!(text.contains("b"));
10971    }
10972
10973    #[test]
10974    fn render_todo_compact_line_all_done_shows_done_marker() {
10975        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10976        let phases = vec![TodoPhase {
10977            name: "Auth".into(),
10978            tasks: vec![TodoItem {
10979                content: "a".into(),
10980                status: TodoStatus::Completed,
10981                notes: None,
10982                block_reason: None,
10983            }],
10984        }];
10985        let line = render_todo_compact_line(&phases);
10986        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10987        assert!(text.contains("done"));
10988    }
10989
10990    fn test_todo_state() -> std::sync::Arc<crate::store::todo_state::TodoState> {
10991        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10992        std::sync::Arc::new(crate::store::todo_state::TodoState::with_phases(vec![
10993            TodoPhase {
10994                name: "Auth".into(),
10995                tasks: vec![TodoItem {
10996                    content: "implement authentication module".into(),
10997                    status: TodoStatus::Pending,
10998                    notes: None,
10999                    block_reason: None,
11000                }],
11001            },
11002        ]))
11003    }
11004
11005    fn hub_with_subagent(
11006        status: oxicode_sdk::HubStatus,
11007        current_task: Option<&str>,
11008    ) -> std::sync::Arc<crate::app::agent_hub_registry::HubRegistry> {
11009        use crate::app::agent_hub_registry::{HubEntry, HubRegistry};
11010        let hub = HubRegistry::new();
11011        hub.register(
11012            "sub".into(),
11013            HubEntry {
11014                kind: oxicode_sdk::HubKind::Subagent,
11015                status,
11016                display_name: "sub".into(),
11017                current_task: current_task.map(str::to_string),
11018                last_activity_ms: 0,
11019                session_file: None,
11020            },
11021        );
11022        std::sync::Arc::new(hub)
11023    }
11024
11025    #[test]
11026    fn frame_refresh_reconciles_todo_with_completed_subagent() {
11027        let state = test_todo_state();
11028        let provider: std::sync::Arc<dyn TodoStateProvider> =
11029            crate::store::todo_state::provider_from_state(state.clone());
11030        let hub = hub_with_subagent(oxicode_sdk::HubStatus::Idle, Some("authentication module"));
11031        let phases = refresh_todo_phases(&provider, Some(&hub));
11032        assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
11033        // The reconcile write-back persisted through the provider.
11034        assert_eq!(state.get_phases()[0].tasks[0].status, TodoStatus::Completed);
11035    }
11036
11037    #[test]
11038    fn matched_closure_lights_pending_todo_for_running_subagent() {
11039        let hub = hub_with_subagent(
11040            oxicode_sdk::HubStatus::Running,
11041            Some("authentication module"),
11042        );
11043        let matched = build_matched_closure(Some(&hub));
11044        let t = oxicode_agent::tools::todo::TodoItem {
11045            content: "implement authentication module".into(),
11046            status: TodoStatus::Pending,
11047            notes: None,
11048            block_reason: None,
11049        };
11050        assert!(matched(&t));
11051    }
11052
11053    #[test]
11054    fn todo_pane_renders_when_items_present() {
11055        // The sticky pane is populated from the live provider in the event
11056        // loop; here we seed it directly to assert the pane paints task text.
11057        let mut state = RenderState::default();
11058        state.todo_phases = vec![oxicode_agent::tools::todo::TodoPhase {
11059            name: "Work".into(),
11060            tasks: vec![
11061                oxicode_agent::tools::todo::TodoItem {
11062                    content: "active task".into(),
11063                    status: TodoStatus::InProgress,
11064                    notes: None,
11065                    block_reason: None,
11066                },
11067                oxicode_agent::tools::todo::TodoItem {
11068                    content: "open task".into(),
11069                    status: TodoStatus::Pending,
11070                    notes: None,
11071                    block_reason: None,
11072                },
11073            ],
11074        }];
11075        let rendered = render_frame_to_string(&state);
11076        assert!(
11077            rendered.contains("active task"),
11078            "in-progress task must render"
11079        );
11080        assert!(rendered.contains("open task"), "pending task must render");
11081        // The active task is marked with the in-progress glyph.
11082        assert!(rendered.contains("▸"), "in-progress status must render");
11083    }
11084
11085    #[test]
11086    fn todo_pane_hidden_when_empty() {
11087        let state = RenderState::default();
11088        let rendered = render_frame_to_string(&state);
11089        // No todo content should leak when the list is empty.
11090        assert!(!rendered.contains("done"), "no completed state when empty");
11091    }
11092
11093    #[test]
11094    fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
11095        use ratatui::{Terminal, backend::TestBackend};
11096        let backend = TestBackend::new(80, 24);
11097        let mut terminal = Terminal::new(backend).unwrap();
11098        let overlay = OverlayState {
11099            title: "OpenAI key".into(),
11100            lines: vec!["Paste your API key".into()],
11101            items: Vec::new(),
11102            selected: 0,
11103            search: None,
11104            secure_input: Some(OverlaySecureInput {
11105                config: SecurePromptConfig {
11106                    label: "Key".into(),
11107                    placeholder: Some("sk-...".into()),
11108                    mask_input: true,
11109                },
11110                editor: oxicode_textarea::EditBuffer::from_parts("sk-abc", 6),
11111            }),
11112            ..Default::default()
11113        };
11114        terminal
11115            .draw(|f| render_overlay(f, f.area(), &overlay))
11116            .unwrap();
11117        let buf = terminal.backend().buffer().clone();
11118        // Mask must show 6 asterisks, never the value.
11119        let text: String = buf
11120            .content()
11121            .iter()
11122            .map(|c| c.symbol())
11123            .collect::<Vec<_>>()
11124            .join("");
11125        assert!(text.contains("Key:"));
11126        assert!(text.contains("******"));
11127        assert!(!text.contains("sk-abc"));
11128    }
11129
11130    #[test]
11131    fn render_overlay_secure_input_placeholder_when_empty() {
11132        use ratatui::{Terminal, backend::TestBackend};
11133        let backend = TestBackend::new(80, 24);
11134        let mut terminal = Terminal::new(backend).unwrap();
11135        let overlay = OverlayState {
11136            title: "OpenAI key".into(),
11137            lines: vec!["Paste your API key".into()],
11138            items: Vec::new(),
11139            selected: 0,
11140            search: None,
11141            secure_input: Some(OverlaySecureInput {
11142                config: SecurePromptConfig {
11143                    label: "Key".into(),
11144                    placeholder: Some("sk-...".into()),
11145                    mask_input: true,
11146                },
11147                editor: oxicode_textarea::EditBuffer::new(),
11148            }),
11149            ..Default::default()
11150        };
11151        terminal
11152            .draw(|f| render_overlay(f, f.area(), &overlay))
11153            .unwrap();
11154        let buf = terminal.backend().buffer().clone();
11155        let text: String = buf
11156            .content()
11157            .iter()
11158            .map(|c| c.symbol())
11159            .collect::<Vec<_>>()
11160            .join("");
11161        assert!(text.contains("sk-..."));
11162    }
11163    /// Pressure-driven allocation ladder: with many blocks competing
11164    /// for a short live region, older blocks must collapse to glyph
11165    /// rows and the emergency branch must paint a `… N earlier
11166    /// blocks hidden` banner.
11167    #[test]
11168    fn ladder_collapses_oldest_blocks_to_glyph_row_and_banner() {
11169        // 6 tool blocks; live region is 6 rows. Allocate 3 source
11170        // lines per block so the natural height (3) exceeds the
11171        // budget per block in the pressure branch. The ladder
11172        // hides the oldest blocks and paints glyph rows for the
11173        // newest ones.
11174        let mut state = RenderState::default();
11175        for i in 0..6 {
11176            let bid = i;
11177            state.transcript.push(TranscriptLine {
11178                kind: InlineMessageKind::Tool,
11179                segments: vec![plain_segment(format!("tool-{i}-headline"))],
11180                block_id: bid,
11181            });
11182            state.transcript.push(TranscriptLine {
11183                kind: InlineMessageKind::Tool,
11184                segments: vec![plain_segment(format!("tool-{i}-middle"))],
11185                block_id: bid,
11186            });
11187            state.transcript.push(TranscriptLine {
11188                kind: InlineMessageKind::Tool,
11189                segments: vec![plain_segment(format!("tool-{i}-trailer"))],
11190                block_id: bid,
11191            });
11192        }
11193        // 80x10 viewport → content_area.height ≈ 10 - composer 3 -
11194        // breath row 1 = 6 rows for the live region.
11195        let rendered = render_frame_to_string_at(&state, 80, 10);
11196        // Emergency banner ("… N earlier blocks hidden") must be
11197        // present when more blocks than rows exist. With 6 blocks
11198        // and a 6-row region, the ladder may or may not hide — but
11199        // it should never panic. We assert the renderer did not
11200        // lose the live region entirely and that the most-recent
11201        // block (tool-5) is at least partially visible.
11202        assert!(
11203            rendered.contains("tool-5")
11204                || rendered.contains("tool-5-headline")
11205                || rendered.contains("\u{25B8}")
11206                || rendered.contains("earlier blocks hidden"),
11207            "live region must surface a recent block or its folded form"
11208        );
11209    }
11210
11211    /// Pressure-driven ladder: the latest block stays full when
11212    /// older blocks are folded to glyph rows.
11213    #[test]
11214    fn ladder_keeps_newest_block_full_under_pressure() {
11215        // 3 blocks: one big (5 lines) + two small (2 lines each) =
11216        // 9 natural items; budget ≈ 6 → pressure. Newest (big)
11217        // gets the largest slice.
11218        let mut state = RenderState::default();
11219        // Block 0 (older, 2 lines)
11220        for j in 0..2 {
11221            state.transcript.push(TranscriptLine {
11222                kind: InlineMessageKind::Agent,
11223                segments: vec![plain_segment(format!("old-block-line-{j}"))],
11224                block_id: 0,
11225            });
11226        }
11227        // Block 1 (middle, 2 lines)
11228        for j in 0..2 {
11229            state.transcript.push(TranscriptLine {
11230                kind: InlineMessageKind::Agent,
11231                segments: vec![plain_segment(format!("mid-block-line-{j}"))],
11232                block_id: 1,
11233            });
11234        }
11235        // Block 2 (newest, 5 lines)
11236        for j in 0..5 {
11237            state.transcript.push(TranscriptLine {
11238                kind: InlineMessageKind::Agent,
11239                segments: vec![plain_segment(format!("new-block-line-{j}"))],
11240                block_id: 2,
11241            });
11242        }
11243        let rendered = render_frame_to_string_at(&state, 80, 12);
11244        // Newest block's first line must be visible.
11245        assert!(
11246            rendered.contains("new-block-line-0"),
11247            "newest block's leading line must be visible"
11248        );
11249        // Oldest block's lines may be folded or hidden — assert
11250        // they don't occupy the FULL natural height (the ladder
11251        // folded them).
11252        let old_visible = (0..2).all(|j| rendered.contains(&format!("old-block-line-{j}")));
11253        assert!(
11254            !old_visible,
11255            "oldest block must be folded or hidden when under pressure"
11256        );
11257    }
11258    /// Long activity strings must be clamped to the live content
11259    /// width — never wrap onto a second visual row that would
11260    /// break the `▸ ` or `╭─ / ╰─ …` affordances.
11261    #[test]
11262    fn long_activity_folded_card_stays_within_width() {
11263        let mut state = RenderState::default();
11264        // Many blocks of 3 lines each in a short live region.
11265        // 6 blocks × 3 = 18 visible items, budget ≈ 6 → pressure.
11266        // Every block gets 1 glyph row; activity descriptors are
11267        // 120+ chars long and would wrap without clamping.
11268        for i in 0..8 {
11269            let bid = i;
11270            let long = format!("tool-{i}-{}", "x".repeat(120));
11271            state.transcript.push(TranscriptLine {
11272                kind: InlineMessageKind::Tool,
11273                segments: vec![plain_segment(format!("tool-{i}-head"))],
11274                block_id: bid,
11275            });
11276            state.transcript.push(TranscriptLine {
11277                kind: InlineMessageKind::Tool,
11278                segments: vec![plain_segment(format!("tool-{i}-body"))],
11279                block_id: bid,
11280            });
11281            state.transcript.push(TranscriptLine {
11282                kind: InlineMessageKind::Tool,
11283                segments: vec![plain_segment(long)],
11284                block_id: bid,
11285            });
11286        }
11287        let rendered = render_frame_to_string_at(&state, 80, 10);
11288        // Every row in the rendered output must stay within the
11289        // 80-cell viewport. (Without clamping, the glyph row
11290        // `▸ tool-N-xxxxxxxxxxxxx...` would wrap onto a second
11291        // visual row whose first cell holds `▸`.)
11292        for line in rendered.split('\n') {
11293            assert!(
11294                line.width() <= 80,
11295                "rendered row exceeded the viewport width: '{}' ({} cells)",
11296                line,
11297                line.width()
11298            );
11299        }
11300        // The glyph row (`▸ `) signature must appear — long
11301        // activity must still surface (clamped, not truncated to
11302        // empty).
11303        assert!(
11304            rendered.contains('\u{25B8}'),
11305            "glyph rows must be painted even with long activities"
11306        );
11307    }
11308
11309    /// Manual `Collapsed` mode must keep the historic `[+] ` prefix
11310    /// produced by `transcript_line_marked(folded=true)`. The
11311    /// ladder applies only to non-manual blocks.
11312    #[test]
11313    fn manual_collapsed_block_keeps_the_plus_marker() {
11314        let mut state = RenderState::default();
11315        // One block with 3 lines + one newest block.
11316        for j in 0..3 {
11317            state.transcript.push(TranscriptLine {
11318                kind: InlineMessageKind::Error,
11319                segments: vec![plain_segment(format!("boom-line-{j}"))],
11320                block_id: 0,
11321            });
11322        }
11323        // Mark block 0 as manually collapsed.
11324        state.block_display.insert(0, BlockDisplayMode::Collapsed);
11325        // Newest block (1) untouched, default mode.
11326        state.transcript.push(TranscriptLine {
11327            kind: InlineMessageKind::Agent,
11328            segments: vec![plain_segment("after-collapsed")],
11329            block_id: 1,
11330        });
11331        let rendered = render_frame_to_string_at(&state, 80, 12);
11332        // The historic `[+] error:` prefix from
11333        // `transcript_line_marked(folded=true)` must still appear.
11334        assert!(
11335            rendered.contains("[+] error: boom-line-0"),
11336            "manual Collapsed must keep the [+] marker (got: {rendered:?})"
11337        );
11338        // The ladder's glyph affordance (`▸ `) must NOT replace it.
11339        assert!(
11340            !rendered.contains('\u{25B8}'),
11341            "manual Collapsed must NOT be replaced by the ladder glyph"
11342        );
11343    }
11344
11345    /// `clamp_fold_text` (the helper that truncates activity
11346    /// strings) honors unicode display width and replaces overflow
11347    /// with an ellipsis.
11348    #[test]
11349    fn clamp_fold_text_truncates_at_unicode_width() {
11350        // ASCII overflow: 80-cell budget, prefix 3, activity 100.
11351        let out = clamp_fold_text(&"x".repeat(100), 3, 80, "\u{2026}");
11352        assert!(out.ends_with('\u{2026}'), "ellipsis appended on overflow");
11353        assert!(out.width() <= 80, "clamped to budget: got {}", out.width());
11354        // CJK: each glyph is 2 cells.
11355        let cjk = "\u{4ECA}\u{65E5}\u{306F}\u{667A}\u{6167}".repeat(20);
11356        let out_cjk = clamp_fold_text(&cjk, 2, 20, "\u{2026}");
11357        assert!(out_cjk.width() <= 20, "CJK clamp: got {}", out_cjk.width());
11358        assert!(out_cjk.ends_with('\u{2026}'));
11359        // Identity when text already fits.
11360        assert_eq!(clamp_fold_text("short", 0, 80, "\u{2026}"), "short");
11361        // Zero-width returns empty.
11362        assert_eq!(clamp_fold_text("text", 0, 0, "\u{2026}"), "");
11363    }
11364
11365    // Cursor math for the composer is now owned by `oxicode_textarea::
11366    // TextArea::cursor_pos_with_state`, which is exercised by the
11367    // 351 tests in `oxicode-textarea`. The byte-cursor column math
11368    // these tests used to pin (composer_cursor_position) is gone.
11369}
11370
11371#[cfg(test)]
11372mod secure_input_tests {
11373    use super::*;
11374    use oxicode_vtui::tui::core::OverlaySubmission;
11375
11376    #[test]
11377    fn overlay_submission_secure_input_is_routed_to_host() {
11378        // Smoke: serialization round-trip — the variant must be reachable
11379        // through the protocol so the input thread can dispatch it.
11380        let _ = OverlaySubmission::SecureInput("sk-test".into());
11381        let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
11382        assert!(serialized.contains("SecureInput"));
11383    }
11384
11385    #[test]
11386    fn providers_action_matrix_branches_correctly() {
11387        // Pin the (has_key, oauth_capable) → Vec<AuthAction> matrix
11388        // exactly. Refactors MUST keep this contract: the order of
11389        // returned actions drives the visible action menu order.
11390        assert_eq!(
11391            next_provider_actions(true, true),
11392            vec![
11393                AuthAction::SetApiKey,
11394                AuthAction::StartOAuth,
11395                AuthAction::RemoveKey,
11396            ],
11397            "has key + oauth-capable: replace, oauth, remove"
11398        );
11399        assert_eq!(
11400            next_provider_actions(true, false),
11401            vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
11402            "has key, key-only provider: replace, remove"
11403        );
11404        assert_eq!(
11405            next_provider_actions(false, true),
11406            vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
11407            "no key + oauth-capable: set key, oauth"
11408        );
11409        assert_eq!(
11410            next_provider_actions(false, false),
11411            vec![AuthAction::SetApiKey],
11412            "no key + key-only provider: set key only"
11413        );
11414    }
11415
11416    // ── EditBuffer-flow tests for the post-port secure input ──────
11417    //
11418    // These exercise the new flow end-to-end so we never regress on the
11419    // core invariants: the real value lives only in the editor, the
11420    // renderer paints asterisks (not the value), and a backspace at the
11421    // end of the masked element clears the buffer atomically. None of the
11422    // assertions reference the secret string directly — only its length
11423    // and the renderer's symbol output.
11424
11425    /// Replicate the secure-input render path against an [`OverlaySecureInput`]
11426    /// so each test can build it without going through `materialize_overlay`.
11427    fn render_secure_to_text(secure: &OverlaySecureInput) -> String {
11428        use ratatui::{Terminal, backend::TestBackend};
11429        let backend = TestBackend::new(80, 24);
11430        let mut terminal = Terminal::new(backend).unwrap();
11431        let overlay = OverlayState {
11432            title: "OpenAI key".into(),
11433            lines: vec!["Paste your API key".into()],
11434            items: Vec::new(),
11435            selected: 0,
11436            search: None,
11437            secure_input: Some(secure.clone()),
11438            ..Default::default()
11439        };
11440        terminal
11441            .draw(|f| render_overlay(f, f.area(), &overlay))
11442            .unwrap();
11443        terminal
11444            .backend()
11445            .buffer()
11446            .content()
11447            .iter()
11448            .map(|c| c.symbol())
11449            .collect::<Vec<_>>()
11450            .join("")
11451    }
11452
11453    #[test]
11454    fn masked_render_shows_asterisks_not_value() {
11455        // The render path must NEVER carry the real value through a
11456        // `Line` span when `mask_input` is on. We assert on the rendered
11457        // buffer symbols only — the secret lives only in `editor.text()`.
11458        let mut editor = oxicode_textarea::EditBuffer::new();
11459        let _ = editor.insert_str("ABCDE");
11460        let rendered = render_secure_to_text(&OverlaySecureInput {
11461            config: SecurePromptConfig {
11462                label: "Key".into(),
11463                placeholder: Some("sk-...".into()),
11464                mask_input: true,
11465            },
11466            editor,
11467        });
11468        assert!(rendered.contains("*****"), "mask must render asterisks");
11469        assert!(
11470            !rendered.contains("ABCDE"),
11471            "masked render must NEVER carry the real value"
11472        );
11473        assert!(rendered.contains("Key:"), "label prefix must still render");
11474    }
11475
11476    #[test]
11477    fn masked_render_caret_lands_after_mask() {
11478        // After a value is set the caret must sit at the end of the
11479        // masked element (atomic boundary). The exact column is the
11480        // label-prefix width plus the masked width — both are stable.
11481        let mut editor = oxicode_textarea::EditBuffer::new();
11482        let _ = editor.insert_str("ABCD");
11483        let secure = OverlaySecureInput {
11484            config: SecurePromptConfig {
11485                label: "Key".into(),
11486                placeholder: Some("sk-...".into()),
11487                mask_input: true,
11488            },
11489            editor,
11490        };
11491        // Drive the same render path used by the production renderer to
11492        // pull the caret column out via `cursor_pos_with_state`.
11493        use ratatui::{Terminal, backend::TestBackend};
11494        let backend = TestBackend::new(80, 24);
11495        let mut terminal = Terminal::new(backend).unwrap();
11496        let overlay = OverlayState {
11497            title: "OpenAI key".into(),
11498            lines: vec!["Paste your API key".into()],
11499            items: Vec::new(),
11500            selected: 0,
11501            search: None,
11502            secure_input: Some(secure.clone()),
11503            ..Default::default()
11504        };
11505        terminal
11506            .draw(|f| render_overlay(f, f.area(), &overlay))
11507            .unwrap();
11508        // Build the masked textarea identically and ask for its cursor
11509        // column relative to the same area the renderer uses.
11510        let value = secure.editor.text();
11511        let mut ta = oxicode_textarea::TextArea::new();
11512        ta.set_text(value);
11513        ta.replace_range_with_element(
11514            0..value.len(),
11515            value,
11516            MASKED_ELEMENT_KIND,
11517            Some(Line::from("*".repeat(value.chars().count()))),
11518        );
11519        ta.set_cursor(secure.editor.cursor_byte());
11520        let caret = ta
11521            .cursor_pos_with_state(
11522                Rect {
11523                    x: 0,
11524                    y: 0,
11525                    width: 80,
11526                    height: 24,
11527                },
11528                oxicode_textarea::TextAreaState::default(),
11529            )
11530            .expect("caret must be visible");
11531        // The masked element covers 0..4, so the textarea's cursor snaps
11532        // to its end boundary and reports column 4 relative to the area.
11533        assert_eq!(caret.0, 4);
11534    }
11535
11536    #[test]
11537    fn backspace_removes_previous_grapheme() {
11538        // The masked element renders the whole buffer as asterisks, but
11539        // `EditBuffer` operates grapheme-by-grapheme — the textarea's
11540        // element bookkeeping only affects cursor snapping at render
11541        // time, not the editor's edit primitives. Pin both halves of the
11542        // contract so a future port that changes either side is caught.
11543        let mut editor = oxicode_textarea::EditBuffer::new();
11544        let _ = editor.insert_str("XYZ");
11545        assert_eq!(editor.text(), "XYZ");
11546        assert_eq!(editor.cursor_byte(), 3);
11547        let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11548        assert_eq!(editor.text(), "XY");
11549        assert_eq!(editor.cursor_byte(), 2);
11550        let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11551        assert_eq!(editor.text(), "X");
11552        let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11553        assert_eq!(editor.text(), "");
11554        assert_eq!(editor.cursor_byte(), 0);
11555    }
11556
11557    #[test]
11558    fn empty_editor_renders_placeholder_not_asterisks() {
11559        // Pin the empty-buffer render path: placeholder text, zero
11560        let rendered = render_secure_to_text(&OverlaySecureInput {
11561            config: SecurePromptConfig {
11562                label: "Key".into(),
11563                placeholder: Some("sk-...".into()),
11564                mask_input: true,
11565            },
11566            editor: oxicode_textarea::EditBuffer::new(),
11567        });
11568        assert!(rendered.contains("sk-..."));
11569        assert!(!rendered.contains("*"));
11570    }
11571
11572    #[test]
11573    fn paste_filter_drops_newline_and_non_ascii_via_edit_command() {
11574        // The paste path now feeds `EditCommand::Insert` per character
11575        // after the same ASCII + newline filter the helper used to apply.
11576        // Re-pinning the contract here means a regression in the filter
11577        // shows up directly as a test failure.
11578        let mut editor = oxicode_textarea::EditBuffer::new();
11579        let pasted = "sk-xyz\nABC\u{1F600}";
11580        let trimmed = pasted.trim_end_matches('\n');
11581        for ch in trimmed.chars() {
11582            if ch.is_ascii_graphic() || ch == ' ' {
11583                let _ = editor.apply(oxicode_textarea::EditCommand::Insert(ch));
11584            }
11585        }
11586        assert_eq!(editor.text(), "sk-xyzABC");
11587        assert_eq!(editor.cursor_byte(), 9);
11588    }
11589}
11590// ═════════════════════════════════════════════════════════════════════════
11591// `/providers` overlay chaining — regression for the bug where the
11592// `OverlayEvent::Submitted` arm closed the current overlay
11593// unconditionally, even when the handler opened a fresh overlay (action
11594// menu, secure prompt). The cmd channel processes `ShowOverlay` and
11595// `CloseOverlay` in submit order, so a `CloseOverlay` enqueued right
11596// after the `ShowOverlay` from the action menu won — leaving the user
11597// with nothing visible on Enter.
11598// ═════════════════════════════════════════════════════════════════════════
11599
11600#[cfg(test)]
11601mod provider_overlay_tests {
11602    use super::*;
11603    use crate::app::agent_session::{AgentSession, AgentSessionHandle};
11604    use crate::store::session::SessionManager;
11605    use crate::store::settings::Settings;
11606    use oxicode_agent::{Agent, AgentConfig};
11607    use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
11608    use oxicode_vtui::tui::core::OverlayEvent;
11609    use std::pin::Pin;
11610    use std::sync::Arc;
11611    use std::task::{Context as TaskContext, Poll};
11612
11613    /// Minimal mock provider — produces an empty stream so `AgentSession`
11614    /// can construct (the `ProviderRow` dispatch never streams).
11615    struct EmptyStream;
11616    impl futures::Stream for EmptyStream {
11617        type Item = ProviderEvent;
11618        fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
11619            Poll::Ready(None)
11620        }
11621    }
11622
11623    struct StubProvider;
11624    impl Provider for StubProvider {
11625        fn stream<'a>(
11626            &'a self,
11627            _model: &'a oxicode_sdk::Model,
11628            _context: &'a oxicode_sdk::Context,
11629            _options: Option<oxicode_sdk::StreamOptions>,
11630        ) -> Pin<
11631            Box<
11632                dyn Future<
11633                        Output = Result<
11634                            Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
11635                            ProviderError,
11636                        >,
11637                    > + Send
11638                    + 'a,
11639            >,
11640        > {
11641            Box::pin(async move {
11642                Ok::<_, ProviderError>(Box::pin(EmptyStream)
11643                    as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
11644            })
11645        }
11646    }
11647
11648    /// Minimal `AgentTool` fixture: name + essential flag, execute is a
11649    /// stub. Used by `make_session_with_tools_for_tests`.
11650    struct StubEssentialTool {
11651        name: &'static str,
11652        essential: bool,
11653    }
11654    #[async_trait::async_trait]
11655    impl oxicode_agent::AgentTool for StubEssentialTool {
11656        fn name(&self) -> &str {
11657            self.name
11658        }
11659        fn label(&self) -> &str {
11660            self.name
11661        }
11662        fn description(&self) -> &str {
11663            "stub tool for multiselect editor tests"
11664        }
11665        fn parameters_schema(&self) -> serde_json::Value {
11666            serde_json::json!({ "type": "object", "properties": {} })
11667        }
11668        fn essential(&self) -> bool {
11669            self.essential
11670        }
11671        async fn execute(
11672            &self,
11673            _id: &str,
11674            _params: serde_json::Value,
11675            _signal: Option<tokio::sync::oneshot::Receiver<()>>,
11676            _ctx: &oxicode_agent::ToolContext,
11677        ) -> Result<oxicode_agent::AgentToolResult, String> {
11678            Ok(oxicode_agent::AgentToolResult::success("ok"))
11679        }
11680    }
11681
11682    fn make_session() -> AgentSessionHandle {
11683        let provider = Arc::new(StubProvider);
11684        let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11685        let agent = Arc::new(Agent::new(
11686            provider,
11687            config,
11688            Arc::new(oxicode_agent::ToolRegistry::new()),
11689        ));
11690        let settings = Settings::default();
11691        let session_manager = SessionManager::in_memory("/tmp/test_providers");
11692        let session = AgentSession::new(
11693            agent,
11694            settings,
11695            session_manager,
11696            "/tmp/test_providers".to_string(),
11697            crate::SessionState::default(),
11698        );
11699        session.clone_handle()
11700    }
11701
11702    /// Session fixture with a registry holding one essential (`bash`)
11703    /// and one optional (`commit`) tool — used by the settings-panel
11704    /// multiselect editor tests (they source their row list from the
11705    /// live registry). `pub(super)` so sibling test mods can reuse the
11706    /// provider stub without duplicating it.
11707    pub(super) fn make_session_with_tools_for_tests() -> AgentSessionHandle {
11708        let provider = Arc::new(StubProvider);
11709        let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11710        let registry = oxicode_agent::ToolRegistry::new();
11711        registry.register_arc(Arc::new(StubEssentialTool {
11712            name: "bash",
11713            essential: true,
11714        }));
11715        registry.register_arc(Arc::new(StubEssentialTool {
11716            name: "commit",
11717            essential: false,
11718        }));
11719        let agent = Arc::new(Agent::new(provider, config, Arc::new(registry)));
11720        let settings = Settings::default();
11721        let session_manager = SessionManager::in_memory("/tmp/test_providers");
11722        let session = AgentSession::new(
11723            agent,
11724            settings,
11725            session_manager,
11726            "/tmp/test_providers".to_string(),
11727            crate::SessionState::default(),
11728        );
11729        session.clone_handle()
11730    }
11731
11732    /// `InlineCommand` does not implement `Debug`, so summarise the channel
11733    /// contents by command variant for assertion failure messages.
11734    fn summarise(cmds: &[InlineCommand]) -> String {
11735        let mut show = 0;
11736        let mut close = 0;
11737        let mut other = 0;
11738        for c in cmds {
11739            match c {
11740                InlineCommand::ShowOverlay { .. } => show += 1,
11741                InlineCommand::CloseOverlay => close += 1,
11742                _ => other += 1,
11743            }
11744        }
11745        format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
11746    }
11747
11748    /// Regression: `/providers` row selection for an OAuth-capable
11749    /// provider with no stored key triggers the multi-action chain
11750    /// `[SetApiKey, StartOAuth]` → `handle.show_list_modal` opens the
11751    /// action menu. The bug closed that menu instantly. The fix tracks
11752    /// whether the handler opened a new overlay and only emits the
11753    /// trailing `close_overlay()` when nothing was opened.
11754    #[test]
11755    fn provider_row_opens_action_menu_without_close() {
11756        // openai is OAuth-capable (per `product-meta.toml`), no key in
11757        // the env / storage, so the action matrix returns the
11758        // multi-action list.
11759        let session = make_session();
11760        let mut state = RenderState::default();
11761        state.overlay_providers = vec!["openai".to_string()];
11762        state.overlay = Some(OverlayState {
11763            title: "Providers".to_string(),
11764            lines: Vec::new(),
11765            items: Vec::new(),
11766            selected: 0,
11767            search: None,
11768            secure_input: None,
11769            ..Default::default()
11770        });
11771
11772        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11773        let handle = InlineHandle::new_for_tests(cmd_tx);
11774        let prompt_queue = Arc::new(PromptQueue::default());
11775
11776        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11777            InlineListSelection::ProviderRow(0),
11778        )));
11779        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11780
11781        let cmds: Vec<InlineCommand> = {
11782            let mut out = Vec::new();
11783            while let Ok(cmd) = cmd_rx.try_recv() {
11784                out.push(cmd);
11785            }
11786            out
11787        };
11788        let show_count = cmds
11789            .iter()
11790            .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11791            .count();
11792        assert_eq!(
11793            show_count,
11794            1,
11795            "submitting a provider row must ShowOverlay exactly once (commands: {})",
11796            summarise(&cmds)
11797        );
11798
11799        // The bug: a `CloseOverlay` followed the `ShowOverlay` on the
11800        // cmd channel and won the order-of-application race. After the
11801        // fix, no `CloseOverlay` may follow the `ShowOverlay`.
11802        let show_idx = cmds
11803            .iter()
11804            .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11805            .expect("ShowOverlay must be present");
11806        let trailing = &cmds[show_idx + 1..];
11807        assert!(
11808            !trailing
11809                .iter()
11810                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11811            "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
11812            summarise(&cmds)
11813        );
11814
11815        // Stale-state cleanup must still run so future `/providers`
11816        // does not see stale indices.
11817        assert!(
11818            state.overlay_providers.is_empty(),
11819            "overlay_providers must be cleared after dispatch (got {:?})",
11820            state.overlay_providers
11821        );
11822    }
11823
11824    /// Regression: `/providers` row selection for a key-only provider
11825    /// (no OAuth spec) with no stored key triggers the single-action
11826    /// chain `[SetApiKey]` → `handle_auth_action` opens the secure
11827    /// prompt modal. The bug closed that modal instantly. The fix
11828    /// propagates the `opened_new_overlay` flag through `|=` so the
11829    /// secure prompt survives.
11830    #[test]
11831    fn provider_row_set_api_key_opens_secure_prompt_without_close() {
11832        // cerebras is key-only (no OAuth spec in `product-meta.toml`).
11833        let session = make_session();
11834        let mut state = RenderState::default();
11835        state.overlay_providers = vec!["cerebras".to_string()];
11836        state.overlay = Some(OverlayState {
11837            title: "Providers".to_string(),
11838            lines: Vec::new(),
11839            items: Vec::new(),
11840            selected: 0,
11841            search: None,
11842            secure_input: None,
11843            ..Default::default()
11844        });
11845
11846        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11847        let handle = InlineHandle::new_for_tests(cmd_tx);
11848        let prompt_queue = Arc::new(PromptQueue::default());
11849
11850        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11851            InlineListSelection::ProviderRow(0),
11852        )));
11853        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11854
11855        let cmds: Vec<InlineCommand> = {
11856            let mut out = Vec::new();
11857            while let Ok(cmd) = cmd_rx.try_recv() {
11858                out.push(cmd);
11859            }
11860            out
11861        };
11862        let show_count = cmds
11863            .iter()
11864            .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11865            .count();
11866        assert_eq!(
11867            show_count,
11868            1,
11869            "submitting a provider row must ShowOverlay exactly once (commands: {})",
11870            summarise(&cmds)
11871        );
11872
11873        let show_idx = cmds
11874            .iter()
11875            .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11876            .expect("ShowOverlay must be present");
11877        let trailing = &cmds[show_idx + 1..];
11878        assert!(
11879            !trailing
11880                .iter()
11881                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11882            "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
11883            summarise(&cmds)
11884        );
11885
11886        // The secure prompt origin must be stashed so a subsequent
11887        // `SecureInput` submission routes the key to the right provider
11888        // and emits a contextual follow-up message.
11889        assert_eq!(
11890            state.secure_input_origin,
11891            Some(SecureInputOrigin::SetKey {
11892                provider: "cerebras".to_string(),
11893            }),
11894            "secure_input_origin must be stashed by SetApiKey"
11895        );
11896    }
11897
11898    /// Catalog model selection (the working baseline) must remain
11899    /// closing — pinning the behavior so the conditional close does
11900    /// not regress the other `Submitted` branches.
11901    #[test]
11902    fn catalog_model_selection_still_closes_overlay() {
11903        let session = make_session();
11904        let mut state = RenderState::default();
11905        state.overlay_catalog_models = vec![(
11906            "anthropic".to_string(),
11907            "claude-sonnet-4-20250514".to_string(),
11908        )];
11909        state.overlay = Some(OverlayState {
11910            title: "Models".to_string(),
11911            lines: Vec::new(),
11912            items: Vec::new(),
11913            selected: 0,
11914            search: None,
11915            secure_input: None,
11916            ..Default::default()
11917        });
11918
11919        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11920        let handle = InlineHandle::new_for_tests(cmd_tx);
11921        let prompt_queue = Arc::new(PromptQueue::default());
11922
11923        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11924            InlineListSelection::CatalogModel(0),
11925        )));
11926        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11927
11928        let cmds: Vec<InlineCommand> = {
11929            let mut out = Vec::new();
11930            while let Ok(cmd) = cmd_rx.try_recv() {
11931                out.push(cmd);
11932            }
11933            out
11934        };
11935        assert!(
11936            cmds.iter()
11937                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11938            "catalog model selection must close the overlay (commands: {})",
11939            summarise(&cmds)
11940        );
11941    }
11942
11943    /// `add_custom_provider` chains into the secure prompt via
11944    /// `open_secure_prompt` with `SecureInputOrigin::NewlyAdded`. The
11945    /// provider must be reachable from either variant so the
11946    /// `OverlaySubmission::SecureInput` consumer routes the key to the
11947    /// right slot without a per-variant branch.
11948    fn secure_input_origin_carries_provider_independently_of_variant() {
11949        let set = SecureInputOrigin::SetKey {
11950            provider: "openai".to_string(),
11951        };
11952        let added = SecureInputOrigin::NewlyAdded {
11953            provider: "minimax".to_string(),
11954        };
11955        // `provider` must be reachable regardless of variant so the
11956        // `OverlaySubmission::SecureInput` consumer can route the key
11957        // without a per-variant branch. (The model-role origins carry
11958        // no provider — they route through their own arm.)
11959        let provider_of = |o: &SecureInputOrigin| match o {
11960            SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
11961                provider.clone()
11962            }
11963            SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => {
11964                unreachable!("model-role origins have no provider")
11965            }
11966            SecureInputOrigin::TextEdit(_) => {
11967                unreachable!("text-edit origin has no provider")
11968            }
11969        };
11970        assert_eq!(provider_of(&set), "openai");
11971        assert_eq!(provider_of(&added), "minimax");
11972        // Variants are distinct (so the follow-up message can branch).
11973        assert_ne!(set, added);
11974    }
11975
11976    /// Regression: the `/sessions` picker arm previously set
11977    /// `state.pending_resume` without the `is_streaming()` gate that the
11978    /// direct `/sessions <id>` path and `/handoff` both use. A mid-stream
11979    /// pick + Enter fired the drain, which calls `resume_from_file` →
11980    /// `AgentSession::new` → `agent.update_state` on the shared
11981    /// `Arc<Agent>`, clobbering the in-flight conversation's message
11982    /// history. The picker now refuses with the same error wording as
11983    /// the direct path and never sets `pending_resume` while streaming.
11984    #[test]
11985    fn session_picker_resume_refused_while_streaming() {
11986        let session = make_session();
11987        // Flip the streaming flag BEFORE dispatch so the gate fires.
11988        // `streaming_flag()` returns an `Arc<AtomicBool>` shared with the
11989        // worker thread, so the production code observes the new value.
11990        session
11991            .streaming_flag()
11992            .store(true, std::sync::atomic::Ordering::SeqCst);
11993
11994        let mut state = RenderState::default();
11995        // Sanity: no resume queued yet.
11996        assert!(
11997            state.pending_resume.is_none(),
11998            "precondition: pending_resume must start None"
11999        );
12000
12001        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12002        let handle = InlineHandle::new_for_tests(cmd_tx);
12003        let prompt_queue = Arc::new(PromptQueue::default());
12004
12005        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12006            InlineListSelection::Session("some-id".to_string()),
12007        )));
12008        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12009
12010        // The gate must have refused: pending_resume stays None.
12011        assert!(
12012            state.pending_resume.is_none(),
12013            "streaming session must not enqueue pending_resume (got {:?})",
12014            state.pending_resume
12015        );
12016
12017        // Drain the handle's cmd channel and inspect appended lines.
12018        let mut cmds: Vec<InlineCommand> = Vec::new();
12019        while let Ok(cmd) = cmd_rx.try_recv() {
12020            cmds.push(cmd);
12021        }
12022        let mut found_error = false;
12023        let mut error_text = String::new();
12024        for cmd in &cmds {
12025            if let InlineCommand::AppendLine { kind, segments } = cmd
12026                && matches!(kind, InlineMessageKind::Error)
12027            {
12028                error_text = segments
12029                    .iter()
12030                    .map(|s| s.text.as_str())
12031                    .collect::<Vec<_>>()
12032                    .join("");
12033                found_error = true;
12034            }
12035        }
12036        assert!(
12037            found_error,
12038            "expected an error AppendLine (commands: {})",
12039            summarise(&cmds)
12040        );
12041        assert!(
12042            error_text.contains("Cannot resume while agent is running"),
12043            "error text must match the direct-path wording (got {error_text:?})"
12044        );
12045
12046        // Cleanup: reset streaming so the flag doesn't leak across tests
12047        // in the same process.
12048        session
12049            .streaming_flag()
12050            .store(false, std::sync::atomic::Ordering::SeqCst);
12051    }
12052
12053    /// `/settings` tab switch: submitting `SettingsTab(1)` must reopen the
12054    /// panel rebuilt for tab 1 (Model) — tab bar, sidebar sections, and
12055    /// the def-table rows for that tab — without emitting a CloseOverlay.
12056    #[test]
12057    fn settings_tab_selection_rebuilds_item_list() {
12058        let session = make_session();
12059        let mut state = RenderState::default();
12060        // Enter already closed the overlay before the submission arrives.
12061        state.overlay = None;
12062
12063        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12064        let handle = InlineHandle::new_for_tests(cmd_tx);
12065        let prompt_queue = Arc::new(PromptQueue::default());
12066
12067        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12068            InlineListSelection::SettingsTab(1),
12069        )));
12070        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12071
12072        let overlay = state.overlay.as_ref().expect("panel reopened on tab 1");
12073        assert_eq!(overlay.active_tab, 1);
12074        assert_eq!(overlay.tabs.get(1).map(String::as_str), Some("Model"));
12075        assert_eq!(
12076            overlay.sections,
12077            vec!["Defaults".to_string(), "Pointers".to_string()]
12078        );
12079        // Rows come from the def table for the Model tab.
12080        let settings = Settings::load().unwrap_or_default();
12081        let expected = settings_overlay_items(SettingsTab::Model, &settings).0;
12082        assert_eq!(overlay.items.len(), expected.len());
12083        assert_eq!(
12084            overlay
12085                .items
12086                .iter()
12087                .map(|i| i.title.clone())
12088                .collect::<Vec<_>>(),
12089            expected.iter().map(|i| i.title.clone()).collect::<Vec<_>>(),
12090        );
12091        assert_eq!(state.settings_active_tab, SettingsTab::Model);
12092        // The switch reopens in place — no close may leak through the
12093        // cmd channel.
12094        while let Ok(cmd) = cmd_rx.try_recv() {
12095            assert!(
12096                !matches!(cmd, InlineCommand::CloseOverlay),
12097                "tab switch must not close the reopened panel"
12098            );
12099        }
12100    }
12101}
12102#[cfg(test)]
12103mod thinking_stream_tests {
12104    //! Regression: a `StreamDelta::Thinking` delta must (a) never append
12105    //! to the transcript, and (b) only set a fixed `thinking…` label on
12106    //! the reasoning stage — never the streamed fragment. Raw reasoning
12107    //! fragments would otherwise leak through two render surfaces
12108    //! (the composer `RUN ` field in `composer_context_line`, and the
12109    //! reasoning indicator above the composer).
12110    use super::*;
12111    use oxicode_ai::{Api, AssistantMessage, ContentBlock, Message, TextContent};
12112    use oxicode_vtui::tui::core::InlineHandle;
12113    use tokio::sync::mpsc;
12114
12115    fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
12116        let (tx, rx) = mpsc::unbounded_channel();
12117        (InlineHandle::new_for_tests(tx), rx)
12118    }
12119
12120    fn assistant() -> Message {
12121        Message::Assistant(AssistantMessage::new(
12122            Api::OpenAiCompletions,
12123            "test",
12124            "test",
12125        ))
12126    }
12127
12128    fn assistant_with_text(text: &str) -> Message {
12129        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "test", "test");
12130        a.content.push(ContentBlock::Text(TextContent::new(text)));
12131        Message::Assistant(a)
12132    }
12133    #[test]
12134    fn thinking_delta_sets_fixed_stage_label_not_raw_text() {
12135        let mut state = RenderState::default();
12136        let (handle, mut cmd_rx) = fresh_handle();
12137
12138        // The exact text the model streamed for reasoning MUST NOT appear
12139        // in the stage indicator — it renders in the transcript's dimmed
12140        // reasoning block instead.
12141        let event = AgentEvent::MessageUpdate {
12142            message: assistant(),
12143            delta: oxicode_sdk::StreamDelta::Thinking("considering options".into()),
12144        };
12145        map_agent_event(&handle, event, &mut state);
12146
12147        assert_eq!(
12148            state.reasoning_stage.as_deref(),
12149            Some("thinking\u{2026}"),
12150            "reasoning stage must show a fixed label, never the streamed fragment"
12151        );
12152        while let Ok(cmd) = cmd_rx.try_recv() {
12153            assert!(
12154                !matches!(cmd, InlineCommand::Inline { .. }),
12155                "thinking must not emit a transcript Inline command"
12156            );
12157        }
12158    }
12159
12160    #[test]
12161    fn thinking_streams_as_dimmed_block_above_the_answer() {
12162        let mut state = RenderState::default();
12163        let (handle, mut rx) = fresh_handle();
12164
12165        map_agent_event(
12166            &handle,
12167            AgentEvent::MessageStart {
12168                message: assistant(),
12169            },
12170            &mut state,
12171        );
12172        map_agent_event(
12173            &handle,
12174            AgentEvent::MessageUpdate {
12175                message: assistant(),
12176                delta: oxicode_sdk::StreamDelta::Thinking("weighing alternatives".into()),
12177            },
12178            &mut state,
12179        );
12180        apply_all(&mut state, &mut rx);
12181        let text: String = state
12182            .transcript
12183            .iter()
12184            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12185            .collect::<Vec<_>>()
12186            .join(" ");
12187        assert!(
12188            text.contains("weighing alternatives"),
12189            "thinking must render in the transcript: {text}"
12190        );
12191        let dim_italic = state.transcript.iter().any(|l| {
12192            l.segments.iter().any(|s| {
12193                let st = s.style.as_ref();
12194                st.effects.contains(anstyle::Effects::DIMMED)
12195                    && st.effects.contains(anstyle::Effects::ITALIC)
12196            })
12197        });
12198        assert!(
12199            dim_italic,
12200            "thinking lines render in the dimmed italic reasoning style"
12201        );
12202
12203        // The answer streams below the thinking block, and thinking survives.
12204        map_agent_event(
12205            &handle,
12206            AgentEvent::MessageUpdate {
12207                message: assistant(),
12208                delta: oxicode_sdk::StreamDelta::Text("the answer".into()),
12209            },
12210            &mut state,
12211        );
12212        apply_all(&mut state, &mut rx);
12213        type_out_stream(&mut state);
12214
12215        // One blank row breathes between the thinking block and the answer.
12216        let texts: Vec<String> = state
12217            .transcript
12218            .iter()
12219            .map(|l| {
12220                l.segments
12221                    .iter()
12222                    .map(|s| s.text.as_str())
12223                    .collect::<String>()
12224            })
12225            .collect();
12226        let think_idx = texts
12227            .iter()
12228            .position(|t| t.contains("weighing alternatives"))
12229            .expect("thinking line");
12230        let answer_idx = texts
12231            .iter()
12232            .position(|t| t.contains("the answer"))
12233            .expect("answer line");
12234        assert!(
12235            answer_idx > think_idx,
12236            "answer renders below thinking: {texts:?}"
12237        );
12238        assert_eq!(
12239            state.reasoning_stage.as_deref(),
12240            Some("generating response"),
12241            "the stage label moves on once the answer streams"
12242        );
12243    }
12244
12245    #[test]
12246    fn tool_lines_survive_the_full_turn_event_sequence() {
12247        let mut state = RenderState::default();
12248        let (handle, mut rx) = fresh_handle();
12249
12250        // Real order (agent_loop): assistant text → MessageEnd → ToolStart
12251        // → ToolComplete → ToolResult(MessageStart+MessageEnd) → next text.
12252        map_agent_event(
12253            &handle,
12254            AgentEvent::MessageStart {
12255                message: assistant(),
12256            },
12257            &mut state,
12258        );
12259        map_agent_event(
12260            &handle,
12261            AgentEvent::MessageUpdate {
12262                message: assistant(),
12263                delta: oxicode_sdk::StreamDelta::Text("I will check.".into()),
12264            },
12265            &mut state,
12266        );
12267        map_agent_event(
12268            &handle,
12269            AgentEvent::MessageEnd {
12270                message: assistant(),
12271            },
12272            &mut state,
12273        );
12274        map_agent_event(
12275            &handle,
12276            AgentEvent::ToolExecutionStart {
12277                tool_call_id: "tc1".into(),
12278                tool_name: "bash".into(),
12279                args: serde_json::json!({"command": "echo hi"}),
12280                intent: None,
12281                context: None,
12282            },
12283            &mut state,
12284        );
12285        map_agent_event(
12286            &handle,
12287            AgentEvent::ToolExecutionEnd {
12288                tool_call_id: "tc1".into(),
12289                tool_name: "bash".into(),
12290                intent: None,
12291                result: oxicode_ai::ToolResult {
12292                    tool_call_id: "tc1".into(),
12293                    content: "ls output".into(),
12294                    status: "success".into(),
12295                },
12296                is_error: false,
12297            },
12298            &mut state,
12299        );
12300        map_agent_event(
12301            &handle,
12302            AgentEvent::MessageStart {
12303                message: assistant(),
12304            },
12305            &mut state,
12306        );
12307        map_agent_event(
12308            &handle,
12309            AgentEvent::MessageUpdate {
12310                message: assistant(),
12311                delta: oxicode_sdk::StreamDelta::Text("All done.".into()),
12312            },
12313            &mut state,
12314        );
12315        map_agent_event(
12316            &handle,
12317            AgentEvent::MessageEnd {
12318                message: assistant(),
12319            },
12320            &mut state,
12321        );
12322        apply_all(&mut state, &mut rx);
12323
12324        let text: String = state
12325            .transcript
12326            .iter()
12327            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12328            .collect::<Vec<_>>()
12329            .join("|");
12330        assert!(
12331            text.contains("$ echo hi"),
12332            "box header shows the shell command: {text}"
12333        );
12334        assert!(
12335            text.contains("Output") && text.contains("ls output"),
12336            "labeled divider separates the call from its output: {text}"
12337        );
12338        assert!(
12339            text.contains("\u{256D}") && text.contains("\u{2570}"),
12340            "rounded top and bottom borders close the box: {text}"
12341        );
12342        // The whole box is ONE block: folding and scrollback commits stay
12343        // atomic per call.
12344        let block_ids: std::collections::HashSet<usize> = state
12345            .transcript
12346            .iter()
12347            .filter(|l| l.kind == InlineMessageKind::Tool)
12348            .map(|l| l.block_id)
12349            .collect();
12350        assert_eq!(block_ids.len(), 1, "one tool call = one block");
12351    }
12352    #[test]
12353    fn first_text_delta_overrides_thinking_stage_with_generating_response() {
12354        let mut state = RenderState::default();
12355        let (handle, _cmd_rx) = fresh_handle();
12356
12357        // The real streaming path emits Thinking and Text deltas as
12358        // `AgentEvent::MessageUpdate { delta: StreamDelta::* }`
12359        // (oxicode-agent/src/agent_loop/streaming.rs:277-280). TextChunk
12360        // is legacy and no producer emits it. The Text arm is the
12361        // lifecycle owner that moves the stage off `thinking…`.
12362        map_agent_event(
12363            &handle,
12364            AgentEvent::MessageUpdate {
12365                message: assistant(),
12366                delta: oxicode_sdk::StreamDelta::Thinking("considering".into()),
12367            },
12368            &mut state,
12369        );
12370        map_agent_event(
12371            &handle,
12372            AgentEvent::MessageUpdate {
12373                message: assistant(),
12374                delta: oxicode_sdk::StreamDelta::Text("hi".into()),
12375            },
12376            &mut state,
12377        );
12378
12379        assert_eq!(
12380            state.reasoning_stage.as_deref(),
12381            Some("generating response"),
12382            "first Text delta must move the stage off `thinking\u{2026}`"
12383        );
12384    }
12385
12386    fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
12387        while let Ok(cmd) = rx.try_recv() {
12388            apply_command(state, cmd);
12389        }
12390    }
12391
12392    #[test]
12393    fn message_end_replaces_the_streamed_block_without_duplicates() {
12394        let mut state = RenderState::default();
12395        let (handle, mut rx) = fresh_handle();
12396
12397        map_agent_event(
12398            &handle,
12399            AgentEvent::MessageStart {
12400                message: assistant(),
12401            },
12402            &mut state,
12403        );
12404        map_agent_event(
12405            &handle,
12406            AgentEvent::MessageUpdate {
12407                message: assistant(),
12408                delta: oxicode_sdk::StreamDelta::Text("para one".into()),
12409            },
12410            &mut state,
12411        );
12412        map_agent_event(
12413            &handle,
12414            AgentEvent::MessageUpdate {
12415                message: assistant(),
12416                delta: oxicode_sdk::StreamDelta::Text("\n\npara two".into()),
12417            },
12418            &mut state,
12419        );
12420        map_agent_event(
12421            &handle,
12422            AgentEvent::MessageEnd {
12423                message: assistant_with_text("para one\n\npara two"),
12424            },
12425            &mut state,
12426        );
12427        apply_all(&mut state, &mut rx);
12428
12429        let text: String = state
12430            .transcript
12431            .iter()
12432            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12433            .collect::<Vec<_>>()
12434            .join(" ");
12435        assert_eq!(
12436            text.matches("para one").count(),
12437            1,
12438            "the markdown re-render must fully replace the streamed raw lines: {text}"
12439        );
12440    }
12441
12442    #[test]
12443    fn consecutive_messages_stream_into_separate_blocks() {
12444        let mut state = RenderState::default();
12445        let (handle, mut rx) = fresh_handle();
12446
12447        for body in ["first answer", "second answer"] {
12448            map_agent_event(
12449                &handle,
12450                AgentEvent::MessageStart {
12451                    message: assistant(),
12452                },
12453                &mut state,
12454            );
12455            map_agent_event(
12456                &handle,
12457                AgentEvent::MessageUpdate {
12458                    message: assistant(),
12459                    delta: oxicode_sdk::StreamDelta::Text(body.into()),
12460                },
12461                &mut state,
12462            );
12463            map_agent_event(
12464                &handle,
12465                AgentEvent::MessageEnd {
12466                    message: assistant_with_text(body),
12467                },
12468                &mut state,
12469            );
12470        }
12471        apply_all(&mut state, &mut rx);
12472
12473        let joined = state
12474            .transcript
12475            .iter()
12476            .map(|l| {
12477                l.segments
12478                    .iter()
12479                    .map(|s| s.text.as_str())
12480                    .collect::<String>()
12481            })
12482            .collect::<Vec<_>>()
12483            .join("|");
12484        assert!(
12485            joined.contains("first answer") && joined.contains("second answer"),
12486            "both messages survive: {joined}"
12487        );
12488        assert!(
12489            !joined.contains("first answersecond answer"),
12490            "a new message must not append into the previous message's line: {joined}"
12491        );
12492    }
12493
12494    #[test]
12495    fn text_deltas_render_markdown_live_not_raw() {
12496        let mut state = RenderState::default();
12497        let (handle, mut rx) = fresh_handle();
12498        map_agent_event(
12499            &handle,
12500            AgentEvent::MessageStart {
12501                message: assistant(),
12502            },
12503            &mut state,
12504        );
12505        map_agent_event(
12506            &handle,
12507            AgentEvent::MessageUpdate {
12508                message: assistant(),
12509                delta: oxicode_sdk::StreamDelta::Text("a **bold** claim".into()),
12510            },
12511            &mut state,
12512        );
12513        apply_all(&mut state, &mut rx);
12514        type_out_stream(&mut state);
12515
12516        let text = state
12517            .transcript
12518            .iter()
12519            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12520            .collect::<Vec<_>>()
12521            .join(" ");
12522        assert!(
12523            !text.contains("**"),
12524            "the live stream must render markdown, not raw syntax: {text}"
12525        );
12526        assert!(text.contains("bold"), "content survives: {text}");
12527    }
12528
12529    #[test]
12530    fn message_end_does_not_reflow_the_streamed_block() {
12531        let mut state = RenderState::default();
12532        let (handle, mut rx) = fresh_handle();
12533        map_agent_event(
12534            &handle,
12535            AgentEvent::MessageStart {
12536                message: assistant(),
12537            },
12538            &mut state,
12539        );
12540        map_agent_event(
12541            &handle,
12542            AgentEvent::MessageUpdate {
12543                message: assistant(),
12544                delta: oxicode_sdk::StreamDelta::Text("hello **world**".into()),
12545            },
12546            &mut state,
12547        );
12548        apply_all(&mut state, &mut rx);
12549        type_out_stream(&mut state);
12550        let streamed: Vec<String> = state
12551            .transcript
12552            .iter()
12553            .map(|l| {
12554                l.segments
12555                    .iter()
12556                    .map(|s| s.text.as_str())
12557                    .collect::<String>()
12558            })
12559            .collect();
12560
12561        map_agent_event(
12562            &handle,
12563            AgentEvent::MessageEnd {
12564                message: assistant_with_text("hello **world**"),
12565            },
12566            &mut state,
12567        );
12568        apply_all(&mut state, &mut rx);
12569        let final_: Vec<String> = state
12570            .transcript
12571            .iter()
12572            .map(|l| {
12573                l.segments
12574                    .iter()
12575                    .map(|s| s.text.as_str())
12576                    .collect::<String>()
12577            })
12578            .collect();
12579        assert_eq!(
12580            streamed, final_,
12581            "MessageEnd must not re-render what the live stream already shows"
12582        );
12583    }
12584
12585    #[test]
12586    fn final_message_renders_the_authoritative_tail() {
12587        // Regression: providers can coalesce the stream tail into the
12588        // final Done message without a matching delta (the Done message
12589        // replaces the accumulated partial in agent_loop/streaming.rs).
12590        // Rendering the final block from the delta buffers lost that
12591        // tail until the next prompt rebuilt history from the session.
12592        let mut state = RenderState::default();
12593        let (handle, mut rx) = fresh_handle();
12594        map_agent_event(
12595            &handle,
12596            AgentEvent::MessageStart {
12597                message: assistant(),
12598            },
12599            &mut state,
12600        );
12601        map_agent_event(
12602            &handle,
12603            AgentEvent::MessageUpdate {
12604                message: assistant(),
12605                delta: oxicode_sdk::StreamDelta::Text("visible prefix ".into()),
12606            },
12607            &mut state,
12608        );
12609        apply_all(&mut state, &mut rx);
12610        type_out_stream(&mut state);
12611
12612        map_agent_event(
12613            &handle,
12614            AgentEvent::MessageEnd {
12615                message: assistant_with_text("visible prefix HIDDEN-TAIL"),
12616            },
12617            &mut state,
12618        );
12619        apply_all(&mut state, &mut rx);
12620        let text: String = state
12621            .transcript
12622            .iter()
12623            .map(|l| {
12624                l.segments
12625                    .iter()
12626                    .map(|s| s.text.as_str())
12627                    .collect::<String>()
12628            })
12629            .collect();
12630        assert!(
12631            text.contains("HIDDEN-TAIL"),
12632            "the final message is authoritative — its tail must render: {text}"
12633        );
12634        assert_eq!(
12635            text.matches("visible prefix").count(),
12636            1,
12637            "no duplicated block: {text}"
12638        );
12639    }
12640
12641    #[test]
12642    fn streamed_body_renders_only_the_revealed_prefix() {
12643        let mut state = RenderState::default();
12644        state.message_buffer = "hello world".to_string();
12645        state.stream_reveal = 5; // bytes — "hello"
12646        let lines = render_streamed_message(&mut state);
12647        let text: String = lines
12648            .iter()
12649            .map(|l| l.iter().map(|s| s.text.as_str()).collect::<String>())
12650            .collect();
12651        assert!(text.contains("hello"), "revealed prefix renders: {text}");
12652        assert!(!text.contains("world"), "unrevealed text waits: {text}");
12653    }
12654
12655    #[test]
12656    fn advance_stream_reveal_types_out_in_bounded_steps() {
12657        let mut state = RenderState::default();
12658        state.stream_anchor = Some(0);
12659        state.message_buffer = "x".repeat(6000);
12660        state.stream_reveal = 0;
12661
12662        assert!(advance_stream_reveal(&mut state), "first tick paints");
12663        assert!(
12664            state.stream_reveal > 0 && state.stream_reveal < 6000,
12665            "bounded step, not a lump: {}",
12666            state.stream_reveal
12667        );
12668        let transcript_after_step: usize = state.transcript.len();
12669        assert!(
12670            transcript_after_step > 0,
12671            "the revealed prefix lands in the transcript"
12672        );
12673
12674        while advance_stream_reveal(&mut state) {}
12675        assert_eq!(
12676            state.stream_reveal, 6000,
12677            "repeated ticks drain the backlog completely"
12678        );
12679    }
12680
12681    /// Drive the typewriter to completion (test-side stand-in for the
12682    /// render tick).
12683    fn type_out_stream(state: &mut RenderState) {
12684        while advance_stream_reveal(state) {}
12685    }
12686
12687    #[test]
12688    fn message_end_clears_reasoning_stage() {
12689        let mut state = RenderState::default();
12690        let (handle, _cmd_rx) = fresh_handle();
12691        state.reasoning_stage = Some("thinking\u{2026}".into());
12692
12693        map_agent_event(
12694            &handle,
12695            AgentEvent::MessageEnd {
12696                message: assistant(),
12697            },
12698            &mut state,
12699        );
12700
12701        assert!(
12702            state.reasoning_stage.is_none(),
12703            "MessageEnd must clear the reasoning stage so follow-ups / tips can render"
12704        );
12705    }
12706
12707    #[test]
12708    fn run_tracker_spans_the_whole_tool_loop() {
12709        let mut state = RenderState::default();
12710        let (handle, _cmd_rx) = fresh_handle();
12711
12712        map_agent_event(
12713            &handle,
12714            AgentEvent::AgentStart {
12715                prompts: vec![],
12716                session_id: None,
12717            },
12718            &mut state,
12719        );
12720        assert!(
12721            state.active_run.is_some(),
12722            "AgentStart opens the run tracker"
12723        );
12724
12725        map_agent_event(
12726            &handle,
12727            AgentEvent::MessageStart {
12728                message: assistant(),
12729            },
12730            &mut state,
12731        );
12732        map_agent_event(
12733            &handle,
12734            AgentEvent::ToolExecutionStart {
12735                tool_call_id: "tc-1".into(),
12736                tool_name: "read".into(),
12737                args: serde_json::json!({}),
12738                intent: None,
12739                context: None,
12740            },
12741            &mut state,
12742        );
12743        map_agent_event(
12744            &handle,
12745            AgentEvent::MessageEnd {
12746                message: assistant(),
12747            },
12748            &mut state,
12749        );
12750
12751        // Turn boundary: the stage may be cleared, but the run tracker —
12752        // with its progress facts — stays live until AgentEnd.
12753        let run = state.active_run.as_ref().expect("run stays live");
12754        assert_eq!(run.turn, 1, "MessageStart counts a turn");
12755        assert_eq!(run.tool_calls, 1, "ToolExecutionStart counts a call");
12756
12757        map_agent_event(
12758            &handle,
12759            AgentEvent::AgentEnd {
12760                messages: vec![],
12761                stop_reason: None,
12762                session_id: None,
12763            },
12764            &mut state,
12765        );
12766        assert!(state.active_run.is_none(), "AgentEnd closes the tracker");
12767        assert!(
12768            state.reasoning_stage.is_none(),
12769            "AgentEnd releases the indicator row"
12770        );
12771    }
12772
12773    #[test]
12774    fn message_end_releases_the_stream_anchor() {
12775        let mut state = RenderState::default();
12776        let (handle, mut rx) = fresh_handle();
12777        map_agent_event(
12778            &handle,
12779            AgentEvent::MessageStart {
12780                message: assistant(),
12781            },
12782            &mut state,
12783        );
12784        map_agent_event(
12785            &handle,
12786            AgentEvent::MessageUpdate {
12787                message: assistant(),
12788                delta: oxicode_sdk::StreamDelta::Text("done".into()),
12789            },
12790            &mut state,
12791        );
12792        map_agent_event(
12793            &handle,
12794            AgentEvent::MessageEnd {
12795                message: assistant(),
12796            },
12797            &mut state,
12798        );
12799        while let Ok(cmd) = rx.try_recv() {
12800            apply_command(&mut state, cmd);
12801        }
12802        assert!(
12803            state.stream_anchor.is_none(),
12804            "MessageEnd finalizes the message — the anchor must release so the finished block can commit to scrollback"
12805        );
12806    }
12807}
12808
12809#[cfg(test)]
12810mod composer_border_tests {
12811    //! The composer's top border is the single chrome surface after the
12812    //! status bar's removal: session facts + brain health, no app badge.
12813    use super::*;
12814
12815    fn spans_to_string(line: &Line<'_>) -> String {
12816        line.spans.iter().map(|s| s.content.as_ref()).collect()
12817    }
12818
12819    #[test]
12820    fn composer_border_has_no_app_badge() {
12821        let mut state = RenderState::default();
12822        state.header_context.provider = "prov".to_string();
12823        state.header_context.model = "prov/m-1".to_string();
12824
12825        let text = spans_to_string(&composer_context_line(&state, 200));
12826
12827        assert!(
12828            text.starts_with("MODEL "),
12829            "model leads with no leading separator: {text}"
12830        );
12831        assert!(
12832            text.contains("MODEL m-1"),
12833            "provider prefix is stripped from the model: {text}"
12834        );
12835    }
12836
12837    #[test]
12838    fn composer_border_fields_drop_by_width() {
12839        let mut state = RenderState::default();
12840        state.header_context.provider = "prov".to_string();
12841        state.header_context.model = "prov/m-1".to_string();
12842
12843        // Narrow: only the model survives; wide: context usage joins.
12844        let narrow = spans_to_string(&composer_context_line(&state, 60));
12845        assert!(
12846            narrow.contains("MODEL ") && !narrow.contains("CTX "),
12847            "narrow keeps the model only: {narrow}"
12848        );
12849        let wide = spans_to_string(&composer_context_line(&state, 140));
12850        assert!(wide.contains("CTX "), "wide carries context usage: {wide}");
12851    }
12852
12853    #[test]
12854    fn model_chips_follow_model_switch() {
12855        let mut state = RenderState::default();
12856        assert_eq!(state.context_window, 128_000, "default before sync");
12857
12858        // A 1M-context model must replace both the MODEL field and the
12859        // CTX denominator (regression: the denominator was written once
12860        // at startup and never updated).
12861        apply_model_to_chips(&mut state, "google/gemini-2.5-pro", 1_048_576);
12862        assert_eq!(state.header_context.provider, "google");
12863        assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12864        assert_eq!(
12865            state.header_context.editor_context.as_deref(),
12866            Some("google/gemini-2.5-pro")
12867        );
12868        assert_eq!(state.context_window, 1_048_576);
12869
12870        let wide = spans_to_string(&composer_context_line(&state, 140));
12871        assert!(
12872            wide.contains("CTX 0/1048.5K"),
12873            "CTX chip renders the synced denominator: {wide}"
12874        );
12875
12876        // Empty id is a no-op.
12877        apply_model_to_chips(&mut state, "", 999);
12878        assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12879
12880        // Zero window (unknown model): the MODEL chip follows the switch,
12881        // the CTX denominator keeps the last known value instead of 0.
12882        apply_model_to_chips(&mut state, "zai/glm-5.1", 0);
12883        assert_eq!(state.header_context.model, "zai/glm-5.1");
12884        assert_eq!(state.context_window, 1_048_576);
12885    }
12886
12887    #[test]
12888    fn plain_segments_render_in_their_kind_color_not_response() {
12889        let styles = active_styles();
12890        let user_color = color_from_anstyle(styles.user.get_fg_color());
12891        let response = color_from_anstyle(styles.response.get_fg_color());
12892        let line = |kind| TranscriptLine {
12893            kind,
12894            segments: vec![plain_segment("body")],
12895            block_id: 0,
12896        };
12897
12898        let user_line = line(InlineMessageKind::User);
12899        let user = transcript_line_marked(&user_line, &styles, false, false, false, true, 80);
12900        assert_eq!(
12901            user.spans[0].style.fg,
12902            Some(user_color),
12903            "user text must read in the user color — response-ink makes turns indistinguishable"
12904        );
12905
12906        let agent_line = line(InlineMessageKind::Agent);
12907        let agent = transcript_line_marked(&agent_line, &styles, false, false, false, true, 80);
12908        assert_eq!(agent.spans[0].style.fg, Some(response));
12909    }
12910}
12911#[cfg(test)]
12912mod transcript_turn_tests {
12913    //! Speaker identity is structural (accent rail + weight), never prose
12914    //! labels. See docs/superpowers/specs/2026-08-20-transcript-turn-rendering-design.md.
12915    use super::*;
12916    fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
12917        TranscriptLine {
12918            kind,
12919            segments: vec![plain_segment(text)],
12920            block_id,
12921        }
12922    }
12923
12924    fn spans_to_string(line: &Line<'_>) -> String {
12925        line.spans.iter().map(|s| s.content.as_ref()).collect()
12926    }
12927
12928    #[test]
12929    fn user_lines_are_bold_primary_without_prefix() {
12930        let styles = active_styles();
12931        let line = tl(InlineMessageKind::User, "refactor the parser", 0);
12932        let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12933        assert_eq!(
12934            rendered.spans.len(),
12935            1,
12936            "plain style renders no prefix span"
12937        );
12938        assert_eq!(
12939            spans_to_string(&rendered),
12940            "refactor the parser",
12941            "user text renders as typed, no glyph"
12942        );
12943        assert!(
12944            rendered.spans[0]
12945                .style
12946                .add_modifier
12947                .contains(Modifier::BOLD),
12948            "user body is the only bold transcript text"
12949        );
12950    }
12951
12952    #[test]
12953    fn agent_tool_and_shell_lines_have_no_prefix() {
12954        let styles = active_styles();
12955        for (kind, label) in [
12956            (InlineMessageKind::Agent, "agent"),
12957            (InlineMessageKind::Tool, "tool"),
12958            (InlineMessageKind::Pty, "shell"),
12959        ] {
12960            let line = tl(kind, &format!("{label}-content"), 0);
12961            let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12962            assert_eq!(
12963                rendered.spans.len(),
12964                1,
12965                "{label} lines carry no marker spans"
12966            );
12967            assert_eq!(spans_to_string(&rendered), format!("{label}-content"));
12968        }
12969    }
12970
12971    #[test]
12972    fn system_labels_render_on_block_start_only() {
12973        let styles = active_styles();
12974        let line = tl(InlineMessageKind::Error, "boom", 0);
12975
12976        let head = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12977        assert_eq!(spans_to_string(&head), "error: boom");
12978
12979        let body = transcript_line_marked(&line, &styles, false, false, false, false, 80);
12980        assert_eq!(
12981            spans_to_string(&body),
12982            "boom",
12983            "continuation lines drop the label"
12984        );
12985    }
12986
12987    #[test]
12988    fn folded_head_keeps_the_block_label() {
12989        let styles = active_styles();
12990        let line = tl(InlineMessageKind::Error, "boom", 0);
12991        let rendered = transcript_line_marked(&line, &styles, true, false, false, false, 80);
12992        assert_eq!(
12993            spans_to_string(&rendered),
12994            "[+] error: boom",
12995            "a collapsed block stays identifiable"
12996        );
12997    }
12998
12999    #[test]
13000    fn transcript_line_marked_clamps_to_width() {
13001        // Write-path width invariant: even if a 300-char segment lands on
13002        // a 40-col viewport, the rendered Line never overflows.
13003        let styles = active_styles();
13004        let big: String = "x".repeat(300);
13005        let line = tl(InlineMessageKind::Agent, &big, 0);
13006        let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 40);
13007        assert!(
13008            rendered.width() <= 40,
13009            "transcript row overflowed the terminal width: rendered.width()={}",
13010            rendered.width()
13011        );
13012    }
13013}
13014
13015#[cfg(test)]
13016mod scrollback_commit_tests {
13017    //! Host-scrollback committing (inline-viewport pattern — peer parity
13018    //! with Claude Code / pi): finalized transcript blocks are printed
13019    //! into the terminal's real scrollback so native scroll-up shows the
13020    //! conversation. Commits are block-atomic, never touch the anchored
13021    //! streaming block, and pause while the user browses.
13022    use super::*;
13023
13024    fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
13025        TranscriptLine {
13026            kind,
13027            segments: vec![plain_segment(text)],
13028            block_id,
13029        }
13030    }
13031
13032    /// 6 agent blocks × 2 lines = 12 entries; one display row each at
13033    /// width 80 (no spacers — agent flow stays contiguous).
13034    fn long_transcript() -> Vec<TranscriptLine> {
13035        (0..6)
13036            .flat_map(|b| {
13037                [
13038                    tl(InlineMessageKind::Agent, &format!("b{b}-line-one"), b),
13039                    tl(InlineMessageKind::Agent, &format!("b{b}-line-two"), b),
13040                ]
13041            })
13042            .collect()
13043    }
13044
13045    #[test]
13046    fn commit_plan_sheds_oldest_blocks_and_keeps_the_tail() {
13047        let state = RenderState {
13048            transcript: long_transcript(),
13049            ..Default::default()
13050        };
13051        let styles = active_styles();
13052        let display = build_transcript_display(&state, &styles, 0, 80);
13053        // 12 rows, keep 4 → 8 rows commit; entry 8 starts block b4, so
13054        // the boundary is already block-atomic (b3 ends at entry 7).
13055        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, None).expect("plan");
13056        assert_eq!(plan.rows, 8, "12 rows total, keep 4 → commit 8");
13057        assert_eq!(plan.new_committed_entries, 8);
13058    }
13059
13060    #[test]
13061    fn commit_plan_never_splits_a_block() {
13062        // Blocks of 3; the keep-window boundary lands mid-block and must
13063        // snap back to the block start.
13064        let transcript: Vec<TranscriptLine> = (0..3)
13065            .flat_map(|b| {
13066                (0..3).map(move |i| tl(InlineMessageKind::Agent, &format!("b{b}-{i}"), b))
13067            })
13068            .collect();
13069        let state = RenderState {
13070            transcript,
13071            ..Default::default()
13072        };
13073        let styles = active_styles();
13074        let display = build_transcript_display(&state, &styles, 0, 80);
13075        // 9 rows; keep 5 → limit 4 → the boundary would split b1
13076        // (items 3,4,5).
13077        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 5, None).expect("plan");
13078        assert_eq!(
13079            plan.new_committed_entries, 3,
13080            "boundary snaps to block start"
13081        );
13082        assert_eq!(plan.rows, 3);
13083    }
13084
13085    #[test]
13086    fn commit_plan_excludes_the_streaming_anchor() {
13087        let state = RenderState {
13088            transcript: long_transcript(),
13089            stream_anchor: Some(4),
13090            ..Default::default()
13091        };
13092        let styles = active_styles();
13093        let display = build_transcript_display(&state, &styles, 0, 80);
13094        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, state.stream_anchor)
13095            .expect("plan");
13096        assert!(
13097            plan.new_committed_entries <= 4,
13098            "nothing at/after the anchored (streaming) block commits"
13099        );
13100    }
13101    #[test]
13102    fn committed_entries_floor_the_live_render() {
13103        let mut state = RenderState::default();
13104        state.transcript = long_transcript();
13105        state.committed_entries = 8;
13106        let backend = ratatui::backend::TestBackend::new(80, 24);
13107        let mut terminal = Terminal::new(backend).expect("backend");
13108        terminal
13109            .draw(|f| render_frame(f, &state, &unused_handle()))
13110            .expect("draw");
13111        let buf = terminal.backend().buffer();
13112        let area = buf.area();
13113        let mut rendered = String::new();
13114        for y in 0..area.height {
13115            for x in 0..area.width {
13116                if let Some(cell) = buf.cell((x, y)) {
13117                    rendered.push_str(cell.symbol());
13118                }
13119            }
13120            rendered.push('\n');
13121        }
13122        assert!(
13123            !rendered.contains("b0-line-one"),
13124            "committed blocks leave the viewport"
13125        );
13126        assert!(rendered.contains("b5-line"), "the live tail stays");
13127    }
13128
13129    #[test]
13130    fn search_skips_committed_entries() {
13131        let mut state = RenderState::default();
13132        state.transcript = long_transcript();
13133        state.committed_entries = 8;
13134        state.start_search("line-one");
13135        let s = state.search.as_ref().expect("search open");
13136        assert!(
13137            s.matches.iter().all(|&i| i >= 8),
13138            "matches confined to the live region: {:?}",
13139            s.matches
13140        );
13141        assert!(!s.matches.is_empty());
13142    }
13143
13144    fn unused_handle() -> InlineHandle {
13145        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13146        InlineHandle::new_for_tests(tx)
13147    }
13148
13149    #[test]
13150    fn commit_plan_noop_when_tail_fits() {
13151        let state = RenderState {
13152            transcript: long_transcript(),
13153            ..Default::default()
13154        };
13155        let styles = active_styles();
13156        let display = build_transcript_display(&state, &styles, 0, 80);
13157        assert!(scrollback_commit_plan(&display, &state.transcript, 80, 12, None).is_none());
13158    }
13159
13160    #[test]
13161    fn oversized_block_commits_its_head_at_line_granularity() {
13162        // One 30-row block + a trailing 2-row block, viewport keeps 10:
13163        // the big block cannot fit the live region, so its head commits.
13164        let mut transcript: Vec<TranscriptLine> = (0..30)
13165            .map(|i| tl(InlineMessageKind::Agent, &format!("big-{i:02}"), 0))
13166            .collect();
13167        transcript.push(tl(InlineMessageKind::Agent, "tail-a", 1));
13168        transcript.push(tl(InlineMessageKind::Agent, "tail-b", 1));
13169        let state = RenderState {
13170            transcript,
13171            ..Default::default()
13172        };
13173        let styles = active_styles();
13174        let display = build_transcript_display(&state, &styles, 0, 80);
13175        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 10, None).expect("plan");
13176        assert_eq!(
13177            plan.new_committed_entries, 22,
13178            "32 rows, keep 10 → head 22 rows commit"
13179        );
13180        assert_eq!(plan.rows, 22);
13181    }
13182
13183    #[test]
13184    fn rebuild_only_on_width_change() {
13185        // Height-only resize: nothing to rebuild — committed transcript
13186        // lives in the host terminal's scrollback at the width it was
13187        // printed at; the live viewport just changes rows.
13188        assert!(!should_rebuild_scrollback(80, 80, 24, 30));
13189        // Width grew: re-commit so freshly finalized rows wrap to the
13190        // new width and the old frozen scrollback must go (CSI 3J).
13191        assert!(should_rebuild_scrollback(80, 100, 24, 24));
13192        // Width shrank: same — the old layout no longer fits.
13193        assert!(should_rebuild_scrollback(100, 80, 30, 24));
13194        // Sentinel (final-review finding 1): prev_w == 0 means "never
13195        // measured" — no frame was drawn at a known width, so there is
13196        // no stale-width scrollback and the wipe must NOT fire, no
13197        // matter what the new width is.
13198        assert!(!should_rebuild_scrollback(0, 100, 24, 24));
13199        assert!(!should_rebuild_scrollback(0, 80, 24, 24));
13200    }
13201
13202    #[test]
13203    fn force_flush_boundary_is_everything() {
13204        // Force-flush on exit ignores viewport fit and commits the
13205        // whole finalized prefix — every display row, regardless of
13206        // what fits in the live region.
13207        assert_eq!(plan_full_flush(0), 0);
13208        assert_eq!(plan_full_flush(8), 8);
13209        assert_eq!(plan_full_flush(40), 40);
13210    }
13211}
13212
13213#[cfg(test)]
13214mod tool_box_tests {
13215    //! omp-style tool boxes: borders, divider labels, and — critically —
13216    //! display-width math. Korean text is width-2 per glyph; a char-count
13217    //! wrap or pad misaligns the right border instantly.
13218    use super::*;
13219
13220    fn row_text(row: &[InlineSegment]) -> String {
13221        row.iter().map(|s| s.text.as_str()).collect()
13222    }
13223
13224    #[test]
13225    fn korean_rows_keep_the_right_border_aligned() {
13226        // w=20 → inner=16 cells. "한글" = 4 cells per word.
13227        let rows = tool_box_rows(
13228            "한글테스트 명령어",
13229            20,
13230            InlineTextStyle::default(),
13231            anstyle::Color::Ansi(anstyle::AnsiColor::White),
13232        );
13233        for row in &rows {
13234            let text = row_text(row);
13235            assert_eq!(text.width(), 20, "row must fill exactly 20 cells: {text:?}");
13236            assert!(text.starts_with('\u{2502}'), "left border: {text:?}");
13237            assert!(text.ends_with('\u{2502}'), "right border: {text:?}");
13238        }
13239        assert!(!rows.is_empty());
13240        // Wrapping counts cells, not chars: 9 Korean chars = 18 cells >
13241        // 16 inner → two rows.
13242        assert_eq!(rows.len(), 2, "wraps by display width");
13243    }
13244
13245    #[test]
13246    fn divider_carries_the_label() {
13247        let seg = tool_box_divider(
13248            "Output",
13249            30,
13250            anstyle::Color::Ansi(anstyle::AnsiColor::White),
13251        );
13252        let text = row_text(&seg);
13253        assert!(
13254            text.starts_with("\u{251C}\u{2500} Output"),
13255            "label after ├─: {text:?}"
13256        );
13257
13258        assert!(text.ends_with('\u{2524}'), "closes with ┤: {text:?}");
13259        assert_eq!(text.width(), 30, "divider fills the box width");
13260    }
13261}
13262
13263#[cfg(test)]
13264mod tool_box_width_tests {
13265    //! Box width must equal the LIVE transcript content width (layout
13266    //! gutters + scrollbar column). At the raw terminal width every
13267    //! row's right border wraps onto the next visual line.
13268    use super::*;
13269
13270    #[test]
13271    fn tool_box_width_matches_live_content_width() {
13272        let state = RenderState {
13273            viewport_width: 100,
13274            ..Default::default()
13275        };
13276        // CHAT_LAYOUT insets 1 column per side; the in-app scrollbar is
13277        // gone (native scrollback owns history): 100 - 2 = 98.
13278        assert_eq!(tool_box_width(&state), 98);
13279    }
13280}
13281
13282#[test]
13283fn tool_box_rows_expand_tabs_so_borders_align() {
13284    // The read tool numbers lines as `{:>6}\t{content}`. unicode-width 0.2
13285    // counts the tab as 1 (`UnicodeWidthStr::width`), but ratatui drops it
13286    // when filling cells — a row built with tab width in its pad math
13287    // renders one column short and the right border lands inside the box.
13288    let chunk = format!("{:>6}\t{}", 1, "[package]");
13289    let rows = tool_box_rows(
13290        &chunk,
13291        176,
13292        InlineTextStyle::default(),
13293        anstyle::Color::Ansi(anstyle::AnsiColor::White),
13294    );
13295    assert_eq!(rows.len(), 1);
13296    for seg in &rows[0] {
13297        assert!(
13298            !seg.text.contains('\t'),
13299            "tabs must be expanded: {:?}",
13300            seg.text
13301        );
13302    }
13303    let built: usize = rows[0]
13304        .iter()
13305        .map(|s| UnicodeWidthStr::width(s.text.as_str()))
13306        .sum();
13307    assert_eq!(built, 176, "built width must equal the box width exactly");
13308}
13309
13310#[cfg(test)]
13311mod contextual_hint_tests {
13312    //! The static shortcuts bar is gone; discoverability is contextual:
13313    //! the brain chip lives on the composer border, abort/quit hints
13314    //! appear only while a run is live or a quit is armed.
13315    use super::*;
13316
13317    #[test]
13318    fn brain_chip_lives_on_the_composer_border() {
13319        let mut state = RenderState::default();
13320        state.header_context.provider = "prov".to_string();
13321        state.header_context.model = "prov/m-1".to_string();
13322
13323        // Off (memory disabled) — no chip.
13324        let off = spans_to_string_border(&state);
13325        assert!(!off.contains("brain"), "chip hidden when off: {off}");
13326
13327        // Ok — right side of the border.
13328        state.brain = BrainChip::Ok;
13329        let ok = spans_to_string_border(&state);
13330        assert!(ok.contains("brain·ok"), "healthy chip on border: {ok}");
13331        assert!(
13332            ok.trim_end().ends_with("brain·ok"),
13333            "chip is right-aligned: {ok}"
13334        );
13335
13336        // Down — still renders.
13337        state.brain = BrainChip::Down;
13338        let down = spans_to_string_border(&state);
13339        assert!(down.contains("brain·down"), "degraded chip: {down}");
13340    }
13341
13342    #[test]
13343    fn brain_chip_does_not_erase_the_border_rule() {
13344        // Regression: the chip used to be space-padded into the fields
13345        // title. A title overwrites the border row for its full width,
13346        // so the padding erased the `─` rule right of the facts.
13347        let backend = ratatui::backend::TestBackend::new(80, 24);
13348        let mut terminal = Terminal::new(backend).expect("backend");
13349        let mut state = RenderState::default();
13350        state.header_context.provider = "prov".to_string();
13351        state.header_context.model = "prov/m-1".to_string();
13352        state.brain = BrainChip::Ok;
13353        terminal
13354            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13355            .expect("draw");
13356        let buf = terminal.backend().buffer();
13357        // The welcome card also prints "MODEL" when the transcript is
13358        // empty; the composer border row is the one with ` | ` field
13359        // separators.
13360        let border_row = (0..buf.area().height)
13361            .map(|y| {
13362                (0..buf.area().width)
13363                    .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
13364                    .collect::<String>()
13365            })
13366            .find(|row| row.contains("MODEL") && row.contains(" | "))
13367            .expect("composer border row rendered");
13368        let rule_count = border_row.chars().filter(|c| *c == '\u{2500}').count();
13369        assert!(
13370            rule_count >= 10,
13371            "the ─ rule must survive right of the facts: {border_row}"
13372        );
13373        assert!(
13374            border_row.contains("brain\u{b7}ok"),
13375            "chip still on the border: {border_row}"
13376        );
13377    }
13378
13379    #[test]
13380    fn run_indicator_stays_up_between_turns() {
13381        // Mid-run the stage is cleared at each turn boundary
13382        // (MessageEnd/TurnEnd); the run tracker must keep the indicator
13383        // row owned so it never flickers to the idle row — and it should
13384        // carry progress facts (spinner, turn/tool counts, elapsed).
13385        let backend = ratatui::backend::TestBackend::new(80, 24);
13386        let mut terminal = Terminal::new(backend).expect("backend");
13387        let state = RenderState {
13388            active_run: Some(RunState {
13389                started_at: std::time::Instant::now(),
13390                turn: 2,
13391                tool_calls: 3,
13392            }),
13393            reasoning_stage: None,
13394            ..Default::default()
13395        };
13396        terminal
13397            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13398            .expect("draw");
13399        let buf = terminal.backend().buffer();
13400        let row: String = (0..buf.area().width)
13401            .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13402            .collect();
13403        assert!(row.contains("RUNNING"), "row stays up between turns: {row}");
13404        assert!(row.contains("working"), "stage fallback label: {row}");
13405        assert!(row.contains("turn 2"), "turn count: {row}");
13406        assert!(row.contains("3 tool calls"), "tool count: {row}");
13407        assert!(row.contains("Esc abort"), "abort hint stays: {row}");
13408        assert!(
13409            RUN_SPINNER.iter().any(|f| row.contains(f)),
13410            "animated spinner frame: {row}"
13411        );
13412    }
13413
13414    #[test]
13415    fn spinner_frame_is_wall_clock_not_draw_count() {
13416        // Regression: the spinner advanced on FRAME_TICK (draw count).
13417        // Event bursts during streaming drive many draws per interval,
13418        // so the spinner raced. Animation frames must key on wall-clock
13419        // time — rapid back-to-back draws show the SAME frame.
13420        let state = || RenderState {
13421            active_run: Some(RunState::default()),
13422            reasoning_stage: None,
13423            ..Default::default()
13424        };
13425        let spinner_of = |s: &RenderState| -> Option<char> {
13426            let backend = ratatui::backend::TestBackend::new(80, 24);
13427            let mut terminal = Terminal::new(backend).expect("backend");
13428            terminal
13429                .draw(|f| render_frame(f, s, &unused_test_handle()))
13430                .expect("draw");
13431            let buf = terminal.backend().buffer();
13432            let row: String = (0..buf.area().width)
13433                .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13434                .collect();
13435            row.chars()
13436                .find(|c| RUN_SPINNER.iter().any(|f| f.starts_with(*c)))
13437        };
13438        // Two draws in the same animation period (sub-80ms apart, which
13439        // consecutive draws in one test always are).
13440        let first = spinner_of(&state());
13441        let second = spinner_of(&state());
13442        assert!(first.is_some(), "spinner renders");
13443        assert_eq!(
13444            first, second,
13445            "back-to-back draws must not advance the spinner"
13446        );
13447    }
13448
13449    #[test]
13450    fn elapsed_formats_minutes_beyond_60s() {
13451        assert_eq!(format_elapsed_secs(59), "59s");
13452        assert_eq!(format_elapsed_secs(60), "1m 00s");
13453        assert_eq!(format_elapsed_secs(125), "2m 05s");
13454    }
13455
13456    #[test]
13457    fn reasoning_row_carries_the_abort_hint() {
13458        let backend = ratatui::backend::TestBackend::new(80, 24);
13459        let mut terminal = Terminal::new(backend).expect("backend");
13460        let state = RenderState {
13461            reasoning_stage: Some("thinking\u{2026}".into()),
13462            ..Default::default()
13463        };
13464        terminal
13465            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13466            .expect("draw");
13467        let buf = terminal.backend().buffer();
13468        let row: String = (0..buf.area().width)
13469            .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13470            .collect();
13471        assert!(
13472            row.contains("Esc abort"),
13473            "streaming shows the contextual abort hint: {row}"
13474        );
13475    }
13476
13477    #[test]
13478    fn pending_quit_owns_the_hint_row() {
13479        let backend = ratatui::backend::TestBackend::new(80, 24);
13480        let mut terminal = Terminal::new(backend).expect("backend");
13481        let state = RenderState {
13482            pending_quit: true,
13483            ..Default::default()
13484        };
13485        terminal
13486            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13487            .expect("draw");
13488        let buf = terminal.backend().buffer();
13489
13490        let row: String = (0..buf.area().width)
13491            .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13492            .collect();
13493        assert!(
13494            row.contains("press Ctrl+C again to quit"),
13495            "armed quit shows its hint: {row}"
13496        );
13497    }
13498
13499    fn spans_to_string_border(state: &RenderState) -> String {
13500        let line = composer_context_line(state, 200);
13501        let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13502        let used = line.spans.iter().map(|s| s.width()).sum();
13503        if let Some(chip) = composer_brain_chip(state, 200, used) {
13504            assert_eq!(
13505                chip.alignment,
13506                Some(Alignment::Right),
13507                "the chip is its own right-aligned title"
13508            );
13509            text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13510        }
13511        text
13512    }
13513
13514    fn unused_test_handle() -> InlineHandle {
13515        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13516        InlineHandle::new_for_tests(tx)
13517    }
13518}
13519
13520#[cfg(test)]
13521mod trailing_breath_tests {
13522    //! The gap between the transcript and the composer is LAYOUT: the
13523    //! scrollback area reserves one blank row above the prompt, so the
13524    //! newest response never glues to the composer at any height — and
13525    //! the gap can't be windowed or committed away.
13526    use super::*;
13527
13528    #[test]
13529    fn display_ends_at_the_last_line_no_trailing_blank_item() {
13530        let state = RenderState {
13531            transcript: vec![TranscriptLine {
13532                kind: InlineMessageKind::Agent,
13533                segments: vec![plain_segment("answer")],
13534                block_id: 0,
13535            }],
13536            ..Default::default()
13537        };
13538        let styles = active_styles();
13539        let display = build_transcript_display(&state, &styles, 0, 80);
13540        assert_eq!(display.len(), 1, "the gap is layout, not a display item");
13541        assert!(display[0].line.is_some());
13542    }
13543
13544    #[test]
13545    fn scrollback_area_reserves_one_breath_row_above_the_composer() {
13546        let area = Rect {
13547            x: 0,
13548            y: 0,
13549            width: 100,
13550            height: 30,
13551        };
13552        let layout = super::super::frame_layout::compute_chrome(area);
13553        assert_eq!(
13554            layout.scrollback.bottom() + 1,
13555            layout.prompt.y,
13556            "exactly one row separates the transcript from the composer"
13557        );
13558        assert_eq!(
13559            super::super::frame_layout::scrollback_height(area),
13560            layout.scrollback.height,
13561            "the commit keep-rows must match the rendered area"
13562        );
13563    }
13564}
13565
13566#[cfg(test)]
13567mod nerd_icon_tests {
13568    //! `glyph_set = "nerd"` swaps the composer's text labels for Nerd
13569    //! Font private-use glyphs — never emoji. Default (unicode) keeps
13570    //! the text labels.
13571    use super::*;
13572
13573    fn border_text(state: &RenderState) -> String {
13574        let line = composer_context_line(state, 200);
13575        let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13576        let used = line.spans.iter().map(|s| s.width()).sum();
13577        if let Some(chip) = composer_brain_chip(state, 200, used) {
13578            text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13579        }
13580        text
13581    }
13582
13583    fn base_state() -> RenderState {
13584        let mut state = RenderState::default();
13585        state.header_context.provider = "prov".to_string();
13586        state.header_context.model = "prov/m-1".to_string();
13587        state.brain = BrainChip::Ok;
13588        state
13589    }
13590
13591    #[test]
13592    fn nerd_mode_replaces_labels_with_private_use_glyphs() {
13593        let mut state = base_state();
13594        state.glyph_set = crate::symbols::GlyphSet::Nerd;
13595        let text = border_text(&state);
13596        assert!(!text.contains("MODEL "), "text label gone: {text}");
13597        assert!(
13598            text.contains(crate::symbols::nerd::MODEL),
13599            "robot glyph for the model: {text}"
13600        );
13601        assert!(
13602            text.contains(crate::symbols::nerd::GIT),
13603            "git glyph present: {text}"
13604        );
13605        assert!(
13606            text.contains(crate::symbols::nerd::BRAIN),
13607            "brain glyph chip: {text}"
13608        );
13609        // No emoji ever: all swaps live in the private-use area
13610        // (U+E000–U+F8FF and the supplementary PUA planes).
13611        for ch in text.chars() {
13612            let cp = ch as u32;
13613            let private_use = (0xE000..=0xF8FF).contains(&cp)
13614                || (0xF0000..=0xFFFFD).contains(&cp)
13615                || (0x100000..=0x10FFFD).contains(&cp);
13616            assert!(
13617                !('\u{1F300}'..='\u{1FAFF}').contains(&ch) || !private_use,
13618                "sanity"
13619            );
13620        }
13621    }
13622
13623    #[test]
13624    fn unicode_default_keeps_text_labels() {
13625        let state = base_state();
13626        let text = border_text(&state);
13627        assert!(text.contains("MODEL "), "default keeps text: {text}");
13628        assert!(text.contains("brain\u{b7}ok"), "default chip text: {text}");
13629    }
13630}
13631#[cfg(test)]
13632mod glyph_cycle_tests {
13633    use crate::symbols::GlyphSet;
13634
13635    #[test]
13636    fn glyph_set_cycles_unicode_ascii_nerd() {
13637        assert_eq!(GlyphSet::Unicode.next(), GlyphSet::Ascii);
13638        assert_eq!(GlyphSet::Ascii.next(), GlyphSet::Nerd);
13639        assert_eq!(GlyphSet::Nerd.next(), GlyphSet::Unicode);
13640    }
13641}
13642
13643#[cfg(test)]
13644mod coalesce_draw_tests {
13645    //! Render coalescing: the event loop used to redraw on every iteration,
13646    //! causing a frame storm during token-stream bursts (one full
13647    //! `terminal.draw` per agent event). The fix is `coalesce_draw`: most
13648    //! arms gate the post-select draw behind a 50ms cadence; user-facing
13649    //! arms (keyboard, SIGINT, brain chip) raise `priority = true` for
13650    //! an immediate repaint.
13651    use super::*;
13652    use std::time::{Duration, Instant};
13653
13654    #[test]
13655    fn defer_within_interval() {
13656        let now = Instant::now();
13657        let last = now - Duration::from_millis(10);
13658        assert_eq!(
13659            coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13660            DrawDecision::Defer
13661        );
13662    }
13663
13664    #[test]
13665    fn draw_now_on_priority_even_within_interval() {
13666        let now = Instant::now();
13667        let last = now - Duration::from_millis(10);
13668        assert_eq!(
13669            coalesce_draw(last, true, DRAW_MIN_INTERVAL),
13670            DrawDecision::DrawNow
13671        );
13672    }
13673
13674    #[test]
13675    fn draw_now_when_interval_elapsed() {
13676        let now = Instant::now();
13677        let last = now - Duration::from_millis(60);
13678        assert_eq!(
13679            coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13680            DrawDecision::DrawNow
13681        );
13682    }
13683}
13684#[cfg(test)]
13685mod settings_panel_tests {
13686    use super::*;
13687    use crate::app::agent_session::AgentSessionHandle;
13688    use crate::store::settings::Settings;
13689    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13690
13691    fn make_session_with_tools() -> AgentSessionHandle {
13692        super::provider_overlay_tests::make_session_with_tools_for_tests()
13693    }
13694    use oxicode_vtui::tui::core::InlineListSelection;
13695    use ratatui::{Terminal, backend::TestBackend};
13696
13697    fn heading(title: &str) -> OverlayListItem {
13698        OverlayListItem {
13699            title: title.into(),
13700            subtitle: None,
13701            badge: None,
13702            indent: 0,
13703            search_value: None,
13704            selection: None,
13705        }
13706    }
13707
13708    fn row(title: &str, badge: &str, selection: Option<InlineListSelection>) -> OverlayListItem {
13709        OverlayListItem {
13710            title: title.into(),
13711            subtitle: None,
13712            badge: Some(badge.into()),
13713            indent: 0,
13714            search_value: None,
13715            selection,
13716        }
13717    }
13718
13719    /// Collect each terminal row as (y, concatenated text, per-char x
13720    /// positions) so tests can assert on WHERE content landed, not just
13721    /// that it exists.
13722    fn rows_with_positions(terminal: &Terminal<TestBackend>) -> Vec<(u16, String, Vec<u16>)> {
13723        let buf = terminal.backend().buffer();
13724        let area = buf.area();
13725        let mut out = Vec::new();
13726        for y in 0..area.height {
13727            let mut text = String::new();
13728            let mut xs = Vec::new();
13729            for x in 0..area.width {
13730                if let Some(cell) = buf.cell((x, y)) {
13731                    text.push_str(cell.symbol());
13732                    xs.push(x);
13733                }
13734            }
13735            out.push((y, text, xs));
13736        }
13737        out
13738    }
13739
13740    /// All (y, x) offsets where `needle` starts in the rendered buffer.
13741    fn occurrences(rows: &[(u16, String, Vec<u16>)], needle: &str) -> Vec<(u16, usize)> {
13742        let mut hits = Vec::new();
13743        for (y, text, xs) in rows {
13744            let mut from = 0;
13745            while let Some(rel) = text[from..].find(needle) {
13746                let byte_idx = from + rel;
13747                let char_idx = text[..byte_idx].chars().count();
13748                if let Some(&x) = xs.get(char_idx) {
13749                    hits.push((*y, x as usize));
13750                }
13751                from = byte_idx + needle.len();
13752            }
13753        }
13754        hits
13755    }
13756
13757    /// A tabbed overlay (>= 2 sections, width >= 60) renders the tab bar
13758    /// and the sidebar column: section names appear BOTH in the sidebar
13759    /// (left of the item column) and as in-list heading rows, and rows
13760    /// outside the active section are dimmed.
13761    #[test]
13762    fn render_overlay_tabbed_settings_shows_tab_bar_and_sidebar() {
13763        let backend = TestBackend::new(80, 24);
13764        let mut terminal = Terminal::new(backend).unwrap();
13765        let overlay = OverlayState {
13766            title: "Settings".into(),
13767            lines: Vec::new(),
13768            items: vec![
13769                heading("Defaults"),
13770                row(
13771                    "Thinking level",
13772                    "medium",
13773                    Some(InlineListSelection::ConfigAction("ThinkingLevel".into())),
13774                ),
13775                row("Model roles", "0", None),
13776                heading("Pointers"),
13777                row("Theme", "dark", None),
13778            ],
13779            selected: 1,
13780            search: None,
13781            secure_input: None,
13782            tabs: vec!["General".into(), "Model".into(), "Interaction".into()],
13783            active_tab: 1,
13784            sections: vec!["Defaults".into(), "Pointers".into()],
13785            active_section: 0,
13786            key_capture: None,
13787        };
13788        terminal
13789            .draw(|f| render_overlay(f, f.area(), &overlay))
13790            .unwrap();
13791        let rows = rows_with_positions(&terminal);
13792
13793        // Tab bar: one row names the inactive tabs flanking the active
13794        // one.
13795        let general = occurrences(&rows, "General");
13796        let interaction = occurrences(&rows, "Interaction");
13797        assert!(
13798            general
13799                .iter()
13800                .any(|(gy, _)| interaction.iter().any(|(iy, _)| gy == iy)),
13801            "tab bar must list tabs on one row"
13802        );
13803
13804        // Sidebar geometry: sidebar width = min(22, longest)+4 = 12, so
13805        // the sidebar column occupies x < 13 and the item list starts at
13806        // x >= 13.
13807        for name in ["Defaults", "Pointers"] {
13808            let hits = occurrences(&rows, name);
13809            assert!(hits.len() >= 2, "{name} must render in sidebar AND list");
13810            assert!(
13811                hits.iter().any(|(_, x)| *x < 13),
13812                "{name} must render in the sidebar column"
13813            );
13814            assert!(
13815                hits.iter().any(|(_, x)| *x >= 13),
13816                "{name} must render in the item column"
13817            );
13818        }
13819
13820        // Out-of-section rows recede: the items-column "Pointers"
13821        // heading is DIM while the active section's is not.
13822        let buf = terminal.backend().buffer();
13823        let pointers_item_col = occurrences(&rows, "Pointers")
13824            .into_iter()
13825            .find(|(_, x)| *x >= 13)
13826            .expect("items-column Pointers heading");
13827        let cell = buf
13828            .cell((pointers_item_col.1 as u16, pointers_item_col.0))
13829            .expect("cell");
13830        assert!(
13831            cell.modifier.contains(Modifier::DIM),
13832            "out-of-section rows must be dimmed"
13833        );
13834        let defaults_item_col = occurrences(&rows, "Defaults")
13835            .into_iter()
13836            .find(|(_, x)| *x >= 13)
13837            .expect("items-column Defaults heading");
13838        let cell = buf
13839            .cell((defaults_item_col.1 as u16, defaults_item_col.0))
13840            .expect("cell");
13841        assert!(
13842            !cell.modifier.contains(Modifier::DIM),
13843            "active-section rows must not be dimmed"
13844        );
13845    }
13846
13847    /// The input loop resolves shortcuts through the live keymap: with
13848    /// `SendNow` rebound to `Alt+s`, that combo fires the send-now path
13849    /// (interrupt + immediate submit of the composed buffer) while the
13850    /// default `Ctrl+Enter` still resolves.
13851    #[test]
13852    fn rebound_send_now_combo_submits_immediately() {
13853        let state = Arc::new(parking_lot::Mutex::new(RenderState::default()));
13854        let mut overrides = std::collections::HashMap::new();
13855        overrides.insert("SendNow".to_string(), vec!["Alt+s".to_string()]);
13856        *state.lock().keymap.write() = Keymap::from_settings(&overrides);
13857
13858        let alt_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::ALT);
13859        let action = state
13860            .lock()
13861            .keymap
13862            .read()
13863            .resolve(alt_s)
13864            .expect("Alt+s must resolve to SendNow after the rebind");
13865        assert!(matches!(action, GlobalAction::SendNow));
13866        // Overrides replace only the named action's combo list: the old
13867        // default combo no longer fires SendNow, while every other
13868        // action keeps its default.
13869        let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
13870        assert_eq!(state.lock().keymap.read().resolve(ctrl_enter), None);
13871        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13872        assert!(matches!(
13873            state.lock().keymap.read().resolve(ctrl_p),
13874            Some(GlobalAction::OpenCommandPalette)
13875        ));
13876
13877        state.lock().composer.set_text("send me now");
13878        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
13879        apply_global_action(action, &state, &tx);
13880
13881        assert_eq!(state.lock().composer.text(), "");
13882        match rx.try_recv().expect("interrupt fires first") {
13883            InlineEvent::Interrupt => {}
13884            other => panic!("expected Interrupt first, got {other:?}"),
13885        }
13886        match rx.try_recv().expect("submit fires second") {
13887            InlineEvent::Submit(text) => assert_eq!(&*text, "send me now"),
13888            other => panic!("expected Submit, got {other:?}"),
13889        }
13890        assert!(rx.try_recv().is_err(), "no further events");
13891    }
13892
13893    /// `OverlaySubmission` variants the settings panel emits must stay
13894    /// constructible through the compat layer (compile-level contract).
13895    #[test]
13896    fn settings_selection_variants_round_trip_names() {
13897        assert_eq!(
13898            InlineListSelection::SettingsTab(1),
13899            InlineListSelection::SettingsTab(1)
13900        );
13901        assert_eq!(
13902            InlineListSelection::SettingsSection(0),
13903            InlineListSelection::SettingsSection(0)
13904        );
13905        assert_eq!(
13906            InlineListSelection::SettingKeyCapture("OpenCommandPalette".into()),
13907            InlineListSelection::SettingKeyCapture("OpenCommandPalette".into())
13908        );
13909        assert_eq!(
13910            InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into()),
13911            InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into())
13912        );
13913        assert_eq!(
13914            InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into()),
13915            InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into())
13916        );
13917        assert_eq!(
13918            InlineListSelection::SettingMultiselect("DisabledTools".into()),
13919            InlineListSelection::SettingMultiselect("DisabledTools".into())
13920        );
13921    }
13922
13923    /// Capturing a new combo for `OpenCommandPalette` is additive: the
13924    /// next `Keymap::resolve` call resolves BOTH the new combo and the
13925    /// original default `Ctrl+P`. The capture path drives the round
13926    /// trip end-to-end — `SettingKeyCapture` selection opens the
13927    /// capture prompt, the simulated `KeyEvent` is fed straight to
13928    /// `handle_key_capture`, and the live `RenderState::keymap` is the
13929    /// single source of truth the test inspects.
13930    ///
13931    /// SANDBOXED: writes go to a tempdir `settings.json` via the
13932    /// `settings_override_path` hook so the real `~/.oxicode/settings.*`
13933    /// is never touched (the previous version of this test polluted the
13934    /// developer's live config — see final-review finding 1).
13935    #[test]
13936    fn key_capture_appends_combo_and_keeps_default_resolving() {
13937        // Snapshot the real ~/.oxicode settings.json mtime so the
13938        // post-condition assertion catches accidental leakage.
13939        let real_settings = dirs::home_dir()
13940            .map(|h| h.join(".oxicode").join("settings.json"))
13941            .filter(|p| p.exists());
13942        let real_settings_mtime_before = real_settings
13943            .as_ref()
13944            .and_then(|p| std::fs::metadata(p).ok())
13945            .and_then(|m| m.modified().ok());
13946        let real_settings_sha_before = real_settings
13947            .as_ref()
13948            .and_then(|p| std::fs::read(p).ok())
13949            .map(|b| {
13950                use std::collections::hash_map::DefaultHasher;
13951                use std::hash::{Hash, Hasher};
13952                let mut h = DefaultHasher::new();
13953                b.hash(&mut h);
13954                h.finish()
13955            });
13956
13957        let tmp = tempfile::tempdir().expect("tempdir");
13958        let sandbox = tmp.path().join("settings.json");
13959
13960        let mut state = RenderState::default();
13961        state.settings_override_path = Some(sandbox.clone());
13962        // Open the capture overlay for OpenCommandPalette — same
13963        // selection variant `handle_inline_event` would dispatch from
13964        // the settings panel.
13965        state.overlay = Some(build_key_capture_overlay(
13966            GlobalAction::OpenCommandPalette.name(),
13967        ));
13968        state.settings_map_rows.clear();
13969
13970        // Pre-condition: the default resolves, the new combo doesn't.
13971        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13972        let alt_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT);
13973        assert_eq!(
13974            state.keymap.read().resolve(ctrl_p),
13975            Some(GlobalAction::OpenCommandPalette)
13976        );
13977        assert_eq!(state.keymap.read().resolve(alt_p), None);
13978
13979        // Drive the capture flow with an Alt+P press.
13980        handle_key_capture(&mut state, alt_p);
13981
13982        // Post-condition: both combos resolve (additive merge into the
13983        // live keymap) — and the panel has been rebuilt back on the
13984        // Keybindings tab with a success status, not the capture
13985        // prompt.
13986        let keymap = state.keymap.read();
13987        assert_eq!(
13988            keymap.resolve(ctrl_p),
13989            Some(GlobalAction::OpenCommandPalette)
13990        );
13991        assert_eq!(
13992            keymap.resolve(alt_p),
13993            Some(GlobalAction::OpenCommandPalette)
13994        );
13995        drop(keymap);
13996        let overlay = state
13997            .overlay
13998            .as_ref()
13999            .expect("capture commits reopen the panel on Keybindings");
14000        assert!(
14001            overlay.key_capture.is_none(),
14002            "capture prompt must be closed"
14003        );
14004        assert_eq!(
14005            overlay.lines.first().map(String::as_str),
14006            Some("Captured Alt+p for OpenCommandPalette")
14007        );
14008        assert_eq!(state.settings_active_tab, SettingsTab::Keybindings);
14009
14010        // Sandbox assertion: the tempdir received the write, the real
14011        // `~/.oxicode/settings.json` is untouched (no mtime or
14012        // content change).
14013        let sandbox_contents = std::fs::read_to_string(&sandbox)
14014            .expect("sandbox settings.json must exist after capture");
14015        assert!(
14016            sandbox_contents.contains("OpenCommandPalette"),
14017            "sandbox file must contain the captured keybinding override; got {sandbox_contents}"
14018        );
14019        assert!(
14020            sandbox_contents.contains("Alt+p"),
14021            "sandbox file must contain the captured Alt+p combo; got {sandbox_contents}"
14022        );
14023        if let Some(before) = real_settings_mtime_before {
14024            let after = real_settings
14025                .as_ref()
14026                .and_then(|p| std::fs::metadata(p).ok())
14027                .and_then(|m| m.modified().ok())
14028                .expect("real settings.json must still exist after capture");
14029            assert_eq!(
14030                before, after,
14031                "real ~/.oxicode/settings.json mtime must not change (sandbox leak)"
14032            );
14033        }
14034        if let (Some(before), Some(after)) = (
14035            real_settings_sha_before,
14036            real_settings
14037                .as_ref()
14038                .and_then(|p| std::fs::read(p).ok())
14039                .map(|b| {
14040                    use std::collections::hash_map::DefaultHasher;
14041                    use std::hash::{Hash, Hasher};
14042                    let mut h = DefaultHasher::new();
14043                    b.hash(&mut h);
14044                    h.finish()
14045                }),
14046        ) {
14047            assert_eq!(
14048                before, after,
14049                "real ~/.oxicode/settings.json content must not change (sandbox leak)"
14050            );
14051        }
14052    }
14053
14054    /// The remove-last-binding guard refuses to drop the final combo of
14055    /// an action — an action with zero keys would be a silent trap
14056    /// (the user could neither trigger it nor reach this panel to fix
14057    /// it). We pre-bind `OpenCommandPalette` to a single combo (the
14058    /// default) and confirm `remove_keybinding_combo` no-ops the
14059    /// removal while surfacing the reason in the panel status.
14060    #[test]
14061    fn remove_keybinding_combo_refuses_to_drop_the_last_combo() {
14062        let mut state = RenderState::default();
14063        // Force a single-combo state: replace OpenCommandPalette's
14064        // list with just `Ctrl+p` (the default minus all other
14065        // combos the action doesn't have — the point is that the
14066        // list ends up at length 1).
14067        let mut settings = Settings::default();
14068        crate::tui_vt::settings_defs::set_action_combos(
14069            &mut settings,
14070            GlobalAction::OpenCommandPalette,
14071            vec!["Ctrl+p".to_string()],
14072        );
14073        *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
14074        assert_eq!(
14075            state
14076                .keymap
14077                .read()
14078                .action_combos(GlobalAction::OpenCommandPalette)
14079                .len(),
14080            1,
14081            "test setup: action must start with exactly one combo"
14082        );
14083
14084        // Place the panel somewhere (the guard reopens it, but
14085        // starting state should be observable).
14086        state.settings_active_tab = SettingsTab::Keybindings;
14087
14088        remove_keybinding_combo(&mut state, GlobalAction::OpenCommandPalette, "Ctrl+p");
14089
14090        // The combo is still live — the guard refused the removal.
14091        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
14092        assert_eq!(
14093            state.keymap.read().resolve(ctrl_p),
14094            Some(GlobalAction::OpenCommandPalette),
14095            "the guard must not let OpenCommandPalette go combo-less"
14096        );
14097        assert_eq!(
14098            state
14099                .keymap
14100                .read()
14101                .action_combos(GlobalAction::OpenCommandPalette)
14102                .len(),
14103            1,
14104            "no combo was removed"
14105        );
14106        // The reason is surfaced as the panel status line so the user
14107        // knows why nothing happened.
14108        let overlay = state.overlay.as_ref().expect("reopen leaves the panel up");
14109        assert!(
14110            overlay
14111                .lines
14112                .first()
14113                .map(|l| l.contains("Refusing to remove the last combo"))
14114                .unwrap_or(false),
14115            "panel must explain why the removal was refused; got {:?}",
14116            overlay.lines
14117        );
14118    }
14119
14120    // ── Final-fix wave: Text / SubmenuSelect / Multiselect editors ────
14121
14122    /// `commit_text_edit` with valid numeric input: the value is
14123    /// parsed, persisted to the SANDBOX path, and the panel reopens
14124    /// with a status line showing the new value.
14125    #[test]
14126    fn text_edit_commit_valid_input() {
14127        let tmp = tempfile::tempdir().expect("tempdir");
14128        let sandbox = tmp.path().join("settings.json");
14129        let mut state = RenderState::default();
14130        state.settings_override_path = Some(sandbox.clone());
14131        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14132        let handle = InlineHandle::new_for_tests(tx);
14133
14134        let (outcome, _msg) = commit_text_edit(
14135            &mut state,
14136            &handle,
14137            None,
14138            SettingKey::SessionHistorySize,
14139            "300".to_string(),
14140        );
14141        assert!(outcome.is_ok(), "valid numeric input must commit");
14142
14143        // The sandbox file received the write with the new value.
14144        let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14145        let saved: Settings = serde_json::from_str(&contents).expect("sandbox parses");
14146        assert_eq!(
14147            saved.session_history_size, 300,
14148            "sandbox must hold session_history_size=300"
14149        );
14150
14151        // The panel reopened with a status line naming the new value.
14152        let overlay = state.overlay.as_ref().expect("panel reopened");
14153        assert!(
14154            overlay
14155                .lines
14156                .first()
14157                .map(|l| l.contains("300"))
14158                .unwrap_or(false),
14159            "status line must show the new value; got {:?}",
14160            overlay.lines
14161        );
14162        // And the transcript Info line was emitted.
14163        let mut saw_info = false;
14164        while let Ok(cmd) = rx.try_recv() {
14165            if let InlineCommand::AppendLine { kind, .. } = cmd
14166                && matches!(kind, InlineMessageKind::Info)
14167            {
14168                saw_info = true;
14169            }
14170        }
14171        assert!(saw_info, "commit must surface an Info line");
14172    }
14173
14174    /// `commit_text_edit` with INVALID input: the parse fails, nothing
14175    /// is persisted (no sandbox file), and the failure surfaces as an
14176    /// Error line — never a silent no-op.
14177    #[test]
14178    fn text_edit_commit_invalid_input_is_rejected() {
14179        let tmp = tempfile::tempdir().expect("tempdir");
14180        let sandbox = tmp.path().join("settings.json");
14181        let mut state = RenderState::default();
14182        state.settings_override_path = Some(sandbox.clone());
14183        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14184        let handle = InlineHandle::new_for_tests(tx);
14185
14186        let (outcome, msg) = commit_text_edit(
14187            &mut state,
14188            &handle,
14189            None,
14190            SettingKey::SessionHistorySize,
14191            "not-a-number".to_string(),
14192        );
14193        assert!(outcome.is_err(), "non-numeric input must be rejected");
14194        assert!(
14195            msg.contains("invalid") || msg.contains("ParseError") || !msg.is_empty(),
14196            "rejection must carry a reason; got {msg}"
14197        );
14198        // No write happened.
14199        assert!(
14200            !sandbox.exists(),
14201            "rejected input must not persist anything"
14202        );
14203        // The failure surfaced as an Error line.
14204        let mut saw_error = false;
14205        while let Ok(cmd) = rx.try_recv() {
14206            if let InlineCommand::AppendLine { kind, .. } = cmd
14207                && matches!(kind, InlineMessageKind::Error)
14208            {
14209                saw_error = true;
14210            }
14211        }
14212        assert!(saw_error, "rejection must surface an Error line");
14213    }
14214
14215    /// `open_submenu_select_prompt` builds the option list from the
14216    /// def's `SubmenuSelect` options with the current value marked, and
14217    /// `commit_submenu_choice` persists the choice and reopens the
14218    /// panel.
14219    #[test]
14220    fn submenu_select_commit_for_sync_backlog() {
14221        let tmp = tempfile::tempdir().expect("tempdir");
14222        let sandbox = tmp.path().join("settings.json");
14223        let mut state = RenderState::default();
14224        state.settings_override_path = Some(sandbox.clone());
14225
14226        // Open the submenu: rows for off/sync/async, current marked.
14227        open_submenu_select_prompt(&mut state, SettingKey::AdvisorSyncBacklog);
14228        let overlay = state.overlay.as_ref().expect("submenu overlay opens");
14229        assert_eq!(overlay.items.len(), 3, "off/sync/async rows");
14230        let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14231        assert_eq!(titles, vec!["off", "sync", "async"]);
14232        // Every row carries a SubmenuCommit selection payload.
14233        for item in &overlay.items {
14234            let sel = item.selection.as_ref().expect("row is selectable");
14235            match sel {
14236                InlineListSelection::ConfigAction(p) => {
14237                    assert!(
14238                        p.starts_with("SubmenuCommit:AdvisorSyncBacklog:"),
14239                        "payload must address the key; got {p}"
14240                    );
14241                }
14242                other => panic!("expected ConfigAction, got {other:?}"),
14243            }
14244        }
14245
14246        // Commit "async" through the commit helper.
14247        let status = commit_submenu_choice(
14248            &mut state,
14249            SettingKey::AdvisorSyncBacklog,
14250            "async".to_string(),
14251        )
14252        .expect("valid option commits");
14253        assert!(status.contains("async"), "status names the new value");
14254
14255        // The sandbox file holds the new value.
14256        let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14257        assert!(
14258            contents.contains("async"),
14259            "sandbox must hold the async choice: {contents}"
14260        );
14261        // The panel reopened with the status line.
14262        let overlay = state.overlay.as_ref().expect("panel reopened");
14263        assert!(
14264            overlay
14265                .lines
14266                .first()
14267                .map(|l| l.contains("async"))
14268                .unwrap_or(false),
14269            "status line must show the new value"
14270        );
14271    }
14272
14273    /// The multiselect editor toggles a non-essential tool into (and
14274    /// out of) `disabled_tools`, persisting through the sandbox, and
14275    /// REFUSES an essential tool with an Error line and no write.
14276    #[test]
14277    fn multiselect_toggles_tool_and_refuses_essential() {
14278        let session = make_session_with_tools();
14279        let tmp = tempfile::tempdir().expect("tempdir");
14280        let sandbox = tmp.path().join("settings.json");
14281        let mut state = RenderState::default();
14282        state.settings_override_path = Some(sandbox.clone());
14283        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14284        let handle = InlineHandle::new_for_tests(tx);
14285
14286        // The overlay lists the registry's tools (bash + commit from
14287        // the fixture), sorted, with essential badges.
14288        open_disabled_tools_multiselect(&mut state, &session);
14289        let overlay = state.overlay.as_ref().expect("multiselect opens");
14290        let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14291        assert_eq!(titles, vec!["bash", "commit"], "registry tools, sorted");
14292        let bash = &overlay.items[0];
14293        assert_eq!(bash.badge.as_deref(), Some("essential"));
14294        let commit = &overlay.items[1];
14295        assert_eq!(commit.badge.as_deref(), Some("enabled"));
14296
14297        // Toggle the optional tool OFF (disable): commit ∈ disabled_tools.
14298        commit_disabled_tool_toggle(
14299            &mut state,
14300            &handle,
14301            &session,
14302            "commit".to_string(),
14303            false, // not essential
14304            false, // currently enabled
14305        );
14306        let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14307            .expect("sandbox parses");
14308        assert!(
14309            saved.disabled_tools.iter().any(|t| t == "commit"),
14310            "toggle must add 'commit' to disabled_tools; got {:?}",
14311            saved.disabled_tools
14312        );
14313
14314        // Toggle it back ON (enable): commit ∉ disabled_tools.
14315        commit_disabled_tool_toggle(
14316            &mut state,
14317            &handle,
14318            &session,
14319            "commit".to_string(),
14320            false, // not essential
14321            true,  // currently disabled
14322        );
14323        let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14324            .expect("sandbox parses");
14325        assert!(
14326            !saved.disabled_tools.iter().any(|t| t == "commit"),
14327            "toggle must remove 'commit' from disabled_tools; got {:?}",
14328            saved.disabled_tools
14329        );
14330
14331        // Essential refusal: an Error line is emitted, no write happens.
14332        let before = std::fs::read_to_string(&sandbox).unwrap();
14333        commit_disabled_tool_toggle(
14334            &mut state,
14335            &handle,
14336            &session,
14337            "bash".to_string(),
14338            true,  // essential
14339            false, // currently enabled
14340        );
14341        let after = std::fs::read_to_string(&sandbox).unwrap();
14342        assert_eq!(before, after, "essential refusal must not write");
14343        let mut saw_refusal = false;
14344        while let Ok(cmd) = rx.try_recv() {
14345            if let InlineCommand::AppendLine { kind, segments } = cmd
14346                && matches!(kind, InlineMessageKind::Error)
14347                && segments.iter().any(|s| s.text.contains("essential"))
14348            {
14349                saw_refusal = true;
14350            }
14351        }
14352        assert!(
14353            saw_refusal,
14354            "essential refusal must surface an Error line mentioning 'essential'"
14355        );
14356    }
14357}
14358
14359// Inline image previews — generate_image result hook (kitty/iTerm2).
14360// ═════════════════════════════════════════════════════════════════════════
14361
14362#[cfg(test)]
14363mod image_preview_hook_tests {
14364    use super::*;
14365    use base64::{Engine, engine::general_purpose};
14366    use tokio::sync::mpsc;
14367
14368    /// Craft a generate_image tool-result body in the exact shape
14369    /// `GenerateImageTool::execute` produces.
14370    fn image_result_content(payload: &[u8]) -> String {
14371        let b64 = general_purpose::STANDARD.encode(payload);
14372        format!(
14373            "Generated 1 image(s).\n\nImage 1 ({} bytes, base64):\n{}\n",
14374            payload.len(),
14375            b64
14376        )
14377    }
14378
14379    fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
14380        let (tx, rx) = mpsc::unbounded_channel();
14381        (InlineHandle::new_for_tests(tx), rx)
14382    }
14383
14384    fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
14385        while let Ok(cmd) = rx.try_recv() {
14386            apply_command(state, cmd);
14387        }
14388    }
14389
14390    fn transcript_text(state: &RenderState) -> Vec<String> {
14391        state
14392            .transcript
14393            .iter()
14394            .map(|l| {
14395                l.segments
14396                    .iter()
14397                    .map(|s| s.text.as_str())
14398                    .collect::<String>()
14399            })
14400            .collect()
14401    }
14402
14403    /// A successful generate_image result renders the text-fallback row
14404    /// (never the raw base64 wall) and enqueues the decoded PNG keyed by
14405    /// its content hash, pointing at the fallback row.
14406    #[test]
14407    fn generate_image_result_renders_fallback_row_and_enqueues_live_preview() {
14408        let mut state = RenderState::default();
14409        let (handle, mut rx) = fresh_handle();
14410        let payload = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14411
14412        map_agent_event(
14413            &handle,
14414            AgentEvent::ToolExecutionStart {
14415                tool_call_id: "img-1".into(),
14416                tool_name: "generate_image".into(),
14417                args: serde_json::json!({"prompt": "a cat"}),
14418                intent: None,
14419                context: None,
14420            },
14421            &mut state,
14422        );
14423        map_agent_event(
14424            &handle,
14425            AgentEvent::ToolExecutionEnd {
14426                tool_call_id: "img-1".into(),
14427                tool_name: "generate_image".into(),
14428                intent: None,
14429                result: oxicode_ai::ToolResult {
14430                    tool_call_id: "img-1".into(),
14431                    content: image_result_content(&payload),
14432                    status: "success".into(),
14433                },
14434                is_error: false,
14435            },
14436            &mut state,
14437        );
14438        apply_all(&mut state, &mut rx);
14439
14440        let texts = transcript_text(&state);
14441        let fallback_idx = texts
14442            .iter()
14443            .position(|t| t.contains("[image: generate_image:"))
14444            .expect("fallback row rendered in the tool box");
14445        assert!(
14446            texts
14447                .iter()
14448                .all(|t| !t.contains(&general_purpose::STANDARD.encode(payload))),
14449            "raw base64 must never render as text"
14450        );
14451
14452        assert_eq!(
14453            state.image_previews.pending_len(),
14454            1,
14455            "decoded PNG enqueued for live placement"
14456        );
14457        let pending = &state.image_previews.pending()[0];
14458        assert_eq!(&*pending.png, &payload, "decoded bytes round-trip");
14459        // The pending preview's label resolves to the fallback row — this
14460        // is the lookup the render pass uses to anchor the placement.
14461        assert!(
14462            texts[fallback_idx].contains(&pending.label),
14463            "label {label:?} matches the fallback row {row:?}",
14464            label = pending.label,
14465            row = texts[fallback_idx],
14466        );
14467    }
14468
14469    /// Results without an embedded base64 image (API errors, empty
14470    /// responses) keep the generic preview path and enqueue nothing.
14471    #[test]
14472    fn generate_image_without_payload_keeps_generic_preview() {
14473        let mut state = RenderState::default();
14474        let (handle, mut rx) = fresh_handle();
14475        map_agent_event(
14476            &handle,
14477            AgentEvent::ToolExecutionStart {
14478                tool_call_id: "img-2".into(),
14479                tool_name: "generate_image".into(),
14480                args: serde_json::json!({"prompt": "a cat"}),
14481                intent: None,
14482                context: None,
14483            },
14484            &mut state,
14485        );
14486        map_agent_event(
14487            &handle,
14488            AgentEvent::ToolExecutionEnd {
14489                tool_call_id: "img-2".into(),
14490                tool_name: "generate_image".into(),
14491                intent: None,
14492                result: oxicode_ai::ToolResult {
14493                    tool_call_id: "img-2".into(),
14494                    content: "Image generation completed but returned no images.".into(),
14495                    status: "success".into(),
14496                },
14497                is_error: false,
14498            },
14499            &mut state,
14500        );
14501        apply_all(&mut state, &mut rx);
14502        let texts = transcript_text(&state);
14503        assert!(
14504            texts.iter().any(|t| t.contains("returned no images")),
14505            "generic preview path still renders the summary"
14506        );
14507        assert_eq!(state.image_previews.pending_len(), 0);
14508    }
14509
14510    /// End-to-end: a live frame records the anchor for the pending
14511    /// image's tool box, and the post-draw emit produces the full kitty
14512    /// sequence (CUP + transmit + place) for it.
14513    #[test]
14514    fn live_frame_anchors_and_emits_kitty_sequence() {
14515        use crate::tui_vt::image_preview::{ImagePreviews, ImageSupport};
14516        use ratatui::{Terminal, backend::TestBackend};
14517
14518        let mut state = RenderState::default();
14519        state.image_previews = ImagePreviews::new(ImageSupport::Kitty);
14520        let (handle, mut rx) = fresh_handle();
14521        let payload = [0x89u8, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14522        map_agent_event(
14523            &handle,
14524            AgentEvent::ToolExecutionStart {
14525                tool_call_id: "img-3".into(),
14526                tool_name: "generate_image".into(),
14527                args: serde_json::json!({"prompt": "a dog"}),
14528                intent: None,
14529                context: None,
14530            },
14531            &mut state,
14532        );
14533        map_agent_event(
14534            &handle,
14535            AgentEvent::ToolExecutionEnd {
14536                tool_call_id: "img-3".into(),
14537                tool_name: "generate_image".into(),
14538                intent: None,
14539                result: oxicode_ai::ToolResult {
14540                    tool_call_id: "img-3".into(),
14541                    content: image_result_content(&payload),
14542                    status: "success".into(),
14543                },
14544                is_error: false,
14545            },
14546            &mut state,
14547        );
14548        apply_all(&mut state, &mut rx);
14549
14550        // Render one live frame (records the anchor through the shared
14551        // interior-mutable channel).
14552        let backend = TestBackend::new(80, 24);
14553        let mut terminal = Terminal::new(backend).expect("backend");
14554        let (tx, _drain) = mpsc::unbounded_channel();
14555        terminal
14556            .draw(|frame| render_frame(frame, &state, &InlineHandle::new_for_tests(tx)))
14557            .expect("draw");
14558        let buf = terminal.backend().buffer().clone();
14559        let frame_text: String = (0..buf.area().height)
14560            .map(|y| {
14561                (0..buf.area().width)
14562                    .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
14563                    .collect::<String>()
14564            })
14565            .collect::<Vec<_>>()
14566            .join("\n");
14567        assert!(
14568            frame_text.contains("[image: generate_image:"),
14569            "live frame paints the fallback row"
14570        );
14571
14572        // Post-draw emit: full kitty stream for the anchored box.
14573        let seq = state.image_previews.emit_live(state.committed_entries);
14574        assert!(seq.contains("\x1b["));
14575        assert!(seq.contains("\x1b_Ga=t,f=100"), "transmit");
14576        assert!(seq.contains("a=p"), "placement");
14577        assert_eq!(state.image_previews.pending_len(), 0, "placed and consumed");
14578    }
14579
14580    /// `extract_generated_png` — the marker parse powering the hook.
14581    #[test]
14582    fn extract_generated_png_parses_first_image_and_rejects_garbage() {
14583        let payload = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14584        assert_eq!(
14585            extract_generated_png(&image_result_content(&payload)),
14586            Some(payload),
14587            "first base64 blob after the marker decodes"
14588        );
14589        // Multiple images: only the first is previewed. Payloads clear
14590        // the 8-byte PNG-header sanity floor.
14591        let first: Vec<u8> = (1u8..=8).collect();
14592        let second: Vec<u8> = (9u8..=16).collect();
14593        let two = format!(
14594            "Generated 2 image(s).\n\nImage 1 (8 bytes, base64):\n{}\n\nImage 2 (8 bytes, base64):\n{}\n",
14595            general_purpose::STANDARD.encode(&first),
14596            general_purpose::STANDARD.encode(&second),
14597        );
14598        assert_eq!(extract_generated_png(&two), Some(first));
14599        // No marker / invalid base64 / sub-PNG-header payload → None.
14600        assert_eq!(extract_generated_png("plain text output"), None);
14601        assert_eq!(
14602            extract_generated_png("Image 1 (8 bytes, base64):\n!!!not-base64!!!\n"),
14603            None
14604        );
14605        assert_eq!(
14606            extract_generated_png("Image 1 (2 bytes, base64):\n AQID \n"),
14607            None,
14608            "payloads shorter than a PNG header are rejected"
14609        );
14610    }
14611}