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    /// This TUI process's liveness identity (`tui-<pid>-<uuid>`), injected
461    /// from `App::ownership_session_id` at startup. Every issue-panel /
462    /// slash-command write names the flock holder through this field — NOT
463    /// a shared constant — so parallel TUIs claim issues independently.
464    /// Empty only in tests that build a bare `RenderState::default()`.
465    pub ownership_session_id: String,
466    /// Per-tip-key show counter — suppresses ambient tips after SEEN_CAP views.
467    pub seen_tips: std::collections::HashMap<&'static str, u32>,
468    /// User-defined slash commands loaded once at startup from
469    /// `.oxicode/commands/` and `~/.oxicode/commands/`.
470    pub file_commands: Vec<FileCommand>,
471    /// Provider name and origin for the currently open secure prompt.
472    /// Set before opening the prompt; cleared on `OverlaySubmission::SecureInput`
473    /// after the key is written. `None` outside the secure-prompt flows
474    /// (`/providers` row action, `/providers add`, programmatic rekey) so a
475    /// stray `SecureInput` cannot leak into a different provider.
476    ///
477    /// The `SecureInputOrigin` variant lets the consumer of the submitted
478    /// key know whether to greet the user ("just added a provider") or
479    /// simply acknowledge ("key replaced") — both write to the same auth
480    /// storage slot, but the surrounding UX differs.
481    pub secure_input_origin: Option<SecureInputOrigin>,
482    /// Live-session swapper. `None` until the TUI startup wires it.
483    /// The render loop and the agent worker both call `current()` per
484    /// dispatch; the resume `tokio::spawn` calls `swap(new_handle)`.
485    /// `Option` because `#[derive(Default)]` requires it.
486    pub session_swapper: Option<Arc<crate::app::agent_session_handle::SessionSwapper>>,
487    /// `Some(path)` when the slash command wants the event loop to
488    /// drain a resume job on the next `Submitted` arm. The
489    /// `Submitted` arm calls `state.pending_resume.take()` and
490    /// enqueues the resume.
491    pub pending_resume: Option<PathBuf>,
492    pub session_state: Option<crate::SessionState>,
493    /// Active `/settings` tab. Persisted across overlay reopens within
494    /// the session; drives both the tab-switch rebuild and the sidebar
495    /// highlight.
496    pub settings_active_tab: crate::tui_vt::settings_defs::SettingsTab,
497    /// Row-kind table for the settings panel's map editors
498    /// (Keybindings / Model roles), index-aligned with
499    /// `overlay.items` while the tabbed panel is open. Built by the
500    /// same pass that builds the items; consulted by the input thread
501    /// to route `Enter` / `d` / `n` on map rows. Empty (or stale —
502    /// every consumer re-checks length alignment) for non-settings
503    /// overlays.
504    pub settings_map_rows: Vec<Option<SettingsMapRow>>,
505    /// Live global-shortcut resolver, seeded from
506    /// `Settings::keybindings` at TUI startup and swapped in place by
507    /// the keybindings editor. `parking_lot::RwLock` (not ArcSwap) — no
508    /// new dependency, and the per-keystroke read-lock cost is
509    /// negligible.
510    pub keymap: Arc<parking_lot::RwLock<crate::tui_vt::keymap::Keymap>>,
511    /// Test-only sandbox: when `Some`, the keybindings commit path
512    /// writes `Settings` to this path via `Settings::save_to` instead
513    /// of touching the real `~/.oxicode/settings.{json,toml}`. The
514    /// production TUI leaves this at `None`; only unit tests set it.
515    /// Thread-safety is the same as `RenderState` itself (single-thread
516    /// use in the input thread).
517    #[cfg(test)]
518    pub settings_override_path: Option<std::path::PathBuf>,
519
520    /// Git TUI overlay — `Some` while `/git` is open. The render loop
521    /// paints the overlay over the scrollback+composer region when set;
522    /// the input thread routes keys through `match_git_key` and never
523    /// lets them reach the composer.
524    pub git_tui: Option<crate::tui_vt::git_tui::GitTuiState>,
525    /// Width/height of the git TUI overlay viewport (mirrored from the
526    /// last render pass so resize events can be detected without a
527    /// round-trip into the ratatui Frame).
528    pub git_tui_viewport: (u16, u16),
529}
530
531impl Default for RenderState {
532    fn default() -> Self {
533        // for every other field. The composer starts empty.
534        Self {
535            composer: oxicode_textarea::TextArea::new(),
536            transcript: Vec::new(),
537            scroll_offset: usize::MAX,
538            committed_entries: 0,
539            header_context: InlineHeaderContext::default(),
540            input_enabled: false,
541            prompt_prefix: String::new(),
542            placeholder: None,
543            thinking_buffer: String::new(),
544            shutdown_requested: false,
545            message_buffer: String::new(),
546            stream_anchor: None,
547            md_cache: oxicode_vtui::tui::ui::markdown::MdRenderCache::default(),
548            agent_hub_open: false,
549            hub_entries: Vec::new(),
550            hub: None,
551            pending_quit: false,
552            slash_popup: SlashPopup::default(),
553            reasoning_stage: None,
554            active_run: None,
555            stream_reveal: usize::MAX,
556            thinking_level: "medium".to_string(),
557            viewport_width: 80,
558            last_viewport_height: 24,
559            glyph_set: crate::symbols::GlyphSet::default(),
560            image_previews: super::image_preview::ImagePreviews::default(),
561            overlay: None,
562            overlay_model_ids: Vec::new(),
563            overlay_catalog_models: Vec::new(),
564            overlay_providers: Vec::new(),
565            catalog: None,
566            queued_inputs: Vec::new(),
567            queue_panel_open: false,
568            queue_selected: 0,
569            shell_mode: false,
570            follow_ups: Vec::new(),
571            todo_phases: Vec::new(),
572            todo_expanded: false,
573            todo_clear_deadline: None,
574            todo_clear_delay_secs: -1,
575            todo_provider: None,
576            vim_state: crate::tui_vt::vim::VimState::default(),
577            vim_clipboard: String::new(),
578            search: None,
579            block_display: std::collections::HashMap::new(),
580            last_esc_at: None,
581            multiline_mode: false,
582            autonomy_mode: Mode::default(),
583            prompt_history: Vec::new(),
584            history_pos: None,
585            next_block_id: 0,
586            cancel_grace_until: None,
587            confirmation: None,
588            tip: None,
589            cwd: PathBuf::new(),
590            file_search: None,
591            issues_panel: None,
592            ownership_session_id: String::new(),
593            issue_store: None,
594            seen_tips: std::collections::HashMap::new(),
595            file_commands: Vec::new(),
596            secure_input_origin: None,
597            session_swapper: None,
598            context_tokens: None,
599            context_window: 128_000,
600            settings_active_tab: crate::tui_vt::settings_defs::SettingsTab::General,
601            settings_map_rows: Vec::new(),
602            // Default bindings only — `new_with_header` (the real TUI
603            // startup) layers `Settings::keybindings` on top, keeping
604            // `Default` free of disk I/O for tests.
605            keymap: Arc::new(parking_lot::RwLock::new(Keymap::from_settings(
606                &std::collections::HashMap::new(),
607            ))),
608            #[cfg(test)]
609            settings_override_path: None,
610            brain: BrainChip::default(),
611            pending_resume: None,
612            session_state: None,
613            git_tui: None,
614            git_tui_viewport: (80, 24),
615        }
616    }
617}
618
619/// Where a secure prompt came from. The `SecureInput` overlay has just one
620/// payload (the text); the origin discriminates the post-commit follow-up
621/// so the user gets a contextual flow instead of a generic "saved" line.
622#[derive(Clone, Debug, PartialEq, Eq)]
623pub enum SecureInputOrigin {
624    /// User picked a provider row and chose "Set API key" (or hit Enter
625    /// on a key-only provider with no key) — this is a *replace* or
626    /// first-time key entry for an existing provider.
627    SetKey { provider: String },
628    /// User just added a provider via `/providers add …` and we are
629    /// chaining straight into the key prompt so they can finish the
630    /// setup without another navigation step.
631    NewlyAdded { provider: String },
632    /// Model-roles map editor: the user pressed `n` — the submitted
633    /// text is the new ROLE name; the value prompt follows.
634    ModelRoleKey,
635    /// Model-roles map editor: the submitted text is the model pattern
636    /// for `role`.
637    ModelRoleValue { role: String },
638    /// Generic settings-panel text editor: the submitted text is
639    /// committed via `settings_defs::apply_change` for the named
640    /// SettingKey. Empty input clears the override (where the field
641    /// is `Option`); invalid input is rejected with an inline error.
642    TextEdit(crate::tui_vt::settings_defs::SettingKey),
643}
644
645/// In-transcript search state.
646#[derive(Clone, Debug)]
647pub struct SearchState {
648    pub query: String,
649    /// Transcript line indices that contain a match.
650    pub matches: Vec<usize>,
651    /// Current match cursor (index into `matches`).
652    pub current: usize,
653}
654
655/// One filtered entry in the `/`-command autocomplete popup.
656#[derive(Clone)]
657pub struct SlashPopupItem {
658    /// Display label, e.g. `"/quit, /exit, /q"`.
659    pub label: String,
660    /// Short human description.
661    pub description: String,
662    /// Canonical command name (no leading `/`), used for completion.
663    pub name: String,
664}
665
666/// Slash-command autocomplete popup state, managed by the input thread and
667/// read by the render loop. The popup is open when the input buffer starts
668/// with `/` and contains no space (i.e. the user is still typing the command
669/// token, not its arguments).
670#[derive(Default, Clone)]
671pub struct SlashPopup {
672    pub open: bool,
673    pub items: Vec<SlashPopupItem>,
674    pub selected: usize,
675}
676
677/// One item rendered inside a list overlay. Mirrors [`InlineListItem`] but
678/// is a value type owned by the TUI (the input thread reads/writes these
679/// fields directly via the `parking_lot::Mutex<RenderState>`).
680#[derive(Clone, Debug)]
681pub struct OverlayListItem {
682    pub title: String,
683    pub subtitle: Option<String>,
684    pub badge: Option<String>,
685    pub indent: u8,
686    pub search_value: Option<String>,
687    /// Original `InlineListSelection` echoed back to the harness on submit.
688    pub selection: Option<oxicode_vtui::tui::core::InlineListSelection>,
689}
690
691/// Overlay modal/list state — materialised by `apply_command` when an
692/// `InlineCommand::ShowOverlay` arrives. The input thread mutates
693/// `selected` / `search` while the overlay is open and reads the same
694/// fields when forwarding `OverlayEvent`s.
695///
696/// `tabs` / `sections` carry the settings panel's tab bar and sidebar.
697/// Both stay default-empty for every other overlay — `render_overlay`
698/// only takes the tabbed/sidebar branches when they are populated.
699#[derive(Clone, Debug, Default)]
700pub struct OverlayState {
701    pub title: String,
702    pub lines: Vec<String>,
703    pub items: Vec<OverlayListItem>,
704    pub selected: usize,
705    pub search: Option<OverlaySearchState>,
706    pub secure_input: Option<OverlaySecureInput>,
707    /// Tab-bar labels (settings panel only; empty ⇒ no tab bar).
708    pub tabs: Vec<String>,
709    /// Index of the active tab into `tabs`.
710    pub active_tab: usize,
711    /// Sidebar section (group) labels for the active tab; the sidebar
712    /// renders when there are at least two.
713    pub sections: Vec<String>,
714    /// Index of the active section into `sections`, synced to the group
715    /// of the currently selected item.
716    pub active_section: usize,
717    /// Keybinding-capture mode (settings panel only): `Some(action
718    /// name)` while the "press a key combo" prompt is up. The input
719    /// thread intercepts the next key BEFORE global-shortcut resolution
720    /// so even a combo that currently triggers an action is captured
721    /// verbatim. Esc cancels.
722    pub key_capture: Option<String>,
723}
724
725/// Secure (masked) single-line input state carried by an overlay.
726/// Only present when the original `OverlayRequest::Modal` carried a
727/// `secure_prompt`. The input thread mutates `editor` while the overlay is
728/// open; on `Enter` it submits `OverlaySubmission::SecureInput` carrying
729/// the editor's text. The real secret never leaves the editor — the
730/// renderer paints the value via a `TextElement` whose display is the
731/// mask.
732#[derive(Clone, Debug)]
733pub struct OverlaySecureInput {
734    pub config: SecurePromptConfig,
735    pub editor: EditBuffer,
736}
737
738/// A y/n/x confirmation dialog (grok-build `ModalConfirmation` parity).
739/// Rendered centered on top of everything else; the input thread routes
740/// `y` → confirm, `n` → decline (when offered), `x`/`Esc` → cancel.
741#[derive(Clone, Debug)]
742pub struct ModalConfirmation {
743    pub title: String,
744    pub message: String,
745    /// What happens when the user confirms (`y`). Cancel (`n`/`x`/`Esc`)
746    /// always just closes the dialog.
747    pub action: ConfirmationAction,
748}
749
750/// The action bound to a [`ModalConfirmation`] — dispatched on `y`/Enter.
751#[derive(Clone, Debug, PartialEq, Eq)]
752pub enum ConfirmationAction {
753    /// Exit the application.
754    Quit,
755    /// Clear the conversation transcript + reset the agent session.
756    ClearConversation,
757    /// Remove the stored API key for a provider (`/providers` → confirm).
758    RemoveProviderKey(String),
759    /// Close the given issue id (from the issues panel's `c` key).
760    CloseIssue(u32),
761}
762
763/// A short-lived contextual tip banner (grok-build ephemeral tips parity).
764/// Shown as one line above the composer for a bounded number of render
765/// ticks, then auto-dismissed.
766#[derive(Clone, Debug)]
767pub struct EphemeralTip {
768    pub text: String,
769    /// Render tick the tip was born at (`FRAME_TICK` snapshot).
770    pub born_tick: u64,
771    /// How many ticks the tip stays visible before auto-dismissing.
772    pub ttl_ticks: u64,
773    /// Stable identifier for per-session seen-cap tracking. Tips with the
774    /// same key are suppressed after `SEEN_CAP` showings.
775    pub key: &'static str,
776    /// Ambient tips (background suggestions) are occluded — their TTL pauses
777    /// while an overlay/confirmation/dropdown is open. Non-ambient tips
778    /// (direct user-action feedback) always count down.
779    pub ambient: bool,
780}
781
782/// Search-bar state for an overlay. `None` value means search is disabled.
783#[derive(Clone, Debug)]
784pub struct OverlaySearchState {
785    pub label: String,
786    pub placeholder: Option<String>,
787    pub value: String,
788}
789
790impl RenderState {
791    fn new_with_header(header: InlineHeaderContext) -> Self {
792        let mut s = Self::default();
793        s.header_context = header;
794        s.prompt_prefix = "> ".to_string();
795        s.input_enabled = true;
796        // Build the live keymap once at startup from the persisted
797        // bindings — the input loop resolves every keystroke against it.
798        let bindings = crate::store::settings::Settings::load()
799            .unwrap_or_default()
800            .keybindings;
801        *s.keymap.write() = Keymap::from_settings(&bindings);
802        s
803    }
804
805    /// Get a clone of the live `SessionSwapper`. Panics if the TUI
806    /// wasn't initialized properly (the `run_tui` startup wires it
807    /// before any user input is processed, so the panic is
808    /// unreachable in normal use).
809    pub fn swapper(&self) -> Arc<crate::app::agent_session_handle::SessionSwapper> {
810        self.session_swapper
811            .clone()
812            .expect("RenderState::session_swapper must be initialized at TUI startup")
813    }
814
815    /// Append one or more brand-new transcript lines.
816    ///
817    /// `ratatui::text::Line` is a single visual line: embedded `\n`
818    /// characters are flattened. Normalize protocol segments at this
819    /// boundary so `TranscriptLine` keeps its name and rendering contract.
820    fn append_line(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
821        let block_id = self.block_id_for_kind(kind);
822        self.transcript
823            .extend(
824                Self::segments_by_explicit_line(segments)
825                    .into_iter()
826                    .map(|segments| TranscriptLine {
827                        kind,
828                        segments,
829                        block_id,
830                    }),
831            );
832    }
833
834    /// Append line(s) that open a NEW block instead of merging into the
835    /// last block of the same kind — omp-style tool boxes are one
836    /// atomic block per call (border, command, output, border).
837    fn append_line_new_block(&mut self, kind: InlineMessageKind, segments: Vec<InlineSegment>) {
838        let block_id = self.fresh_block_id();
839        self.transcript
840            .extend(
841                Self::segments_by_explicit_line(segments)
842                    .into_iter()
843                    .map(|segments| TranscriptLine {
844                        kind,
845                        segments,
846                        block_id,
847                    }),
848            );
849    }
850
851    /// Append a streaming delta to the active line. Explicit newlines finish
852    /// the current line and open another line in the same semantic block.
853    fn inline_segment(&mut self, kind: InlineMessageKind, segment: InlineSegment) {
854        let mut lines = Self::segments_by_explicit_line(vec![segment]).into_iter();
855
856        // Merge into the tail line only while it belongs to the in-flight
857        // stream. Without the anchor guard, the first delta of a NEW
858        // message would append into the previous message's final line —
859        // mutating history the user already read.
860        let streaming = self.stream_anchor.is_some();
861        if let Some(first) = lines.next() {
862            let merge_ok =
863                streaming && self.transcript.last().is_some_and(|last| last.kind == kind);
864            if merge_ok && let Some(last) = self.transcript.last_mut() {
865                last.segments.extend(first);
866            } else {
867                // A fresh streamed message opens its own block so folding
868                // and turn structure cannot bleed across messages.
869                let block_id = self.fresh_block_id();
870                if self.stream_anchor.is_none() {
871                    self.stream_anchor = Some(self.transcript.len());
872                }
873                self.transcript.push(TranscriptLine {
874                    kind,
875                    segments: first,
876                    block_id,
877                });
878            }
879        }
880        let block_id = self
881            .stream_anchor
882            .and_then(|a| self.transcript.get(a))
883            .map(|l| l.block_id)
884            .unwrap_or_else(|| self.fresh_block_id());
885        self.transcript.extend(lines.map(|segments| TranscriptLine {
886            kind,
887            segments,
888            block_id,
889        }));
890    }
891
892    /// Split styled segments without allocating on the common single-line
893    /// path. Empty chunks are retained because blank lines carry layout.
894    fn segments_by_explicit_line(segments: Vec<InlineSegment>) -> Vec<Vec<InlineSegment>> {
895        if !segments.iter().any(|segment| segment.text.contains('\n')) {
896            return vec![segments];
897        }
898
899        let mut lines = vec![Vec::new()];
900        for segment in segments {
901            let InlineSegment { text, style } = segment;
902            for (index, part) in text.split('\n').enumerate() {
903                if index > 0 {
904                    lines.push(Vec::new());
905                }
906                if !part.is_empty()
907                    && let Some(line) = lines.last_mut()
908                {
909                    line.push(InlineSegment {
910                        text: part.to_string(),
911                        style: Arc::clone(&style),
912                    });
913                }
914            }
915        }
916        lines
917    }
918
919    /// Determine the block_id for a new line: reuse the last line's block
920    /// if the kind matches, otherwise allocate a new block.
921    fn block_id_for_kind(&mut self, kind: InlineMessageKind) -> usize {
922        if let Some(last) = self.transcript.last()
923            && last.kind == kind
924        {
925            return last.block_id;
926        }
927        let id = self.next_block_id;
928        self.next_block_id += 1;
929        id
930    }
931
932    /// Allocate a block id that cannot merge with an existing block.
933    fn fresh_block_id(&mut self) -> usize {
934        let id = self.next_block_id;
935        self.next_block_id += 1;
936        id
937    }
938
939    // ── Search ──
940
941    /// Start a new transcript search, collecting all matching line indices.
942    pub fn start_search(&mut self, query: &str) {
943        let needle = query.to_lowercase();
944        let matches: Vec<usize> = self
945            .transcript
946            .iter()
947            .enumerate()
948            // Committed entries are frozen in the host scrollback —
949            // the live region cannot scroll to them, so search skips.
950            .filter(|(i, _)| *i >= self.committed_entries)
951            .filter(|(_, line)| {
952                line.segments
953                    .iter()
954                    .any(|s| s.text.to_lowercase().contains(&needle))
955            })
956            .map(|(i, _)| i)
957            .collect();
958        self.search = Some(SearchState {
959            query: query.to_string(),
960            matches,
961            current: 0,
962        });
963        // Jump to the first match if any.
964        if let Some(s) = &self.search
965            && let Some(&first) = s.matches.first()
966        {
967            self.scroll_offset = first;
968        }
969    }
970
971    /// Advance to the next search match (wraps around).
972    pub fn search_next(&mut self) {
973        if let Some(s) = &mut self.search
974            && !s.matches.is_empty()
975        {
976            s.current = (s.current + 1) % s.matches.len();
977            let line = s.matches[s.current];
978            self.scroll_offset = line;
979        }
980    }
981
982    /// Go to the previous search match (wraps around).
983    pub fn search_prev(&mut self) {
984        if let Some(s) = &mut self.search
985            && !s.matches.is_empty()
986        {
987            if s.current == 0 {
988                s.current = s.matches.len() - 1;
989            } else {
990                s.current -= 1;
991            }
992            let line = s.matches[s.current];
993            self.scroll_offset = line;
994        }
995    }
996
997    // ── Block display modes (Collapsed / Truncated / Expanded) ──
998
999    /// The display mode for a block — explicit override or the Expanded
1000    /// default. Chat content hides nothing by default: middle-elision
1001    /// made long responses unreadable and unscrollable past the gap.
1002    pub fn block_mode(&self, block_id: usize) -> BlockDisplayMode {
1003        self.block_display
1004            .get(&block_id)
1005            .copied()
1006            .unwrap_or(BlockDisplayMode::Expanded)
1007    }
1008
1009    /// Cycle the display mode of the block at (or nearest above) the current
1010    /// scroll offset: Collapsed → Truncated → Expanded → Collapsed.
1011    pub fn cycle_block_at_view(&mut self) {
1012        let offset = self.effective_offset();
1013        if let Some(line) = self.transcript.get(offset) {
1014            let bid = line.block_id;
1015            let next = match self.block_mode(bid) {
1016                BlockDisplayMode::Collapsed => BlockDisplayMode::Truncated,
1017                BlockDisplayMode::Truncated => BlockDisplayMode::Expanded,
1018                BlockDisplayMode::Expanded => BlockDisplayMode::Collapsed,
1019            };
1020            // Expanded is the default — represent it by absence so the map
1021            // only carries real overrides.
1022            if next == BlockDisplayMode::Expanded {
1023                self.block_display.remove(&bid);
1024            } else {
1025                self.block_display.insert(bid, next);
1026            }
1027        }
1028    }
1029
1030    /// Expand every block. Expanded is the default, so this simply drops
1031    /// all overrides.
1032    pub fn expand_all(&mut self) {
1033        self.block_display.clear();
1034    }
1035
1036    /// Collapse every block (first line only).
1037    pub fn fold_all(&mut self) {
1038        for bid in self.all_block_ids() {
1039            self.block_display.insert(bid, BlockDisplayMode::Collapsed);
1040        }
1041    }
1042
1043    /// Reset every block to the default Truncated mode.
1044    pub fn truncate_all(&mut self) {
1045        // Truncated is no longer the default — it must be recorded
1046        // explicitly for every block.
1047        for bid in self.all_block_ids() {
1048            self.block_display.insert(bid, BlockDisplayMode::Truncated);
1049        }
1050    }
1051
1052    /// Distinct block ids in transcript order.
1053    fn all_block_ids(&self) -> Vec<usize> {
1054        let mut ids = Vec::new();
1055        let mut prev: Option<usize> = None;
1056        for l in &self.transcript {
1057            if prev != Some(l.block_id) {
1058                ids.push(l.block_id);
1059                prev = Some(l.block_id);
1060            }
1061        }
1062        ids
1063    }
1064
1065    // ── Turn navigation ──
1066
1067    /// Jump the scroll to the start of the next assistant (Agent) block.
1068    pub fn jump_next_turn(&mut self) {
1069        let offset = self.effective_offset();
1070        let search_after = self
1071            .transcript
1072            .iter()
1073            .enumerate()
1074            .skip(offset + 1)
1075            .find(|(_, l)| l.kind == InlineMessageKind::Agent || l.kind == InlineMessageKind::User);
1076        if let Some((idx, _)) = search_after {
1077            self.scroll_offset = idx;
1078        }
1079    }
1080
1081    /// Jump the scroll to the start of the previous user block.
1082    pub fn jump_prev_turn(&mut self) {
1083        let offset = self.effective_offset();
1084        let search_before = self
1085            .transcript
1086            .iter()
1087            .enumerate()
1088            .take(offset)
1089            .rev()
1090            .find(|(_, l)| l.kind == InlineMessageKind::User);
1091        if let Some((idx, _)) = search_before {
1092            self.scroll_offset = idx;
1093        }
1094    }
1095
1096    /// Effective scroll offset (resolves `usize::MAX` follow-tail to a real index).
1097    fn effective_offset(&self) -> usize {
1098        if self.scroll_offset == usize::MAX {
1099            self.transcript.len().saturating_sub(1)
1100        } else {
1101            self.scroll_offset
1102        }
1103    }
1104
1105    /// Drop the head of the queued-input list. Called when a turn ends so
1106    /// the queue pane stops showing the prompt that is now running.
1107    pub fn drain_queue_head(&mut self) {
1108        if !self.queued_inputs.is_empty() {
1109            self.queued_inputs.remove(0);
1110        }
1111    }
1112
1113    /// Show an ephemeral tip if the per-session seen-cap hasn't been reached.
1114    /// Each unique `key` can show at most `SEEN_CAP` times per session.
1115    pub fn show_tip(&mut self, key: &'static str, text: &str, ttl: u64, ambient: bool) {
1116        let count = self.seen_tips.entry(key).or_insert(0);
1117        if *count >= SEEN_CAP {
1118            return;
1119        }
1120        *count += 1;
1121        self.tip = Some(EphemeralTip {
1122            text: text.to_string(),
1123            born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
1124            ttl_ticks: ttl,
1125            key,
1126            ambient,
1127        });
1128    }
1129}
1130
1131/// Max times an ambient tip key is shown per session before suppression.
1132const SEEN_CAP: u32 = 3;
1133
1134// ─────────────────────────────────────────────────────────────────────────
1135// Main entry: `pub async fn run_tui(app: App) -> Result<()>`
1136// ─────────────────────────────────────────────────────────────────────────
1137
1138/// Run the new oxicode-vtui powered TUI. Returns once the user exits or the
1139/// session is shut down.
1140pub async fn run_tui(app: App) -> Result<()> {
1141    // Resolve shared session-level context up-front so it can outlive the
1142    // TUI RAII guard via the worker thread.
1143    let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
1144    let git_branch = crate::util::git_utils::get_current_branch(&cwd);
1145    super::host::activate_theme(app.settings());
1146    // Validate active theme contrast and log any warnings.
1147    let theme_id = oxicode_vtui::theme::active_theme_id();
1148    let validation = oxicode_vtui::theme::validate_theme_contrast(&theme_id);
1149    if validation.warnings.is_empty() {
1150        tracing::debug!("theme '{theme_id}' passed contrast validation");
1151    } else {
1152        for w in &validation.warnings {
1153            tracing::warn!("theme contrast: {w}");
1154        }
1155    }
1156
1157    // Wire the inline-protocol channels. `cmd_tx` becomes the
1158    // `InlineHandle`; `evt_tx` is the input-thread → main-loop channel.
1159    let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
1160    let (evt_tx, mut evt_rx) = tokio::sync::mpsc::unbounded_channel::<InlineEvent>();
1161    let handle = InlineHandle::new_for_tests(cmd_tx);
1162
1163    // Build the AgentSession from the App. The helper wraps
1164    // `create_agent_session_from_services` so we can construct the session
1165    // without duplicating the runtime plumbing here.
1166    let session = build_agent_session(&app).await?;
1167    // No install_runtime_hooks call: session queues and stop flag are
1168    // wired into the agent hook chain at agent-build time via
1169    // App::from_oxicode → with_session_hooks.
1170    let session_handle = session.clone_handle();
1171
1172    // Wrap the initial handle in a SessionSwapper. The render loop
1173    // and the agent worker both read through `current()`; the
1174    // resume `tokio::spawn` (below) calls `swap(new_handle)`.
1175    let session_swapper = Arc::new(crate::app::agent_session_handle::SessionSwapper::new(
1176        session_handle.clone(),
1177    ));
1178
1179    // Forward session events to a tokio mpsc so the main loop can
1180    // `tokio::select!` on them. We do this in two stages:
1181    //  1. Subscribe to AgentSession — CompactionStart/End, Advisor,
1182    //     QueueUpdate, etc.
1183    //  2. A forwarder thread that drives `agent.run_with_channel` and
1184    //     calls `forward_event_to_extensions` so per-agent events also
1185    //     flow through the same listener.
1186    let (session_tx, mut session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
1187    let _sub_guard = session.subscribe(Box::new(move |event| {
1188        let _ = session_tx.send(event.clone());
1189    }));
1190
1191    // Header context — built once at startup with workspace + branch.
1192    let header = build_header_context(&app, &cwd, git_branch.as_deref());
1193    handle.set_header_context(header.clone());
1194
1195    // Enter the terminal (RAII). Every setup step is fallible, but a
1196    // successful `Tui::enter` is required to draw anything.
1197    let mut tui = Tui::enter()?;
1198
1199    // Initial composer + placeholder — the harness receives these as
1200    // `SetPrompt` / `SetPlaceholder` commands once it spins up its own
1201    // consumer; we set them eagerly so the very first frame is correct.
1202    handle.set_prompt("> ".to_string(), InlineTextStyle::default());
1203    handle.set_placeholder(Some(
1204        "Describe the task, or type / for commands".to_string(),
1205    ));
1206
1207    // Render state — shared between the input thread (which edits the
1208    // buffer) and the main loop (which reads it for drawing).
1209    let state = Arc::new(parking_lot::Mutex::new(RenderState::new_with_header(
1210        header,
1211    )));
1212    state.lock().ownership_session_id = app.ownership_session_id().to_string();
1213    state.lock().cwd = cwd.clone();
1214    state.lock().catalog = Some(app.catalog());
1215    state.lock().file_commands = crate::tui_vt::slash::file_commands::load_file_commands(&cwd);
1216    state.lock().todo_provider = session_handle.todo_provider();
1217    state.lock().todo_clear_delay_secs = app.settings().todo_clear_delay_secs;
1218    state.lock().hub = Some(session_handle.hub_arc());
1219    state.lock().session_swapper = Some(session_swapper.clone());
1220    state.lock().session_state = Some(app.session_state().clone());
1221    state.lock().thinking_level = format!("{:?}", session.thinking_level()).to_ascii_lowercase();
1222    // MODEL chip + CTX denominator from the live session (the boot header
1223    // context carries the model id; the context window comes from here).
1224    {
1225        let mut s = state.lock();
1226        sync_model_chips(&mut s, &session_handle);
1227    }
1228    // Onboarding tip: surfaces the cheatsheet and help command on first run,
1229    // auto-dismisses after ~30s of rendering.
1230    state.lock().tip = Some(EphemeralTip {
1231        text: "Press ? for shortcuts | /help for commands".to_string(),
1232        born_tick: 0,
1233        ttl_ticks: 900,
1234        key: "onboarding",
1235        ambient: true,
1236    });
1237    // SSH tip: suggest tmux when running over SSH (1-time).
1238    if std::env::var("SSH_CONNECTION").is_ok() {
1239        state.lock().show_tip(
1240            "ssh_wrap",
1241            "Over SSH? Consider tmux to keep sessions alive",
1242            600,
1243            true,
1244        );
1245    }
1246    // Shared autonomy-mode handle — Shift+Tab toggles it at runtime. The
1247    // AskBridge atomic is the authority; the render state mirrors it so the
1248    // composer can draw a mode badge.
1249    let mode_handle = app.ask_bridge().map(|b| {
1250        let handle = b.mode_handle();
1251        state.lock().autonomy_mode = Mode::load(&handle);
1252        handle
1253    });
1254    state.lock().glyph_set = app.settings().glyph_set;
1255    // `inline_images` kill-switch (default ON): flips off every image
1256    // escape write; the transcript's fallback text is all that shows.
1257    state
1258        .lock()
1259        .image_previews
1260        .set_enabled(app.settings().inline_images);
1261    let prompt_queue = Arc::new(PromptQueue::default());
1262    // User-remappable keybindings live in `RenderState::keymap`, seeded
1263    // from `Settings::keybindings` by `new_with_header` above and swapped
1264    // in place by the settings keybindings editor — no separate
1265    // keybindings.yml bootstrap.
1266    let (issue_action_tx, mut issue_action_rx) =
1267        tokio::sync::mpsc::unbounded_channel::<crate::tui_vt::issues_panel::IssueActionRequest>();
1268    spawn_input_thread(
1269        state.clone(),
1270        evt_tx.clone(),
1271        mode_handle,
1272        prompt_queue.clone(),
1273        issue_action_tx.clone(),
1274    );
1275
1276    // Worker thread owns the agent loop and takes prompts from the shared
1277    // authoritative queue before dispatching them through `run_with_channel`. The
1278    // returned `AgentEvent`s flow through a `std::sync::mpsc`; a paired
1279    // forwarder thread funnels them into the session's listener bus so
1280    // our subscriber above picks them up.
1281    spawn_agent_worker(session_swapper.clone(), prompt_queue.clone());
1282    // Brain health prober: pings the oxibrain daemon and feeds the
1283    // status-bar chip through a watch channel. The interval's first tick is
1284    // immediate, so the chip reflects reality on the first frame after a
1285    // brief probe; every 20 s afterwards. A slow/absent daemon never blocks
1286    // the loop — the ping is timeout-bounded.
1287    let (brain_tx, mut brain_rx) =
1288        tokio::sync::watch::channel(crate::services::initial_brain_chip(app.settings()));
1289    {
1290        let memory_enabled = app.settings().memory_enabled;
1291        let announce = handle.clone();
1292        tokio::spawn(async move {
1293            let backend = crate::foundation::brain::BrainMemoryBackend::new(
1294                crate::foundation::brain::default_socket_path(),
1295            );
1296            let mut tick = tokio::time::interval(std::time::Duration::from_secs(20));
1297            tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1298            // One automatic revive per session (success or failure — a
1299            // broken daemon must not turn the prober into a spawn loop).
1300            let mut auto_revive_attempted = false;
1301            loop {
1302                tick.tick().await;
1303                let mut chip = if !memory_enabled {
1304                    BrainChip::Off
1305                } else if !crate::services::brain_socket_present(
1306                    &crate::foundation::brain::default_socket_path(),
1307                ) {
1308                    BrainChip::Down
1309                } else {
1310                    match tokio::time::timeout(
1311                        std::time::Duration::from_millis(1500),
1312                        backend.ping(),
1313                    )
1314                    .await
1315                    {
1316                        Ok(Ok(())) => BrainChip::Ok,
1317                        Ok(Err(_)) | Err(_) => BrainChip::Degraded,
1318                    }
1319                };
1320                // Auto-revive: memory users get their daemon back without
1321                // typing /brain. Never installs (binary check), never
1322                // retries, and says what it did on the transcript.
1323                let down = matches!(chip, BrainChip::Down | BrainChip::Degraded);
1324                if crate::foundation::brain_control::should_auto_revive(
1325                    memory_enabled,
1326                    down,
1327                    auto_revive_attempted,
1328                ) {
1329                    auto_revive_attempted = true;
1330                    let installed = crate::foundation::brain_control::probe_control()
1331                        .binary
1332                        .is_some();
1333                    if installed {
1334                        match crate::foundation::brain_control::revive().await {
1335                            Ok(msg) => {
1336                                announce.append_line(
1337                                    InlineMessageKind::Info,
1338                                    vec![plain_segment(format!("brain: daemon was down — {msg}"))],
1339                                );
1340                                // Re-probe now instead of waiting a tick.
1341                                chip = match tokio::time::timeout(
1342                                    std::time::Duration::from_millis(1500),
1343                                    backend.ping(),
1344                                )
1345                                .await
1346                                {
1347                                    Ok(Ok(())) => BrainChip::Ok,
1348                                    _ => chip,
1349                                };
1350                            }
1351                            Err(e) => {
1352                                announce.append_line(
1353                                    InlineMessageKind::Warning,
1354                                    vec![plain_segment(format!(
1355                                        "brain: auto-restart failed — {e} (run /brain for details)"
1356                                    ))],
1357                                );
1358                            }
1359                        }
1360                    }
1361                }
1362                let _ = brain_tx.send(chip);
1363            }
1364        });
1365    }
1366
1367    let result = run_event_loop(
1368        &mut tui.terminal,
1369        &mut cmd_rx,
1370        &mut evt_rx,
1371        &mut session_rx,
1372        &mut brain_rx,
1373        &handle,
1374        &state,
1375        &session_swapper,
1376        &prompt_queue,
1377        &mut issue_action_rx,
1378    )
1379    .await;
1380
1381    handle.shutdown();
1382    // Dropping `tui` restores the terminal. Drop is at function return.
1383    drop(tui);
1384
1385    result
1386}
1387
1388// ─────────────────────────────────────────────────────────────────────────
1389// Event loop
1390// ─────────────────────────────────────────────────────────────────────────
1391#[allow(clippy::too_many_arguments)]
1392async fn run_event_loop(
1393    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
1394    cmd_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineCommand>,
1395    evt_rx: &mut tokio::sync::mpsc::UnboundedReceiver<InlineEvent>,
1396    session_rx: &mut tokio::sync::mpsc::UnboundedReceiver<SessionEvent>,
1397    brain_rx: &mut tokio::sync::watch::Receiver<BrainChip>,
1398    handle: &InlineHandle,
1399    state: &Arc<parking_lot::Mutex<RenderState>>,
1400    session_swapper: &Arc<crate::app::agent_session_handle::SessionSwapper>,
1401    prompt_queue: &Arc<PromptQueue>,
1402    issue_action_rx: &mut tokio::sync::mpsc::UnboundedReceiver<
1403        crate::tui_vt::issues_panel::IssueActionRequest,
1404    >,
1405) -> Result<()> {
1406    // Drain any pending InlineCommands so the harness's initial set_header_context
1407    // (and similar) is observed before the first frame.
1408    while let Ok(cmd) = cmd_rx.try_recv() {
1409        apply_command(&mut state.lock(), cmd);
1410    }
1411
1412    // Seed the resize detector from the real terminal before any
1413    // frame is drawn (final-review finding 1). `RenderState::
1414    // default()`'s 80 columns is a test-only fallback; left in place
1415    // it made the first draw of any terminal wider than 80 look like
1416    // a resize (80 → real width) and fire CSI 3J + Clear, wiping the
1417    // user's pre-TUI shell scrollback on every launch. On a size
1418    // failure we park the 0 sentinel — `should_rebuild_scrollback`
1419    // refuses to wipe until a real width has been observed.
1420    {
1421        let mut s = state.lock();
1422        match terminal.size() {
1423            Ok(size) => {
1424                s.viewport_width = size.width;
1425                s.last_viewport_height = size.height;
1426            }
1427            Err(_) => {
1428                s.viewport_width = 0;
1429                s.last_viewport_height = 0;
1430            }
1431        }
1432    }
1433
1434    // Draw the initial frame *before* blocking on the first event. The
1435    // `select!` below parks until an event arrives, and the per-iteration
1436    // redraw only runs after it resolves — so without this eager draw the
1437    // screen stays black until the user presses a key.
1438    {
1439        let snapshot = state.lock();
1440        let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1441        if let Err(err) = terminal.draw(|frame| render_frame(frame, &snapshot, handle)) {
1442            tracing::warn!(?err, "initial tui draw failed");
1443        }
1444        let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1445    }
1446
1447    // Render tick. The input thread edits shared state (typing, cursor
1448    // movement, backspace, …) *without* sending an event, so without a
1449    // periodic wake the composer would never repaint what the user types.
1450    // The ratatui diff backend coalesces unchanged frames, so a steady tick
1451    // is cheap and also drives future spinner animation.
1452    let mut render_tick = tokio::time::interval(std::time::Duration::from_millis(50));
1453    render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1454    // Render coalescing: most iterations skip the full draw and instead
1455    // rely on the 50ms `render_tick` arm below to guarantee a heartbeat.
1456    // `priority` is raised by user-facing arms (keyboard, SIGINT, brain
1457    // chip) so typing/cancels/chip flips repaint immediately.
1458    let mut last_draw = std::time::Instant::now();
1459    let mut priority = false;
1460
1461    loop {
1462        tokio::select! {
1463            // biased: agent events take priority so streaming output is
1464            // never starved by Ctrl+C noise or sticky key repeats.
1465            biased;
1466
1467            // 1. Agent → TUI commands (transcript updates).
1468            Some(cmd) = cmd_rx.recv() => {
1469                let shutdown = {
1470                    let mut s = state.lock();
1471                    apply_command(&mut s, cmd)
1472                };
1473                if shutdown {
1474                    break;
1475                }
1476            }
1477
1478            // 2. Agent → TUI events (token deltas, tool calls, …).
1479            Some(event) = session_rx.recv() => {
1480                // Intercept handoff-completion to clear transcript + auto-submit.
1481                if let SessionEvent::HandoffComplete { doc_path, auto_continue } = &event {
1482                    let mut s = state.lock();
1483                    s.transcript.clear();
1484                    s.message_buffer.clear();
1485                    s.scroll_offset = usize::MAX;
1486                    s.append_line(
1487                        InlineMessageKind::Info,
1488                        vec![plain_segment(format!(
1489                            "Handoff written to {}. New session started.",
1490                            doc_path
1491                        ))],
1492                    );
1493                    let user_typed = !s.composer.text().trim().is_empty();
1494                    s.composer.set_text("");
1495                    if *auto_continue && !user_typed {
1496                        drop(s);
1497                        prompt_queue.enqueue(format!(
1498                            "Read the handoff document at {} and continue \
1499                             from where the previous session left off.",
1500                            doc_path
1501                        ));
1502                    } else if user_typed {
1503                        drop(s);
1504                        handle.append_line(
1505                            InlineMessageKind::Info,
1506                            vec![plain_segment(
1507                                "Handoff complete. Auto-continue skipped \
1508                                 because input was non-empty \u{2014} press \
1509                                 Enter to submit your message in the new \
1510                                 session."
1511                                    .to_string(),
1512                            )],
1513                        );
1514                    }
1515                    let session = session_swapper.current();
1516                    handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1517                } else {
1518                    // Every regular agent event must reach the presentation
1519                    // bridge.  The handoff path above already does this after
1520                    // resetting the transcript; previously it was the *only*
1521                    // path that did.  As a result, prompts ran in the worker
1522                    // but token deltas, tool progress, and provider errors
1523                    // were silently discarded before a frame could render.
1524                    let session = session_swapper.current();
1525                    handle_session_event(&mut state.lock(), handle, &event, Some(&session));
1526                }
1527            }
1528
1529            // 3. Keyboard / paste / TUI events from the input thread.
1530            Some(evt) = evt_rx.recv() => {
1531                let outcome = handle_inline_event(
1532                    &mut state.lock(),
1533                    handle,
1534                    &session_swapper.current(),
1535                    prompt_queue,
1536                    evt,
1537                );
1538                if outcome == LoopOutcome::Exit {
1539                    break;
1540                }
1541                priority = true;
1542            }
1543
1544            // 4. Issue panel action requests from the input thread (CAS-guarded
1545            //    async store writes that can't run on the sync key path).
1546            Some(req) = issue_action_rx.recv() => {
1547                crate::tui_vt::issues_panel::dispatch_action(req, state.clone());
1548            }
1549
1550            // 5. External SIGINT — route through the same idle-vs-streaming
1551            //    policy as the key path (some terminals deliver Ctrl+C both
1552            //    as a key event AND raise SIGINT; `kill -INT` also lands here).
1553            _ = tokio::signal::ctrl_c() => {
1554                let outcome = {
1555                    let mut s = state.lock();
1556                    handle_interrupt(&mut s, &session_swapper.current(), handle)
1557                };
1558                if outcome == LoopOutcome::Exit {
1559                    break;
1560                }
1561                priority = true;
1562            }
1563            // 6. Brain health chip updates from the background prober.
1564            changed = brain_rx.changed() => {
1565                if changed.is_ok() {
1566                    state.lock().brain = *brain_rx.borrow_and_update();
1567                    priority = true;
1568                }
1569            }
1570
1571            // 7. Periodic repaint — echoes typed input and drives animation
1572            //    even when no other event is ready.
1573            _ = render_tick.tick() => {}
1574        }
1575
1576        // Render coalescing: skip the snapshot/draw pipeline when no
1577        // user-facing arm raised priority and the render cadence has not
1578        // elapsed. The 50ms `render_tick` arm guarantees the heartbeat.
1579        if coalesce_draw(last_draw, priority, DRAW_MIN_INTERVAL) == DrawDecision::DrawNow {
1580            // small_screen tip: warn when terminal is too narrow for full UI.
1581            if let Ok(size) = terminal.size()
1582                && size.width < 40
1583            {
1584                let mut s = state.lock();
1585                if s.tip.is_none() {
1586                    s.show_tip(
1587                        "small_screen",
1588                        "Terminal too narrow \u{2014} resize for full UI",
1589                        300,
1590                        true,
1591                    );
1592                }
1593            }
1594            // Redraw. The harness's redraw is idempotent — the ratatui
1595            let mut snapshot = state.lock();
1596            // Resize observation: ratatui's Inline viewport auto-resizes
1597            // the cursor-row viewport on terminal draw, but the frozen
1598            // transcript in the host scrollback was printed at the
1599            // previous width and cannot re-wrap. When the width changes
1600            // we must (1) wipe the scrollback (CSI 3J) so stale-width
1601            // rows disappear, (2) clear the visible screen so the
1602            // viewport re-anchors cleanly, and (3) reset
1603            // `committed_entries` so the next ticks re-commit at the
1604            // new width. Height-only resize is a no-op (the live
1605            // region just grows or shrinks under the frozen history).
1606            let mut prev_size: Option<(u16, u16)> = None;
1607            if let Ok(size) = terminal.size() {
1608                prev_size = Some((snapshot.viewport_width, snapshot.last_viewport_height));
1609                snapshot.viewport_width = size.width;
1610                snapshot.last_viewport_height = size.height;
1611            }
1612            if let Some((prev_w, prev_h)) = prev_size
1613                && let Ok(size) = terminal.size()
1614                && should_rebuild_scrollback(prev_w, size.width, prev_h, size.height)
1615            {
1616                // CSI 3J erases the host scrollback; Clear(All) wipes
1617                // the visible viewport so stale-width rows vanish.
1618                let _ = execute!(terminal.backend_mut(), crossterm::style::Print("\x1b[3J"));
1619                let _ = terminal.clear();
1620                snapshot.committed_entries = 0;
1621                // No `priority = true` here — the unconditional reset
1622                // at the end of the draw branch would clobber it. The
1623                // CSI 3J + Clear already wiped the visible frame, so
1624                // the next render cadence tick repaints cleanly.
1625            }
1626            // pane reflects phase changes written by the `todo` agent tool, plus
1627            // subagent auto-reconcile (idle subagents close their matched todos).
1628            if let Some(provider) = snapshot.todo_provider.as_ref() {
1629                snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1630            }
1631            // HUD-only auto-clear: once the list settles (all closed) and the
1632            // delay elapses, drop the phases from the pane. The underlying
1633            // TodoState is untouched, so a later `/todo` or `todo` tool call
1634            // still sees the historical phases.
1635            let clear_delay = snapshot.todo_clear_delay_secs;
1636            sync_todo_clear_timer(&mut snapshot, clear_delay);
1637            // Typewriter paint: reveal the streamed body a bounded step per
1638            // frame so it types out instead of jumping per network chunk.
1639            advance_stream_reveal(&mut snapshot);
1640            // Shed finalized rows into the host scrollback before the
1641            // synchronized repaint so the commit and the viewport redraw
1642            // land as one visual update.
1643            commit_scrollback(terminal, &mut snapshot, false);
1644            let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1645            let draw_err = terminal
1646                .draw(|frame| render_frame(frame, &snapshot, handle))
1647                .err();
1648            let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1649            // Inline image previews: now that the frame (with the image
1650            // tool boxes) has flushed, emit the kitty transmit/place or
1651            // iTerm2 inline escapes for rows that rendered LIVE this
1652            // frame. Committed rows never emit — their fallback text is
1653            // already in the host scrollback. The write goes through the
1654            // terminal backend (same path as the synchronized-update
1655            // escapes above).
1656            let committed = snapshot.committed_entries;
1657            let image_escapes = snapshot.image_previews.emit_live(committed);
1658            if !image_escapes.is_empty() {
1659                let _ = execute!(
1660                    terminal.backend_mut(),
1661                    crossterm::style::Print(image_escapes)
1662                );
1663            }
1664            if let Some(err) = draw_err {
1665                tracing::warn!(?err, "tui draw failed");
1666                break;
1667            }
1668            // Reset cadence — next draw is gated again until either
1669            // priority is raised or the interval elapses.
1670            last_draw = std::time::Instant::now();
1671            priority = false;
1672        }
1673    }
1674
1675    // Exit flush: land every committable finalized row into the host
1676    // scrollback before the caller drops `Tui` (which restores the
1677    // terminal — after that the host scrollback is no longer in raw
1678    // mode and the print-before rows survive). The cap is a safety
1679    // belt: a stuck `insert_before` (broken terminal) cannot trap us
1680    // in the flush.
1681    const MAX_EXIT_FLUSH_ITERATIONS: usize = 50;
1682    for _ in 0..MAX_EXIT_FLUSH_ITERATIONS {
1683        let mut snapshot = state.lock();
1684        let before = snapshot.committed_entries;
1685        if snapshot.transcript.is_empty() || before >= snapshot.transcript.len() {
1686            break;
1687        }
1688        // pane reflects phase changes written by the `todo` agent tool, plus
1689        // subagent auto-reconcile (idle subagents close their matched todos).
1690        if let Some(provider) = snapshot.todo_provider.as_ref() {
1691            snapshot.todo_phases = refresh_todo_phases(provider, snapshot.hub.as_ref());
1692        }
1693        // HUD-only auto-clear: once the list settles (all closed) and the
1694        // delay elapses, drop the phases from the pane. The underlying
1695        // TodoState is untouched, so a later `/todo` or `todo` tool call
1696        // still sees the historical phases.
1697        let clear_delay = snapshot.todo_clear_delay_secs;
1698        sync_todo_clear_timer(&mut snapshot, clear_delay);
1699        // Typewriter paint: reveal the streamed body a bounded step per
1700        // frame so it types out instead of jumping per network chunk.
1701        advance_stream_reveal(&mut snapshot);
1702        // Shed finalized rows into the host scrollback before the
1703        // synchronized repaint so the commit and the viewport redraw
1704        // land as one visual update.
1705        commit_scrollback(terminal, &mut snapshot, true);
1706        let _ = execute!(terminal.backend_mut(), BeginSynchronizedUpdate);
1707        let draw_err = terminal
1708            .draw(|frame| render_frame(frame, &snapshot, handle))
1709            .err();
1710        let _ = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
1711        if let Some(err) = draw_err {
1712            tracing::warn!(?err, "tui draw failed");
1713            break;
1714        }
1715    }
1716
1717    Ok(())
1718}
1719
1720/// Minimum interval between successive full `terminal.draw` passes driven
1721/// by the event loop. User-facing arms (keyboard, SIGINT, brain chip)
1722/// bypass this via `priority = true`; token-stream agent events coalesce
1723/// here so a 200-events/sec burst does not become 200 draws/sec.
1724const DRAW_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
1725
1726/// Whether the post-select draw block should run this iteration.
1727#[derive(Debug, PartialEq, Eq)]
1728enum DrawDecision {
1729    /// Run the snapshot/draw pipeline now.
1730    DrawNow,
1731    /// Skip the draw — nothing on screen needs an immediate repaint and
1732    /// the frame cadence has not elapsed yet.
1733    Defer,
1734}
1735
1736/// Pure coalescing decision: `priority` (user input) always wins; otherwise
1737/// we draw once the render-cadence timer has elapsed since the last draw.
1738fn coalesce_draw(
1739    last_draw_at: std::time::Instant,
1740    priority: bool,
1741    min_interval: std::time::Duration,
1742) -> DrawDecision {
1743    if priority || last_draw_at.elapsed() >= min_interval {
1744        DrawDecision::DrawNow
1745    } else {
1746        DrawDecision::Defer
1747    }
1748}
1749
1750#[derive(PartialEq, Eq)]
1751enum LoopOutcome {
1752    Continue,
1753    Exit,
1754}
1755
1756/// Whether an Esc-driven cancel should abort the running stream (via the
1757/// interrupt path, which sets the footer + abort) or exit the app outright
1758/// (idle one-press quit). Extracted as a pure function so the routing can
1759/// be unit-tested without a live `AgentSessionHandle`.
1760#[derive(PartialEq, Eq, Debug)]
1761enum CancelRoute {
1762    /// A stream is running: abort it. The input thread's ~1s post-cancel
1763    /// grace then prevents mashing Esc from firing repeated cancels.
1764    Interrupt,
1765    /// Idle: instant one-press quit — no quit-arming footer, no grace.
1766    Exit,
1767}
1768
1769/// Pure routing decision for `InlineEvent::Cancel`. While a stream is
1770/// running, Esc aborts it (matching Ctrl+C). When idle, Esc quits at once.
1771fn route_cancel(is_streaming: bool) -> CancelRoute {
1772    if is_streaming {
1773        CancelRoute::Interrupt
1774    } else {
1775        CancelRoute::Exit
1776    }
1777}
1778
1779// ─────────────────────────────────────────────────────────────────────────
1780/// Apply a single `InlineCommand` to the render state. Returns `true`
1781/// when the harness has requested a shutdown.
1782fn apply_command(state: &mut RenderState, cmd: InlineCommand) -> bool {
1783    match cmd {
1784        InlineCommand::AppendLine { kind, segments } => {
1785            state.append_line(kind, segments);
1786        }
1787        InlineCommand::Inline { kind, segment } => {
1788            state.inline_segment(kind, segment);
1789        }
1790        InlineCommand::BeginStream { .. } => {
1791            // A new streamed message opens: drop the anchor so the first
1792            // delta starts a fresh block instead of merging into the
1793            // previous message's rendered lines.
1794            state.stream_anchor = None;
1795        }
1796        InlineCommand::EndStream => {
1797            // The streamed message finalized: release the anchor so the
1798            // finished block can commit to the host scrollback. Travels
1799            // in the command stream after the final ReplaceLast — the
1800            // causal order keeps the anchor pinned through the last
1801            // re-render.
1802            state.stream_anchor = None;
1803        }
1804        InlineCommand::AppendLineBlockStart { kind, segments } => {
1805            state.append_line_new_block(kind, segments);
1806        }
1807        InlineCommand::ReplaceLast { kind, lines, .. } => {
1808            // The anchor records where this message's streamed block
1809            // begins; the markdown re-render replaces the whole block
1810            // from there (the raw stream and the markdown render split
1811            // the same text across different line counts, so a tail-pop
1812            // by count would duplicate or eat lines). Without an anchor
1813            // the lines append — a blind tail-pop could eat unrelated
1814            // transcript history.
1815            let from = state.stream_anchor.unwrap_or(state.transcript.len());
1816            state.transcript.truncate(from);
1817            for line in lines {
1818                state.append_line(kind, line);
1819            }
1820            // Keep the anchor pinned at the block start: every later
1821            // delta of the same message re-renders from here. BeginStream
1822            // clears it when the next message opens.
1823            state.stream_anchor = Some(from);
1824        }
1825        InlineCommand::AppendPastedMessage { kind, text, .. } => {
1826            state.append_line(kind, vec![plain_segment(text)]);
1827        }
1828        InlineCommand::SetPrompt { prefix, .. } => {
1829            state.prompt_prefix = prefix;
1830        }
1831        InlineCommand::SetPlaceholder { hint, .. } => {
1832            state.placeholder = hint;
1833        }
1834        InlineCommand::SetHeaderContext { context } => {
1835            state.header_context = *context;
1836        }
1837        InlineCommand::SetInputStatus { .. } => {
1838            // The dedicated status row was removed; input-status text has
1839            // no render surface. Kept as a graceful no-op for protocol
1840            // compatibility with harnesses that still send it.
1841        }
1842        InlineCommand::SetInputEnabled(enabled) => {
1843            state.input_enabled = enabled;
1844        }
1845        InlineCommand::SetCursorVisible(_) | InlineCommand::ForceRedraw => {}
1846        InlineCommand::SetReasoningStage(stage) => {
1847            state.reasoning_stage = stage;
1848        }
1849        InlineCommand::SetVimModeEnabled(enabled) => {
1850            state.vim_state.set_enabled(enabled);
1851        }
1852        InlineCommand::SetQueuedInputs { entries } => {
1853            state.queued_inputs = entries;
1854        }
1855        InlineCommand::ShowOverlay { request } => {
1856            let mut overlay = materialize_overlay(*request);
1857            // The `/settings` panel arrives as the flat Task-4 list; its
1858            // rows are the only producers of ConfigAction selections.
1859            // Hydrate the full tabbed/sidebar overlay from the def table
1860            // (reopening on the last active tab) instead. Map-editor row
1861            // metadata rides along with the hydration; every other
1862            // overlay invalidates it.
1863            let mut map_rows = Vec::new();
1864            if overlay.items.iter().any(|it| {
1865                matches!(
1866                    it.selection,
1867                    Some(InlineListSelection::ConfigAction(_))
1868                        | Some(InlineListSelection::SettingsTab(_))
1869                        | Some(InlineListSelection::SettingsSection(_))
1870                        | Some(InlineListSelection::SettingKeyCapture(_))
1871                        | Some(InlineListSelection::SettingTextEdit(_))
1872                        | Some(InlineListSelection::SettingSubmenuOpen(_))
1873                        | Some(InlineListSelection::SettingMultiselect(_))
1874                )
1875            }) {
1876                let (hydrated, rows) = build_settings_overlay(state.settings_active_tab, None);
1877                overlay = hydrated;
1878                map_rows = rows;
1879            }
1880            state.overlay = Some(overlay);
1881            state.settings_map_rows = map_rows;
1882        }
1883        InlineCommand::CloseOverlay => {
1884            state.overlay = None;
1885        }
1886        InlineCommand::Shutdown => {
1887            state.shutdown_requested = true;
1888            return true;
1889        }
1890        _ => {
1891            // Surface unknown commands as info so they are visible
1892            // during development.
1893            tracing::trace!("unhandled InlineCommand (not rendered)");
1894        }
1895    }
1896    false
1897}
1898
1899/// Convert an `OverlayRequest` into the render-state representation used by
1900/// the TUI. The input thread mutates `selected` / `search` while the overlay
1901/// is open, and `handle_inline_event` projects the user's selection back to
1902/// the harness as `InlineEvent::Overlay`.
1903fn materialize_overlay(request: OverlayRequest) -> OverlayState {
1904    match request {
1905        OverlayRequest::Modal(req) => {
1906            let secure_input = req.secure_prompt.map(|cfg| OverlaySecureInput {
1907                config: cfg,
1908                editor: EditBuffer::new(),
1909            });
1910            OverlayState {
1911                title: req.title,
1912                lines: req.lines,
1913                items: Vec::new(),
1914                selected: 0,
1915                search: None,
1916                secure_input,
1917                ..Default::default()
1918            }
1919        }
1920        OverlayRequest::List(req) => {
1921            let search = req.search.map(|cfg| OverlaySearchState {
1922                label: cfg.label,
1923                placeholder: cfg.placeholder,
1924                value: String::new(),
1925            });
1926            OverlayState {
1927                title: req.title,
1928                lines: req.lines,
1929                items: req.items.into_iter().map(overlay_item_from).collect(),
1930                selected: 0,
1931                search,
1932                secure_input: None,
1933                ..Default::default()
1934            }
1935        }
1936        OverlayRequest::Wizard(req) => {
1937            // Wizard overlays are multi-step flows that this TUI does not yet
1938            // render natively; surface the first step's title/items so the
1939            // user still sees something instead of a blank panel.
1940            let step_items = req
1941                .steps
1942                .first()
1943                .map(|s| {
1944                    s.items
1945                        .iter()
1946                        .map(|it| overlay_item_from(it.clone()))
1947                        .collect()
1948                })
1949                .unwrap_or_default();
1950            let search = req.search.map(|cfg| OverlaySearchState {
1951                label: cfg.label,
1952                placeholder: cfg.placeholder,
1953                value: String::new(),
1954            });
1955            OverlayState {
1956                title: req.title,
1957                lines: Vec::new(),
1958                items: step_items,
1959                selected: 0,
1960                search,
1961                secure_input: None,
1962                ..Default::default()
1963            }
1964        }
1965    }
1966}
1967fn overlay_item_from(item: InlineListItem) -> OverlayListItem {
1968    OverlayListItem {
1969        title: item.title,
1970        subtitle: item.subtitle,
1971        badge: item.badge,
1972        indent: item.indent,
1973        search_value: item.search_value,
1974        selection: item.selection,
1975    }
1976}
1977
1978/// Canonical `/settings` tab order. Indices are the
1979/// `InlineListSelection::SettingsTab(usize)` payloads and
1980/// `OverlayState::active_tab`.
1981const SETTINGS_TABS: &[(SettingsTab, &str)] = &[
1982    (SettingsTab::General, "General"),
1983    (SettingsTab::Model, "Model"),
1984    (SettingsTab::Interaction, "Interaction"),
1985    (SettingsTab::Tools, "Tools"),
1986    (SettingsTab::Ui, "UI"),
1987    (SettingsTab::AdvisorMemory, "Advisor & Memory"),
1988    (SettingsTab::Keybindings, "Keybindings"),
1989    (SettingsTab::Advanced, "Advanced"),
1990];
1991
1992/// Build the full tabbed `/settings` overlay for `tab`: tab-bar labels,
1993/// sidebar section labels (group names, declaration order), and one row
1994/// per def via [`settings_overlay_items`] — the same row builder the
1995/// `/settings` slash command uses, hydrated with the tab/sidebar state.
1996/// `keep_search` preserves the live filter across tab switches.
1997///
1998/// Returns the overlay plus the map-row table (index-aligned with the
1999/// items) for the input thread's `Enter` / `d` / `n` routing.
2000fn build_settings_overlay(
2001    tab: SettingsTab,
2002    keep_search: Option<OverlaySearchState>,
2003) -> (OverlayState, Vec<Option<SettingsMapRow>>) {
2004    let settings = crate::store::settings::Settings::load().unwrap_or_default();
2005    let (items, map_rows) = settings_overlay_items(tab, &settings);
2006    let items: Vec<OverlayListItem> = items.into_iter().map(overlay_item_from).collect();
2007    let mut sections: Vec<String> = Vec::new();
2008    for def in defs_for_tab(tab, &settings) {
2009        if sections.last().map(String::as_str) != Some(def.group) {
2010            sections.push(def.group.to_string());
2011        }
2012    }
2013    let active_tab = SETTINGS_TABS
2014        .iter()
2015        .position(|(t, _)| *t == tab)
2016        .unwrap_or(0);
2017    (
2018        OverlayState {
2019            title: "Settings".into(),
2020            lines: vec!["Browse settings by group; filter with the search bar.".into()],
2021            items,
2022            selected: 0,
2023            search: keep_search.or(Some(OverlaySearchState {
2024                label: "Filter settings".into(),
2025                placeholder: Some("Type to filter".into()),
2026                value: String::new(),
2027            })),
2028            secure_input: None,
2029            tabs: SETTINGS_TABS
2030                .iter()
2031                .map(|(_, name)| name.to_string())
2032                .collect(),
2033            active_tab,
2034            sections,
2035            active_section: 0,
2036            key_capture: None,
2037        },
2038        map_rows,
2039    )
2040}
2041
2042/// Reopen (or switch) the settings panel on `tab`, replacing the first
2043/// context line with `status` when given. Keeps the live search filter,
2044/// syncs `settings_active_tab`, and refreshes the map-row table — the
2045/// single assignment path for the tabbed panel so the rows can never
2046/// drift from the items.
2047fn reopen_settings_panel(state: &mut RenderState, tab: SettingsTab, status: Option<String>) {
2048    let keep_search = state.overlay.as_ref().and_then(|o| o.search.clone());
2049    state.settings_active_tab = tab;
2050    let (mut overlay, map_rows) = build_settings_overlay(tab, keep_search);
2051    if let Some(status) = status {
2052        if overlay.lines.is_empty() {
2053            overlay.lines.push(status);
2054        } else {
2055            overlay.lines[0] = status;
2056        }
2057    }
2058    state.overlay = Some(overlay);
2059    state.settings_map_rows = map_rows;
2060}
2061
2062/// Switch the settings overlay to `SETTINGS_TABS[tab_idx]`: rebuild items
2063/// and sections for that tab (keeping the live search filter) and sync
2064/// `RenderState::settings_active_tab` so a later `/settings` reopens on
2065/// the same tab. No-op when the index is out of range or no overlay is
2066/// open.
2067fn switch_settings_tab(state: &mut RenderState, tab_idx: usize) {
2068    let Some(&(tab, _)) = SETTINGS_TABS.get(tab_idx) else {
2069        return;
2070    };
2071    let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2072    state.settings_active_tab = tab;
2073    let (overlay, map_rows) = build_settings_overlay(tab, search);
2074    state.overlay = Some(overlay);
2075    state.settings_map_rows = map_rows;
2076}
2077
2078/// Jump the settings overlay's selection to the first row of sidebar
2079/// section `section_idx` (an index into `OverlayState::sections`).
2080/// Rebuilds the overlay for the active tab first — submissions arrive
2081/// after the overlay was closed, so the panel has to be reopened anyway.
2082fn jump_settings_section(state: &mut RenderState, section_idx: usize) {
2083    let tab = state.settings_active_tab;
2084    let search = state.overlay.as_ref().and_then(|o| o.search.clone());
2085    let (mut overlay, map_rows) = build_settings_overlay(tab, search);
2086    if let Some(target) = overlay.sections.get(section_idx).cloned() {
2087        // Heading rows (title-only items, per the settings_overlay_items
2088        // convention) delimit groups; the first selectable row after the
2089        // target heading is the section's anchor.
2090        let mut in_target = false;
2091        let mut anchor: Option<usize> = None;
2092        for (idx, item) in overlay.items.iter().enumerate() {
2093            let is_heading =
2094                item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2095            if is_heading {
2096                in_target = item.title == target;
2097            } else if in_target && anchor.is_none() {
2098                anchor = Some(idx);
2099            }
2100        }
2101        if let Some(idx) = anchor {
2102            overlay.selected = idx;
2103            overlay.active_section = section_idx;
2104        }
2105    }
2106    state.overlay = Some(overlay);
2107    state.settings_map_rows = map_rows;
2108}
2109
2110// ─────────────────────────────────────────────────────────────────────────
2111// Keybindings map editor (capture / remove / live swap)
2112// ─────────────────────────────────────────────────────────────────────────
2113
2114/// The "press a key combo" prompt shown after selecting an action row.
2115/// `key_capture` marks capture mode for the input thread.
2116fn build_key_capture_overlay(action_name: &str) -> OverlayState {
2117    OverlayState {
2118        title: format!("Keybinding: {action_name}"),
2119        lines: vec![format!(
2120            "Press a key combo for {action_name} (Esc to cancel)\u{2026}"
2121        )],
2122        key_capture: Some(action_name.to_string()),
2123        ..Default::default()
2124    }
2125}
2126
2127/// Serialize an incoming key event into its canonical `KeyCombo` text.
2128///
2129/// Only `Ctrl` / `Alt` / `Shift` survive (SUPER & co. would never
2130/// round-trip through `KeyCombo::parse`), and a shifted lowercase char
2131/// is uppercased — the same canonicalization `parse` applies — so the
2132/// serialization always round-trips. Kitty note (Task 2): with
2133/// `OXICODE_KITTY_KEYBOARD` the terminal already clears SHIFT on
2134/// shifted chars (they arrive uppercase), which this normalization is
2135/// self-consistent with.
2136fn key_event_to_combo_text(key: KeyEvent) -> Option<(String, KeyCombo)> {
2137    use crossterm::event::KeyCode as Kc;
2138    let mods = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT);
2139    let code = match key.code {
2140        // Canonical shifted-letter form: uppercase char (crossterm's
2141        // `normalize_case` shape), SHIFT retained.
2142        Kc::Char(c) if c.is_ascii_lowercase() && mods.contains(KeyModifiers::SHIFT) => {
2143            Kc::Char(c.to_ascii_uppercase())
2144        }
2145        other => other,
2146    };
2147    let combo = KeyCombo {
2148        code,
2149        modifiers: mods,
2150    };
2151    let text = combo.to_string();
2152    // Reject anything that cannot round-trip through `KeyCombo::parse`
2153    // (F-keys, arrows, Home/End, …): persisting them would write a
2154    // binding that never resolves.
2155    (KeyCombo::parse(&text) == Some(combo.clone())).then_some((text, combo))
2156}
2157
2158/// Handle the next key while the key-capture prompt is open. Esc (no
2159/// Ctrl/Alt) cancels back to the Keybindings tab; any other key is
2160/// validated (`key_event_to_combo_text` + a Ctrl/Alt requirement, since
2161/// an unmodified key would hijack typing) and, when valid, appended to
2162/// the action's live combo list, persisted, and swapped into
2163/// `RenderState::keymap`. Rejections keep the prompt open with the
2164/// reason as its only line.
2165fn handle_key_capture(state: &mut RenderState, key: KeyEvent) {
2166    let Some(action_name) = state.overlay.as_ref().and_then(|o| o.key_capture.clone()) else {
2167        return;
2168    };
2169    let Some(action) = GlobalAction::from_name(&action_name) else {
2170        // Unreachable unless a capture overlay is built by hand with a
2171        // bogus name — fail closed by closing the prompt.
2172        state.overlay = None;
2173        state.settings_map_rows.clear();
2174        return;
2175    };
2176    // Esc cancels without capturing.
2177    if key.code == KeyCode::Esc
2178        && !key
2179            .modifiers
2180            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2181    {
2182        reopen_settings_panel(state, SettingsTab::Keybindings, None);
2183        return;
2184    }
2185    let Some((text, _combo)) = key_event_to_combo_text(key) else {
2186        set_capture_prompt_line(
2187            state,
2188            "That key can't be captured (F-keys and arrows don't round-trip). \
2189             Try another combo, Esc to cancel\u{2026}",
2190        );
2191        return;
2192    };
2193    if !key
2194        .modifiers
2195        .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
2196    {
2197        set_capture_prompt_line(
2198            state,
2199            &format!(
2200                "'{text}' has no Ctrl or Alt — it would hijack typing. \
2201                 Try another combo, Esc to cancel\u{2026}"
2202            ),
2203        );
2204        return;
2205    }
2206    // Append to the action's LIVE combo list (defaults + overrides),
2207    // deduped: capturing an already-bound combo is a no-op edit.
2208    let mut combos: Vec<String> = state
2209        .keymap
2210        .read()
2211        .action_combos(action)
2212        .iter()
2213        .map(|c| c.to_string())
2214        .collect();
2215    if !combos.iter().any(|c| c == &text) {
2216        combos.push(text.clone());
2217    }
2218    match commit_keybindings(state, action, combos) {
2219        Ok(()) => reopen_settings_panel(
2220            state,
2221            SettingsTab::Keybindings,
2222            Some(format!("Captured {text} for {action_name}")),
2223        ),
2224        Err(e) => set_capture_prompt_line(
2225            state,
2226            &format!("Failed to save keybindings: {e} — Esc to cancel\u{2026}"),
2227        ),
2228    }
2229}
2230
2231fn set_capture_prompt_line(state: &mut RenderState, line: &str) {
2232    if let Some(overlay) = state.overlay.as_mut() {
2233        overlay.lines = vec![line.to_string()];
2234    }
2235}
2236
2237/// Persist `action`'s new combo list and swap the rebuilt keymap into
2238/// `RenderState::keymap` so the change takes effect on the very next
2239/// keystroke — no restart. The keymap swap only happens after a
2240/// successful save (never persist a binding the disk state disagrees
2241/// with).
2242fn commit_keybindings(
2243    state: &mut RenderState,
2244    action: GlobalAction,
2245    combos: Vec<String>,
2246) -> anyhow::Result<()> {
2247    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2248    crate::tui_vt::settings_defs::set_action_combos(&mut settings, action, combos);
2249    save_settings_sandboxed(state, &settings)?;
2250    *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
2251    Ok(())
2252}
2253
2254/// Persist `settings`, honoring the test-only
2255/// `RenderState::settings_override_path` sandbox: when set, the write
2256/// lands in the tempdir path via `Settings::save_to` instead of the
2257/// real `~/.oxicode/settings.{json,toml}`. Production code paths never
2258/// set the override, so they always take the plain `save()` branch.
2259fn save_settings_sandboxed(
2260    state: &RenderState,
2261    settings: &crate::store::settings::Settings,
2262) -> anyhow::Result<()> {
2263    #[cfg(test)]
2264    {
2265        if let Some(path) = state.settings_override_path.as_ref() {
2266            if let Some(parent) = path.parent() {
2267                std::fs::create_dir_all(parent).ok();
2268            }
2269            return settings.save_to(path);
2270        }
2271    }
2272    let _ = state; // production builds don't read the sandbox field
2273    settings.save()
2274}
2275
2276/// `d` on a keybinding-combo row: remove that combo. Guarded — the
2277/// final combo of an action is refused (an action with zero keys is a
2278/// silent trap: the user could no longer trigger it, or reach this
2279/// panel to fix it). A remove that lands back on the default list
2280/// drops the override entry entirely.
2281fn remove_keybinding_combo(state: &mut RenderState, action: GlobalAction, combo: &str) {
2282    let current: Vec<String> = state
2283        .keymap
2284        .read()
2285        .action_combos(action)
2286        .iter()
2287        .map(|c| c.to_string())
2288        .collect();
2289    if current.len() <= 1 {
2290        reopen_settings_panel(
2291            state,
2292            SettingsTab::Keybindings,
2293            Some(format!(
2294                "Refusing to remove the last combo for {} — add another first (Enter on the \
2295                 action row)",
2296                action.name()
2297            )),
2298        );
2299        return;
2300    }
2301    let next: Vec<String> = current
2302        .iter()
2303        .filter(|c| c.as_str() != combo)
2304        .cloned()
2305        .collect();
2306    if next.len() == current.len() {
2307        // Not bound (stale row) — nothing to do.
2308        return;
2309    }
2310    match commit_keybindings(state, action, next) {
2311        Ok(()) => reopen_settings_panel(
2312            state,
2313            SettingsTab::Keybindings,
2314            Some(format!("Removed {combo} from {}", action.name())),
2315        ),
2316        Err(e) => reopen_settings_panel(
2317            state,
2318            SettingsTab::Keybindings,
2319            Some(format!("Failed to save keybindings: {e}")),
2320        ),
2321    }
2322}
2323
2324// ─────────────────────────────────────────────────────────────────────────
2325// Model roles map editor (n / Enter / d + text prompts)
2326// ─────────────────────────────────────────────────────────────────────────
2327
2328/// Open the unmasked text prompt for a model-role value (Enter on a
2329/// role row). Prefills the current model pattern so Enter-as-no-op is a
2330/// cheap round-trip.
2331fn open_model_role_value_prompt(state: &mut RenderState, role: &str) {
2332    let current = crate::store::settings::Settings::load()
2333        .map(|s| s.model_roles.get(role).cloned())
2334        .ok()
2335        .flatten();
2336    state.secure_input_origin = Some(SecureInputOrigin::ModelRoleValue {
2337        role: role.to_string(),
2338    });
2339    state.overlay = Some(text_prompt_overlay(
2340        format!("Model for role '{role}'"),
2341        "Enter the model pattern (provider/model). Enter saves, Esc cancels.".into(),
2342        "model",
2343        Some("provider/model".into()),
2344        current.as_deref(),
2345    ));
2346    state.settings_map_rows.clear();
2347}
2348
2349/// Open the unmasked text prompt naming a NEW model role (`n`).
2350fn open_model_role_key_prompt(state: &mut RenderState) {
2351    state.secure_input_origin = Some(SecureInputOrigin::ModelRoleKey);
2352    state.overlay = Some(text_prompt_overlay(
2353        "New model role".to_string(),
2354        "Name the role (e.g. fast, reviewer). Enter continues, Esc cancels.".into(),
2355        "role",
2356        Some("role name".into()),
2357        None,
2358    ));
2359    state.settings_map_rows.clear();
2360}
2361
2362/// Single-line unmasked text prompt built directly on the secure-input
2363/// machinery (`mask_input: false` renders the value in the clear).
2364fn text_prompt_overlay(
2365    title: String,
2366    line: String,
2367    label: &str,
2368    placeholder: Option<String>,
2369    prefill: Option<&str>,
2370) -> OverlayState {
2371    let mut editor = EditBuffer::new();
2372    if let Some(text) = prefill {
2373        let _ = editor.insert_str(text);
2374    }
2375    OverlayState {
2376        title,
2377        lines: vec![line],
2378        secure_input: Some(OverlaySecureInput {
2379            config: SecurePromptConfig {
2380                label: label.to_string(),
2381                placeholder,
2382                mask_input: false,
2383            },
2384            editor,
2385        }),
2386        ..Default::default()
2387    }
2388}
2389
2390/// Whether the settings panel (tabbed overlay + fresh map-row table) is
2391/// open — the precondition for the map-editor hotkeys.
2392fn settings_map_editor_active(state: &RenderState) -> bool {
2393    state.overlay.as_ref().is_some_and(|o| {
2394        o.tabs.len() > 1
2395            && o.key_capture.is_none()
2396            && state.settings_map_rows.len() == o.items.len()
2397    })
2398}
2399
2400/// The map-row (if any) currently selected in the settings panel.
2401fn selected_settings_map_row(state: &RenderState) -> Option<SettingsMapRow> {
2402    if !settings_map_editor_active(state) {
2403        return None;
2404    }
2405    let selected = state.overlay.as_ref().map(|o| o.selected)?;
2406    state.settings_map_rows.get(selected).cloned().flatten()
2407}
2408
2409/// `Enter` on a model-role row opens the value prompt. Returns whether
2410/// the key was consumed (input thread only calls this for Enter).
2411fn try_edit_model_role(state: &mut RenderState) -> bool {
2412    match selected_settings_map_row(state) {
2413        Some(SettingsMapRow::ModelRole(role)) => {
2414            open_model_role_value_prompt(state, &role);
2415            true
2416        }
2417        _ => false,
2418    }
2419}
2420
2421/// `d` on a map row: remove a keybinding combo (guarded — see
2422/// [`remove_keybinding_combo`]) or delete a model role. Returns whether
2423/// the key was consumed.
2424fn try_remove_settings_map_row(state: &mut RenderState) -> bool {
2425    match selected_settings_map_row(state) {
2426        Some(SettingsMapRow::KeybindingCombo(action, combo)) => {
2427            remove_keybinding_combo(state, action, &combo);
2428            true
2429        }
2430        Some(SettingsMapRow::ModelRole(role)) => {
2431            let status = match crate::store::settings::Settings::load() {
2432                Ok(mut settings) => {
2433                    let existed =
2434                        crate::tui_vt::settings_defs::remove_model_role(&mut settings, &role);
2435                    match settings.save() {
2436                        Ok(()) if existed => format!("Removed model role '{role}'"),
2437                        Ok(()) => format!("Role '{role}' was already gone"),
2438                        Err(e) => format!("Failed to save model roles: {e}"),
2439                    }
2440                }
2441                Err(e) => format!("Failed to load settings: {e}"),
2442            };
2443            reopen_settings_panel(state, SettingsTab::Model, Some(status));
2444            true
2445        }
2446        _ => false,
2447    }
2448}
2449
2450/// `n` on the Model tab starts a new model role (name first, then the
2451/// model pattern). Returns whether the key was consumed.
2452fn try_start_new_model_role(state: &mut RenderState) -> bool {
2453    if settings_map_editor_active(state) && state.settings_active_tab == SettingsTab::Model {
2454        open_model_role_key_prompt(state);
2455        true
2456    } else {
2457        false
2458    }
2459}
2460
2461// ─────────────────────────────────────────────────────────────────────────
2462// Settings panel editors: Text / SubmenuSelect / Multiselect (Final-fix wave)
2463// ─────────────────────────────────────────────────────────────────────────
2464
2465/// Open the unmasked text-prompt overlay for a `Text` widget row. The
2466/// submitted text is routed through `SecureInputOrigin::TextEdit(key)`
2467/// so the `OverlaySubmission::SecureInput` consumer commits via
2468/// `settings_defs::apply_change` (parse-validated per-key).
2469fn open_text_edit_prompt(state: &mut RenderState, key: SettingKey) {
2470    let current = crate::store::settings::Settings::load()
2471        .map(|s| get_display_value(key, &s))
2472        .unwrap_or_default();
2473    state.secure_input_origin = Some(SecureInputOrigin::TextEdit(key));
2474    state.overlay = Some(text_prompt_overlay(
2475        format!("Edit {}", label_for_setting(key)),
2476        format!(
2477            "Enter the new value for {} (Esc to cancel). Empty clears the override              when the field supports it.",
2478            label_for_setting(key)
2479        ),
2480        "value",
2481        None,
2482        if current == "default" {
2483            None
2484        } else {
2485            Some(current.as_str())
2486        },
2487    ));
2488}
2489
2490/// Open the submenu-select overlay for a `SubmenuSelect` widget row:
2491/// a child list of the widget's allowed strings; the active value is
2492/// marked in the badge.
2493fn open_submenu_select_prompt(state: &mut RenderState, key: SettingKey) {
2494    let Some(options) = submenu_options_for(key) else {
2495        // Defensive: a stray SettingSubmenuOpen against a non-submenu
2496        // key shouldn't happen, but if it does, surface the mismatch
2497        // and reopen the panel so the user is never stuck on a dead
2498        // overlay.
2499        reopen_settings_panel(
2500            state,
2501            state.settings_active_tab,
2502            Some(format!("'{:?}' is not a submenu-select setting", key)),
2503        );
2504        return;
2505    };
2506    let current = crate::store::settings::Settings::load()
2507        .map(|s| get_display_value(key, &s))
2508        .unwrap_or_default();
2509    let items: Vec<OverlayListItem> = options
2510        .iter()
2511        .map(|opt| InlineListItem {
2512            title: (*opt).to_string(),
2513            subtitle: None,
2514            badge: if *opt == current {
2515                Some("current".to_string())
2516            } else {
2517                None
2518            },
2519            indent: 0,
2520            selection: Some(InlineListSelection::ConfigAction(format!(
2521                "SubmenuCommit:{:?}:{}",
2522                key, opt
2523            ))),
2524            search_value: None,
2525        })
2526        .map(overlay_item_from)
2527        .collect();
2528    let label = label_for_setting(key);
2529    state.overlay = Some(OverlayState {
2530        title: format!("Pick value for {label}"),
2531        lines: vec![format!("Esc cancels — current: {current}")],
2532        items,
2533        selected: options.iter().position(|o| *o == current).unwrap_or(0),
2534        ..Default::default()
2535    });
2536    state.settings_map_rows.clear();
2537}
2538
2539/// Source the registered tool list (live `ToolRegistry`) and open the
2540/// multiselect overlay for `DisabledTools`. Essential tools are shown
2541/// with their badge but cannot be toggled off (the input handler
2542/// refuses the toggle with an Error line).
2543fn open_disabled_tools_multiselect(
2544    state: &mut RenderState,
2545    session: &crate::app::agent_session::AgentSessionHandle,
2546) {
2547    let mut tools = session.agent_ref().tools().get_tools();
2548    tools.sort_by(|a, b| a.name().cmp(b.name()));
2549    let settings = crate::store::settings::Settings::load().unwrap_or_default();
2550    let disabled: std::collections::HashSet<String> =
2551        settings.disabled_tools.iter().cloned().collect();
2552    let items: Vec<OverlayListItem> = tools
2553        .iter()
2554        .map(|t| {
2555            let name = t.name();
2556            let is_disabled = disabled.contains(name);
2557            InlineListItem {
2558                title: name.to_string(),
2559                subtitle: Some(t.description().to_string()),
2560                badge: Some(if t.essential() {
2561                    if is_disabled {
2562                        "essential — locked".to_string()
2563                    } else {
2564                        "essential".to_string()
2565                    }
2566                } else if is_disabled {
2567                    "disabled".to_string()
2568                } else {
2569                    "enabled".to_string()
2570                }),
2571                indent: 0,
2572                selection: Some(InlineListSelection::ConfigAction(format!(
2573                    "DisabledToolToggle:{}",
2574                    name
2575                ))),
2576                search_value: None,
2577            }
2578        })
2579        .map(overlay_item_from)
2580        .collect();
2581    let disabled_count = items
2582        .iter()
2583        .filter(|i| {
2584            i.badge
2585                .as_deref()
2586                .map(|b| b == "disabled" || b == "essential — locked")
2587                .unwrap_or(false)
2588        })
2589        .count();
2590    state.overlay = Some(OverlayState {
2591        title: "Disabled tools".into(),
2592        lines: vec![format!(
2593            "{disabled_count} disabled — Enter/Space toggles, Esc closes"
2594        )],
2595        items,
2596        selected: 0,
2597        ..Default::default()
2598    });
2599    state.settings_map_rows.clear();
2600}
2601
2602/// Commit a `Text` widget edit: `apply_change` parses the input,
2603/// `Settings::save` persists, and the panel reopens with a status
2604/// line. Returns the outcome string for the caller to surface. The
2605/// session is required only for `sync_settings_live`; callers that
2606/// don't need live sync (e.g. model defaults — no live propagation
2607/// today) can pass a real handle from `handle_inline_event`'s scope.
2608/// Tests pass `&RenderState::default()` and skip the live sync via
2609/// the `with_session` toggle.
2610fn commit_text_edit(
2611    state: &mut RenderState,
2612    handle: &InlineHandle,
2613    session: Option<&crate::app::agent_session::AgentSessionHandle>,
2614    key: SettingKey,
2615    text: String,
2616) -> (anyhow::Result<()>, String) {
2617    let label = label_for_setting(key);
2618    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2619    let outcome =
2620        crate::tui_vt::settings_defs::apply_change(key, &mut settings, text.trim().to_string())
2621            .and_then(|_| save_settings_sandboxed(state, &settings));
2622    let new_display = get_display_value(key, &settings);
2623    match &outcome {
2624        Ok(()) => {
2625            if let Some(session) = session {
2626                // Best-effort live sync — `apply_change` already validated
2627                // the parse; sync failures don't undo the save.
2628                if let Err(e) = sync_settings_live(state, session, key, &settings) {
2629                    handle.append_line(
2630                        InlineMessageKind::Error,
2631                        vec![plain_segment(format!("{label}: {e}"))],
2632                    );
2633                }
2634            }
2635            let status = format!("{label}: {new_display}");
2636            // Match the ConfigAction path's transcript feedback: a
2637            // saved scalar edit is surfaced as an Info line, not just
2638            // the panel status.
2639            handle.append_line(InlineMessageKind::Info, vec![plain_segment(status.clone())]);
2640            reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2641            (Ok(()), status)
2642        }
2643        Err(e) => {
2644            let msg = format!("{label}: {e}");
2645            handle.append_line(InlineMessageKind::Error, vec![plain_segment(msg.clone())]);
2646            (Err(anyhow::anyhow!("{e}")), msg)
2647        }
2648    }
2649}
2650
2651/// Commit a `SubmenuSelect` widget edit: write the chosen option,
2652/// persist, reopen the panel with the new badge.
2653fn commit_submenu_choice(
2654    state: &mut RenderState,
2655    key: SettingKey,
2656    value: String,
2657) -> anyhow::Result<String> {
2658    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2659    crate::tui_vt::settings_defs::apply_change(key, &mut settings, value.clone())?;
2660    save_settings_sandboxed(state, &settings)?;
2661    let new_display = get_display_value(key, &settings);
2662    let label = label_for_setting(key);
2663    let status = format!("{label}: {new_display}");
2664    reopen_settings_panel(state, state.settings_active_tab, Some(status.clone()));
2665    Ok(status)
2666}
2667
2668/// Toggle one tool in `Settings::disabled_tools`. Returns the outcome
2669/// string for the caller to surface (success or refusal for
2670/// essential tools).
2671fn commit_disabled_tool_toggle(
2672    state: &mut RenderState,
2673    handle: &InlineHandle,
2674    session: &crate::app::agent_session::AgentSessionHandle,
2675    tool: String,
2676    essential: bool,
2677    currently_disabled: bool,
2678) {
2679    let label = label_for_setting(SettingKey::DisabledTools);
2680    if essential {
2681        handle.append_line(
2682            InlineMessageKind::Error,
2683            vec![plain_segment(format!(
2684                "'{tool}' is essential and cannot be disabled"
2685            ))],
2686        );
2687        // Refresh the overlay so the user's failed toggle doesn't show
2688        // a stale badge.
2689        open_disabled_tools_multiselect(state, session);
2690        return;
2691    }
2692    let mut settings = crate::store::settings::Settings::load().unwrap_or_default();
2693    let new_enabled = currently_disabled; // toggling from disabled → enabled
2694    crate::tui_vt::settings_defs::toggle_disabled_tool(&mut settings, &tool, new_enabled);
2695    match save_settings_sandboxed(state, &settings) {
2696        Ok(()) => {
2697            let new_state = if new_enabled { "enabled" } else { "disabled" };
2698            handle.append_line(
2699                InlineMessageKind::Info,
2700                vec![plain_segment(format!("{label}: '{tool}' {new_state}"))],
2701            );
2702            open_disabled_tools_multiselect(state, session);
2703        }
2704        Err(e) => {
2705            handle.append_line(
2706                InlineMessageKind::Error,
2707                vec![plain_segment(format!("{label}: failed to save: {e}"))],
2708            );
2709        }
2710    }
2711}
2712
2713/// Human label for a SettingKey — mirrors the row label the user
2714/// sees on the panel, used in overlay titles and status messages.
2715fn label_for_setting(key: SettingKey) -> &'static str {
2716    SETTING_DEFS
2717        .iter()
2718        .find(|d| d.key == key)
2719        .map(|d| d.label)
2720        .unwrap_or("setting")
2721}
2722
2723/// Parse a `SettingKey::Debug`-formatted name (the payload the panel
2724/// ships through `InlineListSelection::SettingTextEdit` et al.) back
2725/// into a typed key. Returns `None` for unrecognized names; callers
2726/// must surface that as an Error line (no silent no-op).
2727fn parse_setting_key(name: &str) -> Option<SettingKey> {
2728    SETTING_DEFS
2729        .iter()
2730        .map(|d| d.key)
2731        .find(|k| format!("{k:?}") == name)
2732}
2733
2734/// The allowed option list for a `SubmenuSelect` key, looked up from
2735/// its def. Returns `None` for non-submenu keys.
2736fn submenu_options_for(key: SettingKey) -> Option<&'static [&'static str]> {
2737    SETTING_DEFS
2738        .iter()
2739        .find(|d| d.key == key)
2740        .and_then(|d| match d.widget {
2741            SettingWidget::SubmenuSelect(opts) => Some(opts),
2742            _ => None,
2743        })
2744}
2745
2746/// Sidebar section index an item belongs to: the group of the last
2747/// heading row at or above it. Returns `None` for items outside every
2748/// section (or when the overlay has no sections).
2749fn item_section_idx(overlay: &OverlayState, idx: usize) -> Option<usize> {
2750    let mut current: Option<String> = None;
2751    for (i, item) in overlay.items.iter().enumerate() {
2752        let is_heading =
2753            item.selection.is_none() && item.badge.is_none() && item.subtitle.is_none();
2754        if is_heading {
2755            current = Some(item.title.clone());
2756        }
2757        if i == idx {
2758            return current
2759                .as_deref()
2760                .and_then(|g| overlay.sections.iter().position(|s| s == g));
2761        }
2762    }
2763    None
2764}
2765
2766/// Next variant string for a `Cycle` widget row — the value an Enter
2767/// submits to `settings_defs::apply_change`.
2768fn next_cycle_value(key: SettingKey, s: &crate::store::settings::Settings) -> Option<String> {
2769    match key {
2770        // Mirrors `AgentSession::cycle_thinking_level`'s order.
2771        SettingKey::ThinkingLevel => {
2772            const LEVELS: [&str; 6] = ["off", "minimal", "low", "medium", "high", "xhigh"];
2773            let cur = get_display_value(key, s);
2774            let idx = LEVELS.iter().position(|l| *l == cur).unwrap_or(0);
2775            Some(LEVELS[(idx + 1) % LEVELS.len()].to_string())
2776        }
2777        SettingKey::GlyphSet => Some(s.glyph_set.next().to_string()),
2778        SettingKey::EditFormat => Some(
2779            if get_display_value(key, s) == "hashline" {
2780                "str_replace"
2781            } else {
2782                "hashline"
2783            }
2784            .to_string(),
2785        ),
2786        _ => None,
2787    }
2788}
2789
2790/// Live-sync the handful of settings the open session / render state read
2791/// eagerly, so an overlay edit takes effect without a restart. Everything
2792/// else is re-read from disk on the next turn
2793/// (`AgentSession::rebuild_system_prompt` already reloads on demand).
2794///
2795/// Returns `Err` only when a live propagation genuinely failed (the
2796/// advisor toggle can refuse to start/stop) — the caller surfaces that
2797/// instead of a silent success; the disk value is already saved at that
2798/// point, so the message says what the user must do (restart).
2799fn sync_settings_live(
2800    state: &mut RenderState,
2801    session: &crate::app::agent_session::AgentSessionHandle,
2802    key: SettingKey,
2803    settings: &crate::store::settings::Settings,
2804) -> anyhow::Result<()> {
2805    match key {
2806        SettingKey::ThinkingLevel => {
2807            session.set_thinking_level(settings.thinking_level);
2808            state.thinking_level = get_display_value(key, settings);
2809        }
2810        SettingKey::GlyphSet => state.glyph_set = settings.glyph_set,
2811        SettingKey::AutoCompaction => session.set_auto_compaction(settings.auto_compaction),
2812        SettingKey::AdvisorEnabled if session.is_advisor_enabled() != settings.advisor.enabled => {
2813            session
2814                .set_advisor_enabled(settings.advisor.enabled)
2815                .map_err(|e| {
2816                    anyhow::anyhow!(
2817                        "failed to {} the advisor live: {e} (saved; restart to apply)",
2818                        if settings.advisor.enabled {
2819                            "enable"
2820                        } else {
2821                            "disable"
2822                        }
2823                    )
2824                })?;
2825        }
2826        _ => {}
2827    }
2828    Ok(())
2829}
2830
2831/// Map a `SessionEvent` to the matching `InlineHandle` calls. This is the
2832/// single place where the agent's event vocabulary meets the harness's
2833/// transcript vocabulary.
2834fn handle_session_event(
2835    state: &mut RenderState,
2836    handle: &InlineHandle,
2837    event: &SessionEvent,
2838    session: Option<&crate::app::agent_session::AgentSessionHandle>,
2839) {
2840    match event {
2841        SessionEvent::Agent(boxed) => {
2842            let event = *boxed.clone();
2843            if let (AgentEvent::Error { message, .. }, Some(session)) = (&event, session)
2844                && is_missing_api_key_error(message)
2845            {
2846                let provider = provider_from_model_id(&session.model_id());
2847                handle.append_line(
2848                    InlineMessageKind::Info,
2849                    vec![plain_segment(format!(
2850                        "Authentication is required for '{provider}'. Enter an API key to continue."
2851                    ))],
2852                );
2853                open_secure_prompt(state, handle, SecureInputOrigin::SetKey { provider });
2854            }
2855            map_agent_event(handle, event, state);
2856        }
2857        SessionEvent::CompactionStart { .. } => {
2858            handle.set_reasoning_stage(Some("Compacting\u{2026}".to_string()));
2859        }
2860        SessionEvent::CompactionEnd { error_message, .. } => {
2861            handle.set_reasoning_stage(None);
2862            if let Some(msg) = error_message {
2863                handle.append_line(
2864                    InlineMessageKind::Error,
2865                    vec![plain_segment(format!("Compaction failed: {msg}"))],
2866                );
2867            }
2868        }
2869        SessionEvent::ThinkingLevelChanged { level } => {
2870            state.thinking_level = format!("{level:?}").to_ascii_lowercase();
2871        }
2872        SessionEvent::QueueUpdate { .. } => {
2873            // Surface the queue length as a footer status update.
2874            // The exact count is computed lazily by the agent session;
2875            // we approximate it via the snapshot we hold.
2876            let pending = state.transcript.len();
2877            handle.set_input_status(
2878                None,
2879                Some(if pending == 0 {
2880                    "ready".to_string()
2881                } else {
2882                    "queued".to_string()
2883                }),
2884            );
2885        }
2886        SessionEvent::Advisor { body, .. } => {
2887            handle.append_line(InlineMessageKind::Info, vec![plain_segment(body.clone())]);
2888        }
2889        SessionEvent::SessionInfoChanged => {
2890            // The session name is reflected via header context on next
2891            // `set_header_context`. Nothing to do here.
2892        }
2893        SessionEvent::HandoffComplete { .. } => {
2894            // Intercepted in the event loop's session_rx arm before
2895            // reaching this function — transcript clearing and prompt
2896            // submission happen there. This arm exists for exhaustiveness.
2897        }
2898        SessionEvent::HandoffFailed { error } => {
2899            handle.append_line(
2900                InlineMessageKind::Error,
2901                vec![plain_segment(format!("Handoff failed: {}", error))],
2902            );
2903        }
2904    }
2905}
2906
2907/// Whether a provider failure means the active credential is absent. Keep this
2908/// deliberately narrow: transport, quota, and invalid-key errors must remain
2909/// visible as errors instead of unexpectedly opening a credential prompt.
2910fn is_missing_api_key_error(message: &str) -> bool {
2911    let message = message.to_ascii_lowercase();
2912    message.contains("missing api key") || message.contains("api key is required")
2913}
2914
2915/// The agent model id is always represented as `provider/model`. A malformed
2916/// legacy id still gets a usable, explicit destination for the credential UI.
2917fn provider_from_model_id(model_id: &str) -> String {
2918    model_id
2919        .split_once('/')
2920        .map(|(provider, _)| provider)
2921        .filter(|provider| !provider.is_empty())
2922        .unwrap_or("provider")
2923        .to_string()
2924}
2925
2926/// Push the active model into every render surface that shows it: the
2927/// composer's MODEL field (`header_context`) and the CTX denominator.
2928///
2929/// Before this, both were written once at startup and went stale: the
2930/// MODEL chip kept the boot model after `/model`, and `context_window`
2931/// kept its 128_000 default forever — a 1M-context model showed a
2932/// wrong CTX total for the whole session.
2933pub(crate) fn apply_model_to_chips(state: &mut RenderState, model_id: &str, ctx_window: usize) {
2934    if model_id.is_empty() {
2935        return;
2936    }
2937    state.header_context.provider = provider_from_model_id(model_id);
2938    state.header_context.model = model_id.to_string();
2939    state.header_context.editor_context = Some(model_id.to_string());
2940    if ctx_window > 0 {
2941        state.context_window = ctx_window;
2942    }
2943}
2944
2945/// [`apply_model_to_chips`] sourced from the live session.
2946pub(crate) fn sync_model_chips(
2947    state: &mut RenderState,
2948    session: &crate::app::agent_session::AgentSessionHandle,
2949) {
2950    apply_model_to_chips(state, &session.model_id(), session.context_window());
2951}
2952/// Render the in-flight message: the dimmed italic thinking block (one
2953/// line per explicit newline, reasoning-styled) above the markdown-rendered
2954/// answer. Re-rendered whole on every reveal step so the live view equals
2955/// the final render — but only for the REVEALED prefix of the body (see
2956/// [`advance_stream_reveal`]).
2957fn render_streamed_message(state: &mut RenderState) -> Vec<Vec<InlineSegment>> {
2958    let mut lines = Vec::new();
2959    if !state.thinking_buffer.is_empty() {
2960        let styles = active_styles();
2961        let mut style = InlineTextStyle::default();
2962        style.color = styles.reasoning.get_fg_color();
2963        style.effects |= anstyle::Effects::DIMMED | anstyle::Effects::ITALIC;
2964        for chunk in state.thinking_buffer.split('\n') {
2965            lines.push(vec![InlineSegment {
2966                text: chunk.to_string(),
2967                style: Arc::new(style.clone()),
2968            }]);
2969        }
2970        // One blank row breathes between the thinking block and the
2971        // answer — only once the answer has started streaming.
2972        if !state.message_buffer.is_empty() {
2973            lines.push(vec![plain_segment("")]);
2974        }
2975    }
2976    let body = revealed_stream_body(state).to_string();
2977    if !body.is_empty() {
2978        // Tables pre-compute their geometry, so they must know the real
2979        // content width — a table built wider wraps at the terminal
2980        // edge and every border row breaks.
2981        let (_, content_w) = super::frame_layout::scrollback_geometry(Rect {
2982            x: 0,
2983            y: 0,
2984            width: state.viewport_width,
2985            height: 24,
2986        });
2987        lines.extend(oxicode_vtui::tui::ui::markdown::render_markdown_cached(
2988            &body,
2989            content_w as usize,
2990            &mut state.md_cache,
2991        ));
2992    }
2993    lines
2994}
2995
2996/// The portion of the streamed body currently revealed by the
2997/// typewriter (char-boundary-safe). `usize::MAX` reveals everything.
2998fn revealed_stream_body(state: &RenderState) -> &str {
2999    if state.stream_reveal == usize::MAX {
3000        &state.message_buffer
3001    } else {
3002        let idx = floor_char_boundary(&state.message_buffer, state.stream_reveal);
3003        &state.message_buffer[..idx]
3004    }
3005}
3006
3007/// Largest char-boundary index `<= i` (std's `floor_char_boundary` is
3008/// still unstable).
3009fn floor_char_boundary(s: &str, mut i: usize) -> usize {
3010    if i >= s.len() {
3011        return s.len();
3012    }
3013    while !s.is_char_boundary(i) {
3014        i -= 1;
3015    }
3016    i
3017}
3018
3019/// Advance the typewriter one frame and paint the newly revealed text
3020/// into the streamed block. Returns `true` when something was painted.
3021///
3022/// Network chunks land in `message_buffer` whole; painting them whole
3023/// made the transcript jump in lumps. The reveal advances per render
3024/// tick (50 ms) by `remaining / 6` (min 8 bytes), so any backlog drains
3025/// in a handful of frames while steady streams type out at their
3026/// arrival pace. The final authoritative paint at `MessageEnd` reveals
3027/// everything at once.
3028fn advance_stream_reveal(state: &mut RenderState) -> bool {
3029    if state.stream_anchor.is_none() || state.stream_reveal == usize::MAX {
3030        return false;
3031    }
3032    let len = state.message_buffer.len();
3033    if state.stream_reveal >= len {
3034        state.stream_reveal = len;
3035        return false;
3036    }
3037    let remaining = len - state.stream_reveal;
3038    let step = (remaining / 6).max(8);
3039    let target = (state.stream_reveal + step).min(len);
3040    state.stream_reveal = floor_char_boundary(&state.message_buffer, target);
3041    // Paint: replace the streamed block with the revealed prefix — the
3042    // same mutation `InlineCommand::ReplaceLast` applies.
3043    let lines = render_streamed_message(state);
3044    let from = state.stream_anchor.unwrap_or(state.transcript.len());
3045    state.transcript.truncate(from);
3046    for line in lines {
3047        state.append_line(InlineMessageKind::Agent, line);
3048    }
3049    state.stream_anchor = Some(from);
3050    true
3051}
3052
3053/// Project the agent-level event variants onto the harness transcript.
3054/// One-line human preview of a tool call's arguments: the command for
3055/// shell tools, key=value pairs otherwise, bounded to the transcript
3056/// width. "Which command ran" is the single most useful fact about a
3057/// tool call — peers (Claude Code, pi, OpenCode) all surface it.
3058fn tool_args_preview(args: &serde_json::Value) -> String {
3059    use serde_json::Value;
3060    let raw = match args {
3061        Value::Null => return String::new(),
3062        Value::String(s) => s.clone(),
3063        Value::Object(map) => {
3064            if let Some(Value::String(cmd)) = map.get("command") {
3065                cmd.clone()
3066            } else {
3067                map.iter()
3068                    .filter_map(|(k, v)| match v {
3069                        Value::String(s) => Some(format!("{k}={s}")),
3070                        _ => None,
3071                    })
3072                    .take(3)
3073                    .collect::<Vec<_>>()
3074                    .join(" ")
3075            }
3076        }
3077        other => other.to_string(),
3078    };
3079    if raw.chars().count() > 72 {
3080        let head: String = raw.chars().take(71).collect();
3081        format!("{head}\u{2026}")
3082    } else {
3083        raw
3084    }
3085}
3086/// Tool box content width: the LIVE transcript content width (layout
3087/// gutters), floored so narrow terminals still draw a coherent box.
3088/// Building at the terminal width would wrap every row's right border
3089/// onto the next visual line.
3090fn tool_box_width(state: &RenderState) -> usize {
3091    let area = Rect {
3092        x: 0,
3093        y: 0,
3094        width: state.viewport_width,
3095        height: 24,
3096    };
3097    let (_x, w) = super::frame_layout::scrollback_geometry(area);
3098    w.max(24) as usize
3099}
3100
3101fn border_segment(text: impl Into<String>, color: anstyle::Color) -> InlineSegment {
3102    let mut style = InlineTextStyle::default();
3103    style.color = Some(color);
3104    InlineSegment {
3105        text: text.into(),
3106        style: Arc::new(style),
3107    }
3108}
3109
3110/// `╭────╮` — rounded top border, no interior fill.
3111fn tool_box_top(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3112    vec![border_segment(
3113        format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(w.saturating_sub(2))),
3114        color,
3115    )]
3116}
3117
3118/// `╰────╯` — rounded bottom border.
3119fn tool_box_bottom(w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3120    vec![border_segment(
3121        format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(w.saturating_sub(2))),
3122        color,
3123    )]
3124}
3125
3126/// `├── Output ───┤` — section divider with a label, omp-style.
3127fn tool_box_divider(label: &str, w: usize, color: anstyle::Color) -> Vec<InlineSegment> {
3128    let text = format!(" {label} ");
3129    let dashes = w
3130        .saturating_sub(2)
3131        .saturating_sub(text.chars().count())
3132        .saturating_sub(2);
3133    vec![border_segment(
3134        format!(
3135            "\u{251C}\u{2500}{}{}\u{2500}\u{2524}",
3136            text,
3137            "\u{2500}".repeat(dashes)
3138        ),
3139        color,
3140    )]
3141}
3142
3143/// `│ text │` rows with the right border aligned at `w`. Long text
3144/// hard-wraps at the inner width; explicit newlines open new rows.
3145fn tool_box_rows(
3146    text: &str,
3147    w: usize,
3148    style: InlineTextStyle,
3149    color: anstyle::Color,
3150) -> Vec<Vec<InlineSegment>> {
3151    let inner = w.saturating_sub(4).max(1);
3152    text.split('\n')
3153        .map(|line| expand_tabs(line, TAB_WIDTH))
3154        .flat_map(|line| wrap_by_display_width(&line, inner))
3155        .map(|chunk| {
3156            // Pad by DISPLAY width — CJK chars occupy two cells, so a
3157            // char-count pad misaligns the right border on Korean text.
3158            let pad = inner.saturating_sub(chunk.width());
3159            vec![
3160                border_segment("\u{2502} ", color),
3161                InlineSegment {
3162                    text: chunk,
3163                    style: Arc::new(style.clone()),
3164                },
3165                border_segment(format!("{} \u{2502}", " ".repeat(pad)), color),
3166            ]
3167        })
3168        .collect()
3169}
3170
3171/// Hard-wrap a line into chunks of at most `inner` DISPLAY cells
3172/// (Korean/CJK glyphs count as 2). Zero-width chars never break a chunk.
3173fn wrap_by_display_width(line: &str, inner: usize) -> Vec<String> {
3174    use unicode_width::UnicodeWidthChar as _;
3175    if line.width() <= inner {
3176        return vec![line.to_string()];
3177    }
3178    let mut out: Vec<String> = Vec::new();
3179    let mut cur = String::new();
3180    let mut cur_w = 0usize;
3181    for ch in line.chars() {
3182        let ch_w = ch.width().unwrap_or(0);
3183        if cur_w + ch_w > inner && !cur.is_empty() {
3184            out.push(std::mem::take(&mut cur));
3185            cur_w = 0;
3186        }
3187        cur.push(ch);
3188        cur_w += ch_w;
3189    }
3190    if !cur.is_empty() {
3191        out.push(cur);
3192    }
3193    out
3194}
3195
3196/// Tab stop for box-content expansion. Tool output (e.g. the read tool's
3197/// `{:>6}\t{line}` numbering) carries literal tabs; ratatui drops them when
3198/// filling cells while unicode-width 0.2 counts them as 1 — a row padded
3199/// with tab width in its math renders one column short. Expand tabs to the
3200/// next stop so builder and renderer agree on every cell.
3201const TAB_WIDTH: usize = 4;
3202
3203/// Expand tabs to spaces at `TAB_WIDTH` display-column stops.
3204fn expand_tabs(line: &str, tab_width: usize) -> String {
3205    use unicode_width::UnicodeWidthChar as _;
3206    if !line.contains('\t') {
3207        return line.to_string();
3208    }
3209    let mut out = String::with_capacity(line.len() + tab_width);
3210    let mut col = 0usize;
3211    for ch in line.chars() {
3212        if ch == '\t' {
3213            let spaces = tab_width - (col % tab_width);
3214            for _ in 0..spaces {
3215                out.push(' ');
3216                col += 1;
3217            }
3218        } else {
3219            out.push(ch);
3220            col += ch.width().unwrap_or(1).max(1);
3221        }
3222    }
3223    out
3224}
3225
3226/// Diff rows for a tool box: colored +/- lines plus a diffstat header.
3227/// Returns `None` when the content is not a recognizable diff.
3228fn diff_rows(content: &str) -> Option<Vec<(String, InlineTextStyle)>> {
3229    let lines: Vec<&str> = content.lines().collect();
3230    // Require a unified-diff hunk header (`@@ … @@`) as a strong signal that
3231    // the content is actually a diff — prevents grep context lines, bullet
3232    // lists, and shell output from being mis-rendered as deletions.
3233    if !lines.iter().any(|l| l.starts_with("@@")) {
3234        return None;
3235    }
3236    let additions = lines
3237        .iter()
3238        .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
3239        .count();
3240    let deletions = lines
3241        .iter()
3242        .filter(|l| l.starts_with('-') && !l.starts_with("---"))
3243        .count();
3244    if additions + deletions < 2 {
3245        return None;
3246    }
3247
3248    let styles = active_styles();
3249    let green = styles.secondary.get_fg_color();
3250    let red = styles.error.get_fg_color();
3251    const MAX_DIFF_LINES: usize = 30;
3252
3253    let mut rows: Vec<(String, InlineTextStyle)> = Vec::new();
3254    let mut hdr = InlineTextStyle::default();
3255    hdr.effects |= anstyle::Effects::DIMMED;
3256    rows.push((format!("diff +{additions} -{deletions}"), hdr));
3257    for line in lines.iter().take(MAX_DIFF_LINES) {
3258        let mut style = InlineTextStyle::default();
3259        if line.starts_with('+') && !line.starts_with("+++") {
3260            style.color = green;
3261        } else if line.starts_with('-') && !line.starts_with("---") {
3262            style.color = red;
3263        } else {
3264            style.effects |= anstyle::Effects::DIMMED;
3265        }
3266        rows.push(((*line).to_string(), style));
3267    }
3268    if lines.len() > MAX_DIFF_LINES {
3269        let mut more = InlineTextStyle::default();
3270        more.effects |= anstyle::Effects::DIMMED;
3271        rows.push((
3272            format!("\u{2026} +{} lines", lines.len() - MAX_DIFF_LINES),
3273            more,
3274        ));
3275    }
3276    Some(rows)
3277}
3278
3279fn map_agent_event(handle: &InlineHandle, event: AgentEvent, state: &mut RenderState) {
3280    match event {
3281        AgentEvent::TextChunk { text } => {
3282            state.reasoning_stage = Some("generating response".to_string());
3283            state.message_buffer.push_str(&text);
3284            handle.inline(InlineMessageKind::Agent, plain_segment(text));
3285        }
3286        AgentEvent::AgentStart { .. } => {
3287            // The run is live until the matching AgentEnd. The tracker —
3288            // not the stage label — owns the indicator row, so the row
3289            // survives the per-turn stage clears of a tool loop.
3290            state.active_run = Some(RunState::default());
3291        }
3292        AgentEvent::MessageStart { .. } => {
3293            if let Some(run) = &mut state.active_run {
3294                run.turn += 1;
3295            }
3296            state.reasoning_stage = Some("generating response".to_string());
3297            state.message_buffer.clear();
3298            state.thinking_buffer.clear();
3299            state.stream_reveal = 0;
3300            // The stream boundary travels in the command stream so the
3301            // anchor lifecycle shares one causal order with Inline and
3302            // ReplaceLast — a direct state write here would race batched
3303            // command application.
3304            handle.begin_stream(InlineMessageKind::Agent);
3305        }
3306        AgentEvent::MessageUpdate { delta, .. } => match &delta {
3307            oxicode_sdk::StreamDelta::Text(text) => {
3308                // The Text delta is the lifecycle owner of the visible
3309                // answer: the first one transitions the reasoning stage
3310                // off `thinking…` into `generating response`. Raw
3311                // `MessageUpdate { delta: Text }` is the live streaming
3312                // path (oxicode-agent/src/agent_loop/streaming.rs:277-280).
3313                state.reasoning_stage = Some("generating response".to_string());
3314                state.message_buffer.push_str(text);
3315                handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3316            }
3317            oxicode_sdk::StreamDelta::Thinking(text) => {
3318                // The reasoning text renders as a dimmed italic block above
3319                // the answer (peer parity: Claude Code / pi). The stage
3320                // indicator keeps a fixed `thinking…` label — streaming raw
3321                // fragments into `reasoning_stage` would leak them through
3322                // the composer `RUN ` field and the indicator row.
3323                state.reasoning_stage = Some("thinking\u{2026}".to_string());
3324                state.thinking_buffer.push_str(text);
3325                handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3326            }
3327            oxicode_sdk::StreamDelta::Sync => {
3328                // Re-render the complete message as markdown
3329                if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3330                    handle.replace_last(
3331                        0,
3332                        InlineMessageKind::Agent,
3333                        render_streamed_message(state),
3334                    );
3335                    state.message_buffer.clear();
3336                    state.thinking_buffer.clear();
3337                    state.stream_reveal = 0;
3338                }
3339            }
3340        },
3341        AgentEvent::MessageEnd { message } => {
3342            // Between turns of a live tool loop the stage is briefly
3343            // `None`; the run tracker keeps the indicator row up (the
3344            // renderer falls back to `working…`). Only a finished run
3345            // releases the row to follow-ups / tips.
3346            if state.active_run.is_none() {
3347                state.reasoning_stage = None;
3348            }
3349            // Authoritative final render: the Done message REPLACES the
3350            // accumulated partial in agent_loop/streaming.rs, so the
3351            // final message — not the delta buffers — carries the
3352            // complete text. Providers can coalesce the stream tail
3353            // into it without a matching delta; rendering from the
3354            // buffers lost that tail until the next prompt rebuilt
3355            // history from the session.
3356            if let oxicode_ai::Message::Assistant(a) = &message {
3357                state.thinking_buffer = a
3358                    .content
3359                    .iter()
3360                    .filter_map(|b| b.as_thinking().map(|t| t.thinking.clone()))
3361                    .collect();
3362                state.message_buffer = a.text_content();
3363                state.stream_reveal = usize::MAX;
3364            }
3365            if !state.message_buffer.is_empty() || !state.thinking_buffer.is_empty() {
3366                handle.replace_last(0, InlineMessageKind::Agent, render_streamed_message(state));
3367                state.message_buffer.clear();
3368                state.thinking_buffer.clear();
3369            }
3370            // The message is final: release the anchor in the command
3371            // stream (after the final ReplaceLast above) so the finished
3372            // block becomes committable to the host scrollback.
3373            handle.end_stream();
3374        }
3375        AgentEvent::ToolExecutionStart {
3376            tool_name, args, ..
3377        } => {
3378            // omp-style tool box: rounded border, no fill, the call in
3379            // the header — "which command ran" is the headline fact.
3380            let styles = active_styles();
3381            let border = styles
3382                .tool
3383                .get_fg_color()
3384                .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White));
3385            let w = tool_box_width(state);
3386            let header = match args.get("command").and_then(|v| v.as_str()) {
3387                Some(cmd) => format!("$ {cmd}"),
3388                None => {
3389                    let preview = tool_args_preview(&args);
3390                    if preview.is_empty() {
3391                        tool_name.clone()
3392                    } else {
3393                        format!("{tool_name}  {preview}")
3394                    }
3395                }
3396            };
3397            handle.append_line_block_start(InlineMessageKind::Tool, tool_box_top(w, border));
3398            for row in tool_box_rows(&header, w, InlineTextStyle::default(), border) {
3399                handle.append_line(InlineMessageKind::Tool, row);
3400            }
3401            let stage = format!("tool: {tool_name}");
3402            if let Some(run) = &mut state.active_run {
3403                run.tool_calls += 1;
3404            }
3405            state.reasoning_stage = Some(stage.clone());
3406            handle.set_reasoning_stage(Some(stage));
3407        }
3408        AgentEvent::ToolExecutionEnd {
3409            tool_name,
3410            result,
3411            is_error,
3412            ..
3413        } => {
3414            // Close the box: a labeled divider separates the call from
3415            // its output (errors redden the border and the label), then
3416            // the bottom border. Diffs render colored inside the box.
3417            let styles = active_styles();
3418            let (border, label) = if is_error {
3419                (
3420                    styles
3421                        .error
3422                        .get_fg_color()
3423                        .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3424                    "Error",
3425                )
3426            } else {
3427                (
3428                    styles
3429                        .tool
3430                        .get_fg_color()
3431                        .unwrap_or(anstyle::Color::Ansi(anstyle::AnsiColor::White)),
3432                    "Output",
3433                )
3434            };
3435            let w = tool_box_width(state);
3436            handle.append_line(InlineMessageKind::Tool, tool_box_divider(label, w, border));
3437            // Inline image preview: a successful generate_image result
3438            // renders the text-fallback row here (this is what the
3439            // scrollback keeps); the decoded PNG is queued so the
3440            // post-draw step can transmit + place the real pixels over
3441            // the LIVE rows only. Unsupported terminals and the
3442            // `inline_images = false` kill-switch degrade to this text.
3443            let embedded_png = if tool_name == "generate_image" && !is_error {
3444                extract_generated_png(&result.content)
3445            } else {
3446                None
3447            };
3448            if let Some(png) = embedded_png {
3449                let id = super::image_preview::content_hash_id(&png);
3450                let label = format!("generate_image:{id:08x}");
3451                let mut dim = InlineTextStyle::default();
3452                dim.effects |= anstyle::Effects::DIMMED;
3453                let fallback = super::image_preview::text_fallback(&label);
3454                for row in tool_box_rows(&fallback, w, dim, border) {
3455                    handle.append_line(InlineMessageKind::Tool, row);
3456                }
3457                // The row index is resolved later, at render time — the
3458                // append command is still in the harness channel.
3459                state
3460                    .image_previews
3461                    .enqueue(id, std::sync::Arc::new(png), label);
3462            } else if let Some(rows) = diff_rows(&result.content) {
3463                for (text, style) in rows {
3464                    for row in tool_box_rows(&text, w, style, border) {
3465                        handle.append_line(InlineMessageKind::Tool, row);
3466                    }
3467                }
3468            } else {
3469                const MAX_BOX_LINES: usize = 12;
3470                let preview = preview_tool_result(&result.content);
3471                let lines: Vec<&str> = preview.split('\n').collect();
3472                let mut dim = InlineTextStyle::default();
3473                dim.effects |= anstyle::Effects::DIMMED;
3474                for line in lines.iter().take(MAX_BOX_LINES) {
3475                    for row in tool_box_rows(line, w, dim.clone(), border) {
3476                        handle.append_line(InlineMessageKind::Tool, row);
3477                    }
3478                }
3479                if lines.len() > MAX_BOX_LINES {
3480                    let more = format!("\u{2026} +{} lines", lines.len() - MAX_BOX_LINES);
3481                    for row in tool_box_rows(&more, w, dim, border) {
3482                        handle.append_line(InlineMessageKind::Tool, row);
3483                    }
3484                }
3485            }
3486            handle.append_line(InlineMessageKind::Tool, tool_box_bottom(w, border));
3487            state.reasoning_stage = Some("generating response".to_string());
3488            handle.set_reasoning_stage(Some("generating response".to_string()));
3489            handle.set_input_enabled(true);
3490        }
3491        AgentEvent::Error { message, .. } => {
3492            handle.append_line(InlineMessageKind::Error, vec![plain_segment(message)]);
3493            state.active_run = None;
3494            state.reasoning_stage = None;
3495            handle.set_input_enabled(true);
3496            handle.set_input_status(None, None);
3497        }
3498        AgentEvent::Compaction { .. } => {
3499            // Detailed lifecycle is handled by the AgentSession layer
3500            // (CompactionStart/End SessionEvents).
3501        }
3502        AgentEvent::Cancelled => {
3503            state.active_run = None;
3504            state.reasoning_stage = None;
3505            handle.set_input_enabled(true);
3506            handle.set_input_status(None, Some("cancelled".to_string()));
3507        }
3508        AgentEvent::AutoRetryStart {
3509            attempt,
3510            max_attempts,
3511            ..
3512        } => {
3513            state.reasoning_stage = Some(format!("retrying {attempt} of {max_attempts}"));
3514            handle.set_input_status(None, Some(format!("retry {attempt}/{max_attempts}")));
3515        }
3516        AgentEvent::TurnEnd { .. } => {
3517            // Notify via the terminal's best-supported desktop-notification
3518            // protocol (OSC 9/99/777, falling back to BEL) so the user
3519            // notices a finished turn even when the window is unfocused.
3520            crate::tui_vt::notifications::emit_notification("oxicode", "Response complete");
3521            // The next queued prompt (if any) now starts running — drop it
3522            // from the visible queue pane so the pane only shows still-pending
3523            // inputs.
3524            state.drain_queue_head();
3525            // Mid-run TurnEnds (a tool loop turn boundary) must not clear
3526            // the stage through the command path either — the run tracker
3527            // owns the row until AgentEnd.
3528            if state.active_run.is_none() {
3529                handle.set_reasoning_stage(None);
3530            }
3531        }
3532        AgentEvent::Usage { input_tokens, .. } => {
3533            // `input_tokens` is the provider's tokenization of the complete
3534            // prompt for this turn, so it is a useful live snapshot of the
3535            // context currently occupying the window (unlike a character
3536            // count or a local approximation).
3537            state.context_tokens = Some(input_tokens);
3538        }
3539        AgentEvent::AgentEnd { .. } => {
3540            // The run is over: release the indicator row to follow-ups /
3541            // tips and reset the tracker.
3542            state.active_run = None;
3543            state.reasoning_stage = None;
3544            handle.set_reasoning_stage(None);
3545        }
3546        AgentEvent::TodoReminder { open, attempt, max } => {
3547            // Commit a visible banner of *why* the agent kept going; the
3548            // injected user turn itself is hidden (UserMessage::hidden).
3549            let header = format!(
3550                "⚠ {} incomplete todo{} — reminder {attempt}/{max}",
3551                open.len(),
3552                if open.len() == 1 { "" } else { "s" }
3553            );
3554            handle.append_line(InlineMessageKind::Warning, vec![plain_segment(header)]);
3555            for t in &open {
3556                handle.append_line(
3557                    InlineMessageKind::Warning,
3558                    vec![plain_segment(format!("  ☐ {}", t.content))],
3559                );
3560            }
3561        }
3562        _ => {
3563            // Other variants (TurnStart, Compaction, ToolCallDelta, …) are
3564            // logged but not rendered — they're either metadata or covered
3565            // by the dedicated SessionEvent variants above.
3566            tracing::debug!(?event, "ignored AgentEvent variant");
3567        }
3568    }
3569}
3570
3571/// Decide which `/providers` actions apply for a provider, given whether
3572/// the user already has a stored credential and whether the provider
3573/// supports the OAuth `authorization_code` flow.
3574///
3575/// Single-action branches skip the menu entirely and drive directly
3576/// (no user-visible "Pick an action" list for the obvious cases).
3577pub(crate) fn next_provider_actions(has_key: bool, oauth_capable: bool) -> Vec<AuthAction> {
3578    match (has_key, oauth_capable) {
3579        (true, true) => vec![
3580            AuthAction::SetApiKey,
3581            AuthAction::StartOAuth,
3582            AuthAction::RemoveKey,
3583        ],
3584        (true, false) => vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
3585        (false, true) => vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
3586        (false, false) => vec![AuthAction::SetApiKey],
3587    }
3588}
3589
3590/// Open a masked secure prompt and stash the `origin` so the
3591/// `OverlaySubmission::SecureInput` consumer can route the key to the
3592/// right provider slot and emit a contextual follow-up message.
3593///
3594/// Shared by:
3595/// - `handle_auth_action::SetApiKey` (replace or first-time key entry)
3596/// - `add_custom_provider` (chain immediately after persisting a new
3597///   custom provider so the user does not have to navigate back)
3598///
3599/// The caller must consume the boolean return value the same way it does
3600/// for `handle_auth_action`: `true` means a new overlay was opened, so
3601/// the previously-open overlay must NOT be closed in the same submit
3602pub(crate) fn open_secure_prompt(
3603    state: &mut RenderState,
3604    handle: &InlineHandle,
3605    origin: SecureInputOrigin,
3606) {
3607    // Model-role prompts are built by their own (unmasked, prefilled)
3608    // builders; this auth-specific helper is never called with them.
3609    let provider = match &origin {
3610        SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
3611            provider.clone()
3612        }
3613        SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => return,
3614        SecureInputOrigin::TextEdit(_) => return,
3615    };
3616    state.secure_input_origin = Some(origin);
3617    handle.show_modal(
3618        format!("Set API key for {provider}"),
3619        vec![
3620            "Paste the API key. Press Enter to save, Esc to cancel.".into(),
3621            "The key is masked on screen; nothing is logged.".into(),
3622        ],
3623        Some(SecurePromptConfig {
3624            label: format!("{provider} key"),
3625            placeholder: Some("sk-...".into()),
3626            mask_input: true,
3627        }),
3628    );
3629}
3630
3631/// Dispatch a single `AuthAction` for `provider`.
3632///
3633/// `SetApiKey` opens the secure (masked) prompt via `open_secure_prompt`
3634/// (stashing `SecureInputOrigin::SetKey` so the consumer can route the
3635/// key to the right provider). `StartOAuth` spawns `run_oauth_flow` on a
3636/// dedicated tokio task (PKCE + loopback callback + token exchange +
3637/// persistence). `RemoveKey` reuses the existing confirmation modal —
3638/// its `ConfirmationAction::RemoveProviderKey` handler runs through
3639/// `/providers remove <name> --yes`.
3640pub(crate) fn handle_auth_action(
3641    provider: &str,
3642    action: &AuthAction,
3643    auth: &Arc<crate::store::auth_storage::AuthStorage>,
3644    handle: &InlineHandle,
3645    state: &mut RenderState,
3646) -> bool {
3647    // Returns true when the dispatched action opened a new overlay via
3648    // `handle.show_*` (currently only `SetApiKey` opens the secure prompt
3649    // modal). The caller — the `OverlayEvent::Submitted` arm in
3650    // `handle_inline_event` — uses this signal to decide whether the
3651    // previously-open overlay should be closed after dispatch. Closing
3652    // unconditionally would also clear the freshly-opened overlay because
3653    // the cmd channel processes `ShowOverlay` and `CloseOverlay` in submit
3654    // order, so a stale `CloseOverlay` enqueued right after the
3655    // `ShowOverlay` wins. Branches that do NOT open a new overlay
3656    // (`StartOAuth` spawns an async task, `RemoveKey` sets
3657    // `state.confirmation` rather than `state.overlay`) return false so
3658    // the caller is free to close the old overlay.
3659    match action {
3660        AuthAction::SetApiKey => {
3661            open_secure_prompt(
3662                state,
3663                handle,
3664                SecureInputOrigin::SetKey {
3665                    provider: provider.to_string(),
3666                },
3667            );
3668            true
3669        }
3670        AuthAction::StartOAuth => {
3671            // PKCE + loopback-callback glue lives in `run_oauth_flow`
3672            // (defined just below `handle_auth_action`). Spawn it on a
3673            // dedicated tokio task so the main loop can continue
3674            // rendering; the spawned task posts status updates back to
3675            // the transcript via the cloned `InlineHandle`.
3676            //
3677            // First, gate on the provider actually having an OAuth
3678            // spec in `product-meta.toml` — the action is only offered
3679            // when `next_provider_actions` includes it, so this branch
3680            // is purely defensive against a stale UI state.
3681            let spec = match crate::provider_oauth::spec_for(provider) {
3682                Some(s) => s,
3683                None => {
3684                    handle.append_line(
3685                        InlineMessageKind::Error,
3686                        vec![plain_segment(format!(
3687                            "OAuth: no OAuth config for '{provider}'."
3688                        ))],
3689                    );
3690                    return false;
3691                }
3692            };
3693            // `provider_owned` and `tx` are cloned Strings/`InlineHandle`s
3694            // owned by the task; `auth_clone` is the shared storage
3695            // singleton (cheap to clone — it is already `Arc`-backed).
3696            // `spec` is moved into the task.
3697            let provider_owned = provider.to_string();
3698            let tx = handle.clone();
3699            let auth_clone = Arc::clone(auth);
3700            tokio::spawn(async move {
3701                run_oauth_flow(provider_owned, spec, tx, auth_clone).await;
3702            });
3703            false
3704        }
3705        AuthAction::RemoveKey => {
3706            state.confirmation = Some(ModalConfirmation {
3707                title: format!("Remove key for {provider}?"),
3708                message: "  y \u{2014} remove key     n / x \u{2014} cancel".into(),
3709                action: ConfirmationAction::RemoveProviderKey(provider.to_string()),
3710            });
3711            false
3712        }
3713    }
3714}
3715
3716/// Drive the OAuth `authorization_code` flow for `provider` end to end:
3717///
3718/// 1. Bind an ephemeral loopback TCP listener and capture its port.
3719/// 2. Generate PKCE verifier + S256 challenge (`provider_oauth::pkce_pair`).
3720/// 3. Build the authorization URL (`provider_oauth::build_auth_url`) and
3721///    open it in the user's browser (`provider_oauth::open_browser`).
3722/// 4. Wait on the listener for the redirect carrying the `code` + `state`
3723///    (`oauth_listener::await_callback`); bind a timeout so a stuck
3724///    listener cannot leak.
3725/// 5. Exchange the code for tokens at the provider's token URL
3726///    (`provider_oauth::exchange_code`).
3727/// 6. Persist the OAuth credential via `AuthStorage::set_oauth_full` so
3728///    subsequent requests can use the access token (and `refresh_token`
3729///    if granted) without re-prompting the user.
3730///
3731/// Steps that hard-fail (callback timeout, state mismatch, missing
3732/// `code`, exchange error, persist error) post an `InlineMessageKind::Error`
3733/// line to the transcript and return; the bound listener is dropped on
3734/// every return path, satisfying the single-shot invariant.
3735///
3736/// Headless fallback (plan §3 / design §3): if `open_browser` returns
3737/// `Err`, we do NOT abort. We post an `Info` line printing the auth URL
3738/// and lengthen the callback timeout to 5 minutes so the user can paste
3739/// the URL into a browser on another machine and complete the flow.
3740/// Masking: every user-facing line that mentions the access token
3741/// surfaces only the token length (`access_token.chars().count()`), never
3742/// the value. Tokens are never logged via `tracing`.
3743pub(crate) async fn run_oauth_flow(
3744    provider: String,
3745    spec: crate::provider_oauth::ProviderOAuthSpec,
3746    handle: InlineHandle,
3747    auth: Arc<crate::store::auth_storage::AuthStorage>,
3748) {
3749    use std::time::Duration;
3750    // Timeout is selected AFTER the browser attempt: 2 minutes when the
3751    // browser opened (the user is right in front of it), 5 minutes when
3752    // it didn't (headless box — user has to copy the URL to another
3753    // machine, sign in there, and the redirect has to traverse NAT).
3754    // The variable is declared once as `mut` and then frozen below.
3755    // 1. Bind the loopback listener BEFORE opening the browser so the
3756    //    `redirect_uri` we hand to the provider already points at a live
3757    //    port. `TcpListener::bind("127.0.0.1:0")` picks an ephemeral port.
3758    let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await {
3759        Ok(l) => l,
3760        Err(e) => {
3761            handle.append_line(
3762                InlineMessageKind::Error,
3763                vec![plain_segment(format!(
3764                    "OAuth: could not bind loopback listener for '{provider}': {e}"
3765                ))],
3766            );
3767            return;
3768        }
3769    };
3770    let port = match listener.local_addr() {
3771        Ok(addr) => addr.port(),
3772        Err(e) => {
3773            handle.append_line(
3774                InlineMessageKind::Error,
3775                vec![plain_segment(format!(
3776                    "OAuth: could not read loopback port for '{provider}': {e}"
3777                ))],
3778            );
3779            return;
3780        }
3781    };
3782
3783    // 2. PKCE pair + per-flow `state`. The state must match what we send
3784    //    in the auth URL and what we accept on the callback — a single
3785    //    random base64-url string is enough since the flow is single-shot.
3786    let (verifier, challenge) = crate::provider_oauth::pkce_pair();
3787    let state_token = crate::provider_oauth::pkce_pair().0; // 43-char url-safe random
3788
3789    // 3. Build auth URL and open the browser. `open_browser` already
3790    //    validates the URL scheme so a malformed spec would have failed
3791    //    at `build_auth_url` time (it calls `Url::parse` internally).
3792    let auth_url = crate::provider_oauth::build_auth_url(&spec, port, &state_token, &challenge);
3793    handle.append_line(
3794        InlineMessageKind::Info,
3795        vec![plain_segment(format!(
3796            "OAuth: opening browser for '{provider}' on http://127.0.0.1:{port}{}",
3797            spec.redirect_path
3798        ))],
3799    );
3800    // Pick the callback timeout based on whether the browser opened.
3801    // Headless fallback (plan §3 / design §3): when the OS refuses to
3802    // launch a browser, we surface the URL and KEEP listening so a user
3803    // on a different machine can paste it, sign in, and let the
3804    // redirect land back on our loopback port. A 5-minute window is
3805    // long enough for that round-trip; a 2-minute window is plenty
3806    // when the browser already opened in front of the user.
3807    let callback_timeout = match crate::provider_oauth::open_browser(&auth_url) {
3808        Ok(()) => Duration::from_secs(120),
3809        Err(e) => {
3810            handle.append_line(
3811                InlineMessageKind::Info,
3812                vec![plain_segment(format!(
3813                    "OAuth: could not open a browser ({e}).\nOpen this URL manually within 5 minutes:\n  {auth_url}"
3814                ))],
3815            );
3816            Duration::from_secs(300)
3817        }
3818    };
3819
3820    // 4. Wait for the callback. The listener is single-shot by design:
3821    //    `await_callback` accepts exactly one connection.
3822    let callback = match crate::oauth_listener::await_callback(
3823        listener,
3824        state_token.clone(),
3825        spec.redirect_path.clone(),
3826        callback_timeout,
3827    )
3828    .await
3829    {
3830        Ok(c) => c,
3831        Err(crate::oauth_listener::CallbackError::Timeout) => {
3832            handle.append_line(
3833                InlineMessageKind::Error,
3834                vec![plain_segment(format!(
3835                    "OAuth: timed out waiting for '{provider}' callback (after {}s)",
3836                    callback_timeout.as_secs()
3837                ))],
3838            );
3839            return;
3840        }
3841        Err(e) => {
3842            handle.append_line(
3843                InlineMessageKind::Error,
3844                vec![plain_segment(format!(
3845                    "OAuth: callback failed for '{provider}': {e}"
3846                ))],
3847            );
3848            return;
3849        }
3850    };
3851
3852    // 5. Exchange code → tokens.
3853    let tokens =
3854        match crate::provider_oauth::exchange_code(&spec, port, &callback.code, &verifier).await {
3855            Ok(t) => t,
3856            Err(e) => {
3857                handle.append_line(
3858                    InlineMessageKind::Error,
3859                    vec![plain_segment(format!(
3860                        "OAuth: token exchange failed for '{provider}': {e}"
3861                    ))],
3862                );
3863                return;
3864            }
3865        };
3866
3867    // 6. Persist. `set_oauth_full` takes u64 `expires_at`; `OAuthTokens`
3868    //    exposes i64 (so callers can branch on `now < expires_at` in
3869    //    signed arithmetic). Saturate defensively — the value is always
3870    //    `now + expires_in` with `expires_in >= 0`, so negatives are
3871    //    impossible here, but a guard costs nothing.
3872    let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
3873    let access_token_len = tokens.access_token.chars().count();
3874    // `set_oauth_full` returns `()` and logs persistence failures via
3875    // `tracing::warn` — the in-memory credential is always updated.
3876    auth.set_oauth_full(
3877        &provider,
3878        tokens.access_token,
3879        tokens.refresh_token,
3880        new_expires_at,
3881        if tokens.scopes.is_empty() {
3882            None
3883        } else {
3884            Some(tokens.scopes.join(" "))
3885        },
3886        None,
3887    );
3888    handle.append_line(
3889        InlineMessageKind::Info,
3890        vec![plain_segment(format!(
3891            "OAuth: '{provider}' logged in. Token stored ({} chars).",
3892            access_token_len
3893        ))],
3894    );
3895}
3896
3897/// Map an input-thread `InlineEvent` to agent actions / state edits.
3898fn handle_inline_event(
3899    state: &mut RenderState,
3900    handle: &InlineHandle,
3901    session: &crate::app::agent_session::AgentSessionHandle,
3902    prompt_queue: &Arc<PromptQueue>,
3903    evt: InlineEvent,
3904) -> LoopOutcome {
3905    match evt {
3906        InlineEvent::Submit(text) => {
3907            // ── Drain pending resume (set by /sessions <id> or the picker). ──
3908            if let Some(path) = state.pending_resume.take() {
3909                let swapper = state.swapper();
3910                let agent_arc = Arc::clone(&session.agent_arc());
3911                let settings = session.settings_clone();
3912                let session_state = state
3913                    .session_state
3914                    .clone()
3915                    .expect("RenderState::session_state must be initialized at TUI startup");
3916                let path_for_log = path.clone();
3917                let handle = handle.clone();
3918                let swapper_for_swap = swapper.clone();
3919                tokio::spawn(async move {
3920                    match crate::app::agent_session::resume_from_file(
3921                        agent_arc,
3922                        settings,
3923                        session_state,
3924                        &path,
3925                        None,
3926                    )
3927                    .await
3928                    {
3929                        Ok(new_session) => {
3930                            swapper_for_swap.swap(new_session.clone_handle());
3931                            let n = new_session.messages().len();
3932                            let id = new_session.session_id();
3933                            handle.append_line(
3934                                InlineMessageKind::Info,
3935                                vec![plain_segment(format!(
3936                                    "Resumed session {id} ({n} messages)"
3937                                ))],
3938                            );
3939                        }
3940                        Err(crate::app::agent_session::ResumeError::FileNotFound(p)) => {
3941                            handle.append_line(
3942                                InlineMessageKind::Error,
3943                                vec![plain_segment(format!("No session file: {}", p.display()))],
3944                            );
3945                        }
3946                        Err(crate::app::agent_session::ResumeError::CwdInvalid(cwd)) => {
3947                            handle.append_line(
3948                                InlineMessageKind::Error,
3949                                vec![plain_segment(format!(
3950                                    "Cannot resume {}: the session was recorded in `{cwd}`, which no longer exists. \
3951                                     Use /export to save its content, then /clear.",
3952                                    path_for_log.display()
3953                                ))],
3954                            );
3955                        }
3956                    }
3957                });
3958                return LoopOutcome::Continue;
3959            }
3960            // Drain the composer — the input thread already cleared its
3961            // local copy once Submit fired, but we keep the canonical
3962            // buffer here in sync.
3963            let prompt = text.to_string();
3964            state.composer.set_text("");
3965            if prompt.is_empty() {
3966                return LoopOutcome::Continue;
3967            }
3968            state.pending_quit = false;
3969            // Slash commands: dispatch locally instead of forwarding to
3970            // the agent. The echoed line is appended before dispatch so
3971            // every command output appears after the prompt.
3972            if prompt.trim_start().starts_with('/') {
3973                state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
3974                let mut ctx = SlashCtx {
3975                    session,
3976                    handle,
3977                    state,
3978                };
3979                return match SlashRegistry::builtins().dispatch(&prompt, &mut ctx) {
3980                    SlashOutcome::Quit => LoopOutcome::Exit,
3981                    SlashOutcome::Handled => LoopOutcome::Continue,
3982                    SlashOutcome::NotHandled => {
3983                        // File-based commands: try before erroring.
3984                        if let Some(expanded) = crate::tui_vt::slash::file_commands::try_expand(
3985                            &ctx.state.file_commands,
3986                            &prompt,
3987                        ) {
3988                            // Send expanded text directly to the agent worker.
3989                            // The original `/cmd args` is already echoed above.
3990                            prompt_queue.enqueue(expanded);
3991                            LoopOutcome::Continue
3992                        } else {
3993                            ctx.reply(
3994                                InlineMessageKind::Error,
3995                                format!("Unknown command: {}", prompt.trim()),
3996                            );
3997                            LoopOutcome::Continue
3998                        }
3999                    }
4000                };
4001            }
4002            state.append_line(InlineMessageKind::User, vec![plain_segment(prompt.clone())]);
4003            // While a run is active, mirror the prompt into the queue pane so
4004            // the user sees their input is queued (the worker channel already
4005            // serialises execution; this is the visible counterpart).
4006            if session.is_streaming() {
4007                state.queued_inputs.push(prompt.clone());
4008                state.show_tip(
4009                    "send_now",
4010                    "Ctrl+Enter sends now | Ctrl+; manages queue",
4011                    240,
4012                    true,
4013                );
4014            }
4015            // Hand the prompt to the worker thread. If the worker has
4016            // already exited (e.g. shutdown), drop it on the floor.
4017            prompt_queue.enqueue(prompt);
4018        }
4019        InlineEvent::Cancel => {
4020            // Esc-driven cancel. While a stream is running, abort it (the
4021            // input thread's ~1s post-cancel grace then prevents mashing).
4022            // When idle, Esc is an instant one-press quit — no grace, no
4023            // quit-arming footer that would invite a re-press the grace
4024            // swallows.
4025            return match route_cancel(session.is_streaming()) {
4026                CancelRoute::Interrupt => handle_interrupt(state, session, handle),
4027                CancelRoute::Exit => LoopOutcome::Exit,
4028            };
4029        }
4030        InlineEvent::Exit => {
4031            return LoopOutcome::Exit;
4032        }
4033        InlineEvent::Interrupt => {
4034            return handle_interrupt(state, session, handle);
4035        }
4036        InlineEvent::ScrollLineUp => {
4037            state.scroll_offset = state.scroll_offset.saturating_add(1);
4038        }
4039        InlineEvent::ScrollLineDown => {
4040            state.scroll_offset = state.scroll_offset.saturating_sub(1);
4041        }
4042        InlineEvent::ScrollPageUp => {
4043            state.scroll_offset = state.scroll_offset.saturating_add(10);
4044        }
4045        InlineEvent::ScrollPageDown => {
4046            state.scroll_offset = state.scroll_offset.saturating_sub(10);
4047        }
4048        InlineEvent::CyclePrimaryAgent => {
4049            let _ = session.cycle_model();
4050        }
4051        InlineEvent::CyclePrimaryAgentPrevious => {
4052            // No dedicated reverse-cycling API in AgentSession yet;
4053            // forward-cycle is the closest match.
4054            let _ = session.cycle_model();
4055        }
4056        InlineEvent::Overlay(overlay_evt) => {
4057            use oxicode_vtui::tui::core::OverlayEvent;
4058            match overlay_evt {
4059                OverlayEvent::Submitted(sub) => {
4060                    // Tracks whether this submission chained into a new overlay
4061                    // (the action menu after `/providers` row selection, or the
4062                    // secure prompt after `SetApiKey`). When set, the
4063                    // unconditional `close_overlay()` at the end of the arm
4064                    // would clear the freshly-opened overlay because the
4065                    // `cmd` channel processes `ShowOverlay` and
4066                    // `CloseOverlay` in submit order. Stale-state cleanup
4067                    // (clearing `overlay_providers` etc.) still runs — only
4068                    // the close is gated.
4069                    let mut opened_new_overlay = false;
4070                    // If this was a /model picker, set the selected model.
4071                    if let OverlaySubmission::Selection(InlineListSelection::Model(idx)) = &sub
4072                        && idx < &state.overlay_model_ids.len()
4073                    {
4074                        let model_id = state.overlay_model_ids[*idx].clone();
4075                        match session.set_model(&model_id) {
4076                            Ok(()) => {
4077                                sync_model_chips(state, session);
4078                                handle.append_line(
4079                                    InlineMessageKind::Info,
4080                                    vec![plain_segment(format!("Switched to {model_id}"))],
4081                                );
4082                            }
4083                            Err(e) => handle.append_line(
4084                                InlineMessageKind::Error,
4085                                vec![plain_segment(format!("Failed to set model: {e}"))],
4086                            ),
4087                        }
4088                    }
4089                    // If this was a /theme picker, apply the selected theme.
4090                    if let OverlaySubmission::Selection(InlineListSelection::Theme(theme_id)) = &sub
4091                    {
4092                        match oxicode_vtui::theme::set_active_theme(theme_id) {
4093                            Ok(()) => {
4094                                let label = oxicode_vtui::theme::theme_label(theme_id)
4095                                    .unwrap_or(theme_id.as_ref())
4096                                    .to_string();
4097                                handle.append_line(
4098                                    InlineMessageKind::Info,
4099                                    vec![plain_segment(format!("Theme: {label}"))],
4100                                );
4101                            }
4102                            Err(e) => handle.append_line(
4103                                InlineMessageKind::Error,
4104                                vec![plain_segment(format!("Unknown theme: {e}"))],
4105                            ),
4106                        }
4107                    }
4108                    // If this was a command palette selection, fill the prompt.
4109                    if let OverlaySubmission::Selection(InlineListSelection::SlashCommand(name)) =
4110                        &sub
4111                    {
4112                        state.composer.set_text(&format!("/{name} "));
4113                    }
4114                    // Settings overlay: toggle/cycle the selected setting.
4115                    // `ConfigAction` carries the `SettingKey` Debug name
4116                    // emitted by `settings_overlay_items`; dispatch goes
4117                    // through the def table (`apply_change`), never a
4118                    // per-name match.
4119                    // Synthetic ConfigAction payloads emitted by the
4120                    // panel editors: handled FIRST so they never reach
4121                    // the generic `ConfigAction(name)` arm (which would
4122                    // treat `SubmenuCommit:…` / `DisabledToolToggle:…`
4123                    // as a bogus SettingKey name and error out).
4124                    let synthetic_dispatched = if let OverlaySubmission::Selection(
4125                        InlineListSelection::ConfigAction(payload),
4126                    ) = &sub
4127                    {
4128                        if let Some(rest) = payload.strip_prefix("SubmenuCommit:") {
4129                            if let Some((key_str, value)) = rest.split_once(':') {
4130                                if let Some(key) = parse_setting_key(key_str) {
4131                                    match commit_submenu_choice(state, key, value.to_string()) {
4132                                        Ok(status) => {
4133                                            handle.append_line(
4134                                                InlineMessageKind::Info,
4135                                                vec![plain_segment(status.clone())],
4136                                            );
4137                                            opened_new_overlay = state.overlay.is_some();
4138                                        }
4139                                        Err(e) => handle.append_line(
4140                                            InlineMessageKind::Error,
4141                                            vec![plain_segment(format!(
4142                                                "Failed to save setting: {e}"
4143                                            ))],
4144                                        ),
4145                                    }
4146                                } else {
4147                                    handle.append_line(
4148                                        InlineMessageKind::Error,
4149                                        vec![plain_segment(format!(
4150                                            "Unknown setting key in submenu commit: {key_str}"
4151                                        ))],
4152                                    );
4153                                }
4154                            } else {
4155                                handle.append_line(
4156                                    InlineMessageKind::Error,
4157                                    vec![plain_segment(format!(
4158                                        "Malformed submenu commit payload: {payload}"
4159                                    ))],
4160                                );
4161                            }
4162                            true
4163                        } else if let Some(tool) = payload.strip_prefix("DisabledToolToggle:") {
4164                            let essential = session
4165                                .agent_ref()
4166                                .tools()
4167                                .get_tools()
4168                                .into_iter()
4169                                .find(|t| t.name() == tool)
4170                                .is_some_and(|t| t.essential());
4171                            let currently_disabled = crate::store::settings::Settings::load()
4172                                .map(|s| s.disabled_tools.iter().any(|t| t == tool))
4173                                .unwrap_or(false);
4174                            commit_disabled_tool_toggle(
4175                                state,
4176                                handle,
4177                                session,
4178                                tool.to_string(),
4179                                essential,
4180                                currently_disabled,
4181                            );
4182                            opened_new_overlay = state.overlay.is_some();
4183                            true
4184                        } else {
4185                            false
4186                        }
4187                    } else {
4188                        false
4189                    };
4190                    if !synthetic_dispatched
4191                        && let OverlaySubmission::Selection(InlineListSelection::ConfigAction(key)) =
4192                            &sub
4193                    {
4194                        let def = SETTING_DEFS.iter().find(|d| format!("{:?}", d.key) == *key);
4195                        match def {
4196                            Some(def) => {
4197                                let mut settings =
4198                                    crate::store::settings::Settings::load().unwrap_or_default();
4199                                // Toggle submits the inverted bool; Cycle
4200                                // the next variant. The structured editors
4201                                // (Text/Submenu/Multiselect/MapEditor)
4202                                // commit their own explicit values.
4203                                let next_value = match def.widget {
4204                                    SettingWidget::Toggle => Some(
4205                                        (get_display_value(def.key, &settings) != "true")
4206                                            .to_string(),
4207                                    ),
4208                                    SettingWidget::Cycle => next_cycle_value(def.key, &settings),
4209                                    _ => None,
4210                                };
4211                                if let Some(next) = next_value {
4212                                    match crate::tui_vt::settings_defs::apply_change(
4213                                        def.key,
4214                                        &mut settings,
4215                                        next,
4216                                    ) {
4217                                        Ok(()) => match settings.save() {
4218                                            Ok(()) => {
4219                                                if let Err(e) = sync_settings_live(
4220                                                    state, session, def.key, &settings,
4221                                                ) {
4222                                                    // Saved, but the live
4223                                                    // toggle failed — an
4224                                                    // Error line, never a
4225                                                    // silent success.
4226                                                    handle.append_line(
4227                                                        InlineMessageKind::Error,
4228                                                        vec![plain_segment(format!(
4229                                                            "{}: {e}",
4230                                                            def.label
4231                                                        ))],
4232                                                    );
4233                                                } else {
4234                                                    handle.append_line(
4235                                                        InlineMessageKind::Info,
4236                                                        vec![plain_segment(format!(
4237                                                            "{}: {}",
4238                                                            def.label,
4239                                                            get_display_value(def.key, &settings)
4240                                                        ))],
4241                                                    );
4242                                                }
4243                                            }
4244                                            Err(e) => handle.append_line(
4245                                                InlineMessageKind::Error,
4246                                                vec![plain_segment(format!(
4247                                                    "Failed to save {}: {e}",
4248                                                    def.label
4249                                                ))],
4250                                            ),
4251                                        },
4252                                        Err(e) => handle.append_line(
4253                                            InlineMessageKind::Error,
4254                                            vec![plain_segment(format!(
4255                                                "Failed to apply {}: {e}",
4256                                                def.label
4257                                            ))],
4258                                        ),
4259                                    }
4260                                }
4261                            }
4262                            None => handle.append_line(
4263                                InlineMessageKind::Error,
4264                                vec![plain_segment(format!("Unknown setting: {key}"))],
4265                            ),
4266                        }
4267                    }
4268                    // Settings panel tab switch: reopen the panel rebuilt
4269                    // for the requested tab (Enter closes the overlay, so
4270                    // the switch has to reopen it).
4271                    if let OverlaySubmission::Selection(InlineListSelection::SettingsTab(idx)) =
4272                        &sub
4273                    {
4274                        switch_settings_tab(state, *idx);
4275                        opened_new_overlay = state.overlay.is_some();
4276                    }
4277                    // Settings panel sidebar section jump: reopen on the
4278                    // active tab with the selection moved to the section's
4279                    // first row.
4280                    if let OverlaySubmission::Selection(InlineListSelection::SettingsSection(idx)) =
4281                        &sub
4282                    {
4283                        jump_settings_section(state, *idx);
4284                        opened_new_overlay = state.overlay.is_some();
4285                    }
4286                    // Keybinding capture: selecting an action row opens
4287                    // the "press a key combo" prompt. The INPUT thread
4288                    // consumes the next key before global-shortcut
4289                    // resolution (`handle_key_capture`) and commits
4290                    // through the keybindings map editor.
4291                    if let OverlaySubmission::Selection(InlineListSelection::SettingKeyCapture(
4292                        name,
4293                    )) = &sub
4294                    {
4295                        if GlobalAction::from_name(name).is_some() {
4296                            state.overlay = Some(build_key_capture_overlay(name));
4297                            state.settings_map_rows.clear();
4298                            opened_new_overlay = true;
4299                        } else {
4300                            handle.append_line(
4301                                InlineMessageKind::Error,
4302                                vec![plain_segment(format!("Unknown keybinding action: {name}"))],
4303                            );
4304                        }
4305                    }
4306                    // Settings-panel text editor: open the prompt; the
4307                    // submitted text arrives via the SecureInput arm
4308                    // below (`SecureInputOrigin::TextEdit(key)`).
4309                    if let OverlaySubmission::Selection(InlineListSelection::SettingTextEdit(
4310                        key_name,
4311                    )) = &sub
4312                    {
4313                        if let Some(key) = parse_setting_key(key_name) {
4314                            open_text_edit_prompt(state, key);
4315                            opened_new_overlay = true;
4316                        } else {
4317                            handle.append_line(
4318                                InlineMessageKind::Error,
4319                                vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4320                            );
4321                        }
4322                    }
4323                    // Settings-panel submenu-select: open a child list
4324                    // whose selections arrive as synthetic
4325                    // `ConfigAction("SubmenuCommit:Key:value")` payloads
4326                    // routed by the ConfigAction arm below.
4327                    if let OverlaySubmission::Selection(InlineListSelection::SettingSubmenuOpen(
4328                        key_name,
4329                    )) = &sub
4330                    {
4331                        if let Some(key) = parse_setting_key(key_name) {
4332                            open_submenu_select_prompt(state, key);
4333                            opened_new_overlay = true;
4334                        } else {
4335                            handle.append_line(
4336                                InlineMessageKind::Error,
4337                                vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4338                            );
4339                        }
4340                    }
4341                    // Settings-panel multiselect: open a tool list
4342                    // sourced live from `session.agent_ref().tools()`;
4343                    // selections arrive as synthetic
4344                    // `ConfigAction("DisabledToolToggle:tool")` payloads.
4345                    if let OverlaySubmission::Selection(InlineListSelection::SettingMultiselect(
4346                        key_name,
4347                    )) = &sub
4348                    {
4349                        if let Some(parsed) = parse_setting_key(key_name) {
4350                            if parsed == SettingKey::DisabledTools {
4351                                open_disabled_tools_multiselect(state, session);
4352                                opened_new_overlay = true;
4353                            } else {
4354                                handle.append_line(
4355                                    InlineMessageKind::Error,
4356                                    vec![plain_segment(format!(
4357                                        "'{parsed:?}' has no multiselect editor"
4358                                    ))],
4359                                );
4360                            }
4361                        } else {
4362                            handle.append_line(
4363                                InlineMessageKind::Error,
4364                                vec![plain_segment(format!("Unknown setting key: {key_name}"))],
4365                            );
4366                        }
4367                    }
4368                    // Session picker: enqueue the selected session. The next
4369                    // Submit event drains it before normal composer dispatch.
4370                    if let OverlaySubmission::Selection(InlineListSelection::Session(id)) = &sub {
4371                        // Gate: refuse to queue a resume while the agent is
4372                        // running — the pending_resume drain would clobber
4373                        // the in-flight conversation's message history on
4374                        // the shared Arc<Agent> (same wording as the direct
4375                        // /sessions <id> path and /handoff).
4376                        if session.is_streaming() {
4377                            handle.append_line(
4378                                InlineMessageKind::Error,
4379                                vec![plain_segment(
4380                                    "Cannot resume while agent is running. Use /cancel first.",
4381                                )],
4382                            );
4383                        } else {
4384                            let path = crate::tui_vt::slash::registry::sessions_dir()
4385                                .join(format!("{id}.jsonl"));
4386                            if !path.is_file() {
4387                                handle.append_line(
4388                                    InlineMessageKind::Error,
4389                                    vec![plain_segment(format!(
4390                                        "No session file: {}",
4391                                        path.display()
4392                                    ))],
4393                                );
4394                            } else {
4395                                state.pending_resume = Some(path);
4396                            }
4397                        }
4398                    }
4399                    // `/models` catalog browser: switch to the selected model.
4400                    if let OverlaySubmission::Selection(InlineListSelection::CatalogModel(idx)) =
4401                        &sub
4402                        && idx < &state.overlay_catalog_models.len()
4403                    {
4404                        let (provider, model_id) = &state.overlay_catalog_models[*idx];
4405                        let full = format!("{provider}/{model_id}");
4406                        match session.set_model(&full) {
4407                            Ok(()) => {
4408                                sync_model_chips(state, session);
4409                                handle.append_line(
4410                                    InlineMessageKind::Info,
4411                                    vec![plain_segment(format!("Switched to {full}"))],
4412                                );
4413                            }
4414                            Err(e) => handle.append_line(
4415                                InlineMessageKind::Error,
4416                                vec![plain_segment(format!("Failed to set model: {e}"))],
4417                            ),
4418                        }
4419                    }
4420                    // `/providers` list: pick a provider, then drive the
4421                    // `next_provider_actions(has_key, oauth_capable)` matrix.
4422                    // Single-action cases fire straight into
4423                    // `handle_auth_action`; multi-action cases open a
4424                    // one-shot action list whose selections are
4425                    // `ProviderAction { provider, action }`.
4426                    if let OverlaySubmission::Selection(InlineListSelection::ProviderRow(idx)) =
4427                        &sub
4428                        && idx < &state.overlay_providers.len()
4429                    {
4430                        let name = state.overlay_providers[*idx].clone();
4431                        let auth = crate::store::auth_storage::shared_auth_storage();
4432                        let has_key = auth.has(&name);
4433                        let oauth_capable = crate::provider_oauth::spec_for(&name).is_some();
4434                        let actions = next_provider_actions(has_key, oauth_capable);
4435                        if actions.len() == 1 {
4436                            // Single action — drive directly with no menu.
4437                            opened_new_overlay |=
4438                                handle_auth_action(&name, &actions[0], &auth, handle, state);
4439                        } else {
4440                            // Show action menu.
4441                            let items: Vec<InlineListItem> = actions
4442                                .iter()
4443                                .map(|a| InlineListItem {
4444                                    title: match a {
4445                                        AuthAction::SetApiKey => "Set API key".into(),
4446                                        AuthAction::StartOAuth => "Login with OAuth".into(),
4447                                        AuthAction::RemoveKey => "Remove key".into(),
4448                                    },
4449                                    subtitle: None,
4450                                    badge: None,
4451                                    indent: 0,
4452                                    selection: Some(InlineListSelection::ProviderAction {
4453                                        provider: name.clone(),
4454                                        action: a.clone(),
4455                                    }),
4456                                    search_value: None,
4457                                })
4458                                .collect();
4459                            handle.show_list_modal(
4460                                name.clone(),
4461                                vec!["Pick an action".into()],
4462                                items,
4463                                None,
4464                                None,
4465                            );
4466                            opened_new_overlay = true;
4467                        }
4468                    }
4469                    // `/providers` action menu: forward the chosen
4470                    // `AuthAction` to the host dispatcher. Selecting
4471                    // "Remove key" reuses the existing y/n confirmation
4472                    // modal; "Set API key" opens the secure prompt;
4473                    // "Login with OAuth" prints the Task 8 stub.
4474                    if let OverlaySubmission::Selection(InlineListSelection::ProviderAction {
4475                        provider,
4476                        action,
4477                    }) = &sub
4478                    {
4479                        let auth = crate::store::auth_storage::shared_auth_storage();
4480                        opened_new_overlay |=
4481                            handle_auth_action(provider, action, &auth, handle, state);
4482                    }
4483                    // Text/secure prompt committed by the user. The
4484                    // matching open prompt must have stashed
4485                    // `state.secure_input_origin`; we trust that field
4486                    // here because every prompt path sets it before
4487                    // opening the modal (`open_secure_prompt` for auth,
4488                    // the model-role prompt builders for the map
4489                    // editor).
4490                    if let OverlaySubmission::SecureInput(text) = &sub
4491                        && let Some(origin) = state.secure_input_origin.take()
4492                    {
4493                        match origin {
4494                            SecureInputOrigin::ModelRoleKey => {
4495                                // Phase 1 of the new-role flow: the text
4496                                // is the role NAME — chain straight into
4497                                // the value prompt.
4498                                let role = text.trim().to_string();
4499                                if role.is_empty() {
4500                                    handle.append_line(
4501                                        InlineMessageKind::Error,
4502                                        vec![plain_segment(
4503                                            "Model role name can't be empty".to_string(),
4504                                        )],
4505                                    );
4506                                } else {
4507                                    open_model_role_value_prompt(state, &role);
4508                                    opened_new_overlay = true;
4509                                }
4510                            }
4511                            SecureInputOrigin::TextEdit(key) => {
4512                                let (_outcome, _msg) = commit_text_edit(
4513                                    state,
4514                                    handle,
4515                                    Some(session),
4516                                    key,
4517                                    text.clone(),
4518                                );
4519                                opened_new_overlay = state.overlay.is_some();
4520                            }
4521                            SecureInputOrigin::ModelRoleValue { role } => {
4522                                let model = text.trim().to_string();
4523                                let outcome = if model.is_empty() {
4524                                    Err("model pattern can't be empty".to_string())
4525                                } else {
4526                                    crate::store::settings::Settings::load()
4527                                        .map_err(|e| e.to_string())
4528                                        .and_then(|mut settings| {
4529                                            crate::tui_vt::settings_defs::set_model_role(
4530                                                &mut settings,
4531                                                &role,
4532                                                model.clone(),
4533                                            );
4534                                            settings.save().map_err(|e| e.to_string())
4535                                        })
4536                                };
4537                                match outcome {
4538                                    Ok(()) => {
4539                                        handle.append_line(
4540                                            InlineMessageKind::Info,
4541                                            vec![plain_segment(format!(
4542                                                "Model role '{role}' \u{2192} {model}"
4543                                            ))],
4544                                        );
4545                                        reopen_settings_panel(
4546                                            state,
4547                                            SettingsTab::Model,
4548                                            Some(format!("Saved '{role}' \u{2192} {model}")),
4549                                        );
4550                                        opened_new_overlay = true;
4551                                    }
4552                                    Err(e) => handle.append_line(
4553                                        InlineMessageKind::Error,
4554                                        vec![plain_segment(format!(
4555                                            "Failed to save model role '{role}': {e}"
4556                                        ))],
4557                                    ),
4558                                }
4559                            }
4560                            origin @ (SecureInputOrigin::SetKey { .. }
4561                            | SecureInputOrigin::NewlyAdded { .. }) => {
4562                                let provider = match &origin {
4563                                    SecureInputOrigin::SetKey { provider }
4564                                    | SecureInputOrigin::NewlyAdded { provider } => {
4565                                        provider.clone()
4566                                    }
4567                                    _ => unreachable!("auth arm only matches auth origins"),
4568                                };
4569                                let auth = crate::store::auth_storage::shared_auth_storage();
4570                                auth.set_api_key(&provider, text.clone());
4571                                // The agent keeps a constructed provider instance. Saving a
4572                                // key alone is not enough for an already-open session: ask
4573                                // the resolver for a fresh provider immediately so the next
4574                                // message uses this credential without a restart or model
4575                                // switch.
4576                                let refreshed = session.refresh_api_key();
4577                                let msg = match origin {
4578                                    SecureInputOrigin::SetKey { .. } => format!(
4579                                        "Saved API key for '{provider}'. {}",
4580                                        match refreshed {
4581                                            Ok(()) => "Ready to retry your message.",
4582                                            Err(_) => "Restart this session before retrying.",
4583                                        }
4584                                    ),
4585                                    SecureInputOrigin::NewlyAdded { .. } => format!(
4586                                        "Added and configured '{provider}'. {}",
4587                                        match refreshed {
4588                                            Ok(()) =>
4589                                                "Use /models to choose a model, or send a message.",
4590                                            Err(_) => "Restart this session before using it.",
4591                                        }
4592                                    ),
4593                                    SecureInputOrigin::ModelRoleKey
4594                                    | SecureInputOrigin::ModelRoleValue { .. } => {
4595                                        unreachable!("auth branch reached with a model-role origin")
4596                                    }
4597                                    SecureInputOrigin::TextEdit(_) => {
4598                                        unreachable!("auth branch reached with a text-edit origin")
4599                                    }
4600                                };
4601                                handle
4602                                    .append_line(InlineMessageKind::Info, vec![plain_segment(msg)]);
4603                            }
4604                        }
4605                    }
4606                    state.overlay_catalog_models.clear();
4607                    state.overlay_providers.clear();
4608                    state.overlay_model_ids.clear();
4609                    if !opened_new_overlay {
4610                        handle.close_overlay();
4611                    }
4612                }
4613                OverlayEvent::Cancelled => {
4614                    handle.close_overlay();
4615                }
4616                OverlayEvent::SelectionChanged(_) => {}
4617            }
4618        }
4619        _ => {
4620            // Other events (overlay, list-selection, etc.) are no-ops in
4621            // this harness — they are handled by the harness overlay
4622            // component, not by the inline protocol.
4623        }
4624    }
4625    LoopOutcome::Continue
4626}
4627
4628// ─────────────────────────────────────────────────────────────────────────
4629// Ctrl+C policy / streaming guard
4630// ─────────────────────────────────────────────────────────────────────────
4631
4632/// RAII guard that clears the streaming flag on drop (normal exit, error,
4633/// or panic cancellation). Wired in [`run_one_prompt`] around each run.
4634struct StreamingGuard<'a>(&'a std::sync::atomic::AtomicBool);
4635
4636impl Drop for StreamingGuard<'_> {
4637    fn drop(&mut self) {
4638        use std::sync::atomic::Ordering;
4639        self.0.store(false, Ordering::SeqCst);
4640    }
4641}
4642
4643/// Central Ctrl+C policy.
4644///
4645/// - **Agent streaming** → abort the current run and tell the user to press
4646///   again to quit. The abort is effective because the session hooks installed
4647///   via `App::from_oxicode` → `with_session_hooks` wire the session's
4648///   `should_stop` flag into the agent loop.
4649/// - **Agent idle** → exit the application.
4650///
4651/// Both the input-thread key event (`InlineEvent::Interrupt`) and the OS
4652/// signal handler (`tokio::signal::ctrl_c()`) route through here so
4653/// behavior is identical regardless of how the interrupt arrives.
4654///
4655fn handle_interrupt(
4656    state: &mut RenderState,
4657    session: &crate::app::agent_session::AgentSessionHandle,
4658    _handle: &InlineHandle,
4659) -> LoopOutcome {
4660    // If a confirmation is already open, Ctrl+C acts as confirm (quit).
4661    if state.confirmation.is_some() {
4662        return LoopOutcome::Exit;
4663    }
4664    // A second Ctrl+C (after the first armed a quit during a stream) opens
4665    // the quit confirmation modal instead of exiting outright.
4666    if state.pending_quit {
4667        state.confirmation = Some(quit_confirmation());
4668        state.pending_quit = false;
4669        return LoopOutcome::Continue;
4670    }
4671    // First Ctrl+C. While streaming, abort the run and arm a quit (the next
4672    // press opens the confirmation). When idle, open the confirmation at
4673    // once — no separate quit-arming step needed.
4674    if session.is_streaming() {
4675        let s = session.clone();
4676        tokio::spawn(async move {
4677            s.abort().await;
4678        });
4679        state.pending_quit = true;
4680    } else {
4681        state.confirmation = Some(quit_confirmation());
4682    }
4683    LoopOutcome::Continue
4684}
4685
4686/// Build the standard quit-confirmation dialog.
4687fn quit_confirmation() -> ModalConfirmation {
4688    ModalConfirmation {
4689        title: "Quit oxicode?".into(),
4690        message: "  y \u{2014} quit now     n / x \u{2014} stay".into(),
4691        action: ConfirmationAction::Quit,
4692    }
4693}
4694
4695/// Build a clear-conversation confirmation dialog.
4696pub(super) fn clear_confirmation() -> ModalConfirmation {
4697    ModalConfirmation {
4698        title: "Clear conversation?".into(),
4699        message: "  y \u{2014} clear all     n / x \u{2014} cancel".into(),
4700        action: ConfirmationAction::ClearConversation,
4701    }
4702}
4703
4704// ─────────────────────────────────────────────────────────────────────────
4705// Input thread — polls crossterm, edits the shared buffer, and forwards
4706// lifecycle events (Submit, Cancel, …) over a tokio channel.
4707// ─────────────────────────────────────────────────────────────────────────
4708
4709/// Execute a global shortcut resolved by the [`Keymap`]. The bodies are
4710/// the original hardcoded Ctrl-* handlers from the input loop, unchanged
4711/// — only the trigger condition became keymap-driven. The branch's
4712/// KeyAction set (Submit/ScrollUp/ScrollDown/Clear/Help/ModelPicker/
4713/// ToggleThinking) was folded into this single match via the unified
4714/// `GlobalAction` enum, so a user rebind for any of them dispatches here
4715/// without falling through to the hardcoded arms below.
4716fn apply_global_action(
4717    action: GlobalAction,
4718    state: &Arc<parking_lot::Mutex<RenderState>>,
4719    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4720) {
4721    match action {
4722        // Ctrl+C: even with raw mode enabled some terminals / shells
4723        // fall back to delivering it as a SIGINT. Handle it as an
4724        // explicit interrupt so we don't depend on the OS signal.
4725        GlobalAction::Interrupt => {
4726            let _ = evt_tx.send(InlineEvent::Interrupt);
4727        }
4728        // Ctrl+M: toggle multiline input mode.
4729        GlobalAction::ToggleMultiline => {
4730            let mut s = state.lock();
4731            s.multiline_mode = !s.multiline_mode;
4732        }
4733        // Ctrl+P: open the command palette.
4734        GlobalAction::OpenCommandPalette => {
4735            let mut s = state.lock();
4736            s.overlay = Some(build_command_palette());
4737        }
4738        // Ctrl+;: toggle the interactive queue panel.
4739        GlobalAction::ToggleQueuePanel => {
4740            let mut s = state.lock();
4741            s.queue_panel_open = !s.queue_panel_open;
4742            if s.queue_panel_open {
4743                s.queue_selected = 0;
4744            }
4745        }
4746        // Ctrl+E: fold all blocks (Shift+E expands all).
4747        GlobalAction::FoldAll => {
4748            let mut s = state.lock();
4749            s.fold_all();
4750        }
4751        // Ctrl+Enter: send-now — abort the current run (if any) and submit
4752        // the composed input immediately, bypassing the queue pane.
4753        GlobalAction::SendNow => {
4754            let submitted = harvest_and_clear_input(state);
4755            if !submitted.is_empty() {
4756                let _ = evt_tx.send(InlineEvent::Interrupt);
4757                let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4758            }
4759        }
4760        // Plain Enter (and Shift+Enter, both default Submit bindings):
4761        // harvest and submit the buffer. The muscle-memory carve-out for
4762        // plain Enter in multiline mode (so it inserts a newline) lives
4763        // in `keymap_pre_match` — this arm only fires after that check.
4764        GlobalAction::Submit => {
4765            let submitted = harvest_and_clear_input(state);
4766            let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
4767        }
4768        // PageUp: scroll transcript up by a page.
4769        GlobalAction::ScrollUp => {
4770            let _ = evt_tx.send(InlineEvent::ScrollPageUp);
4771        }
4772        // PageDown: scroll transcript down by a page.
4773        GlobalAction::ScrollDown => {
4774            let _ = evt_tx.send(InlineEvent::ScrollPageDown);
4775        }
4776        // Ctrl+L: clear visible scrollback / fold-all (default behavior
4777        // matches Ctrl+E / FoldAll today — the branch wired Clear to
4778        // Ctrl+E; main has Ctrl+E = FoldAll already, so Clear was
4779        // reassigned to Ctrl+L and routed to fold_all()).
4780        GlobalAction::Clear => {
4781            let mut s = state.lock();
4782            s.fold_all();
4783        }
4784        // ?: open the keyboard-shortcuts overlay. The carve-out for `?`
4785        // typed into a non-empty composer (so it inserts the char)
4786        // lives in the Char arm and `keymap_pre_match`'s Help gate.
4787        GlobalAction::Help => {
4788            let mut s = state.lock();
4789            s.overlay = Some(cheatsheet_overlay());
4790        }
4791        // Ctrl+G: model picker shortcut — currently aliased to the
4792        // command palette (the palette has the model switcher as its
4793        // first tab). Future PR can split ModelPicker into its own
4794        // overlay; for now it mirrors OpenCommandPalette.
4795        GlobalAction::ModelPicker => {
4796            let mut s = state.lock();
4797            s.overlay = Some(build_command_palette());
4798        }
4799        // Ctrl+T: toggle the thinking-reasoning channel. Same wiring as
4800        // ToggleMultiline today; the branch introduced this name, main
4801        // had ToggleMultiline on Ctrl+M. Both bindings stay live so a
4802        // user rebinding one doesn't lose the other.
4803        GlobalAction::ToggleThinking => {
4804            let mut s = state.lock();
4805            s.multiline_mode = !s.multiline_mode;
4806        }
4807    }
4808}
4809
4810/// Outcome of the generic keymap pre-match for the four actions that
4811/// historically lived only inside hardcoded dispatch arms (Submit,
4812/// ScrollUp, ScrollDown, Help). [`KeymapDispatch::None`] means "the
4813/// keymap does not bind this key to any of the four" — the hardcoded
4814/// arms below then act as the fallback.
4815#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4816pub(crate) enum KeymapDispatch {
4817    Submit,
4818    ScrollPageUp,
4819    ScrollPageDown,
4820    Help,
4821    None,
4822}
4823
4824/// Consult the keymap for the four actions whose dispatch used to be
4825/// hardcoded. Consuming the key here is what makes a user rebind real:
4826/// `submit: alt+s` fires although no arm in the input thread matches
4827/// Alt+S; previously the capability was "disable at the original key"
4828/// only. After the squash-merge these are `GlobalAction` variants, not
4829/// the branch's `KeyAction`.
4830///
4831/// Two muscle-memory carve-outs keep the pre-keymap behavior intact:
4832/// * Plain Enter in multiline mode inserts a newline even when Enter
4833///   is bound to Submit — only the *send* path is remappable, so the
4834///   key falls through to the Enter arm ([`KeymapDispatch::None`]).
4835/// * A PRINTABLE Help binding (the default `?`) is left to the Char
4836///   arm, which gates Help on the empty composer so typing `?` inside
4837///   text still inserts it. Non-printable Help bindings (function
4838///   keys, …) never reach a Char arm and dispatch here.
4839pub(crate) fn keymap_pre_match(
4840    keymap: &Keymap,
4841    key: &crossterm::event::KeyEvent,
4842    multiline: bool,
4843) -> KeymapDispatch {
4844    let plain_enter_multiline =
4845        key.code == KeyCode::Enter && multiline && !key.modifiers.contains(KeyModifiers::SHIFT);
4846    if keymap.matches(GlobalAction::Submit, key) && !plain_enter_multiline {
4847        return KeymapDispatch::Submit;
4848    }
4849    if keymap.matches(GlobalAction::ScrollUp, key) {
4850        return KeymapDispatch::ScrollPageUp;
4851    }
4852    if keymap.matches(GlobalAction::ScrollDown, key) {
4853        return KeymapDispatch::ScrollPageDown;
4854    }
4855    if keymap.matches(GlobalAction::Help, key) && !matches!(key.code, KeyCode::Char(_)) {
4856        return KeymapDispatch::Help;
4857    }
4858    KeymapDispatch::None
4859}
4860
4861/// Harvest the composer buffer (or the selected slash-popup item) as a
4862/// submit payload: clears the composer and popup, records prompt
4863/// history, and returns the submitted text. Shared by the SendNow
4864/// arm, the Submit arm, and the generic Submit dispatch.
4865fn harvest_and_clear_input(state: &Arc<parking_lot::Mutex<RenderState>>) -> String {
4866    let mut s = state.lock();
4867    let buf = if s.slash_popup.open && !s.slash_popup.items.is_empty() {
4868        format!("/{}", s.slash_popup.items[s.slash_popup.selected].name)
4869    } else {
4870        let buf = s.composer.text().to_string();
4871        s.composer.set_text("");
4872        buf
4873    };
4874    s.slash_popup = SlashPopup::default();
4875    s.history_pos = None;
4876    // Record non-empty, non-command prompts in history.
4877    if !buf.is_empty() && !buf.starts_with('/') {
4878        s.prompt_history.insert(0, buf.clone());
4879        s.prompt_history.truncate(100);
4880    }
4881    buf
4882}
4883
4884/// The keyboard-shortcuts overlay. Shared by the generic Help dispatch
4885/// and the printable (`?`) Char-arm check, which gates it on the empty
4886/// composer.
4887fn cheatsheet_overlay() -> OverlayState {
4888    OverlayState {
4889        title: "Keyboard Shortcuts".into(),
4890        lines: cheatsheet_lines(),
4891        items: vec![],
4892        selected: 0,
4893        search: None,
4894        secure_input: None,
4895        ..Default::default()
4896    }
4897}
4898
4899fn spawn_input_thread(
4900    state: Arc<parking_lot::Mutex<RenderState>>,
4901    evt_tx: tokio::sync::mpsc::UnboundedSender<InlineEvent>,
4902    mode_handle: Option<std::sync::Arc<std::sync::atomic::AtomicU8>>,
4903    prompt_queue: Arc<PromptQueue>,
4904    issue_action_tx: tokio::sync::mpsc::UnboundedSender<
4905        crate::tui_vt::issues_panel::IssueActionRequest,
4906    >,
4907) -> std::thread::JoinHandle<()> {
4908    std::thread::spawn(move || {
4909        // Poll stdin in a tight loop. `event::poll` returns `Ok(false)` on
4910        // timeout (no key within the window) — that is NOT a reason to exit,
4911        // only to poll again. The previous `while let Ok(true) = poll(...)`
4912        // treated the first timeout as loop termination, killing this thread
4913        // ~50ms after launch, dropping `evt_tx`, and leaving the TUI unable
4914        // to receive keyboard input — a black screen that only redrew on
4915        // Ctrl+C. Exit only on a genuine read error (stdin closed).
4916        loop {
4917            match event::poll(std::time::Duration::from_millis(50)) {
4918                Ok(true) => {}
4919                Ok(false) => continue,
4920                Err(_) => break,
4921            }
4922            let event = match event::read() {
4923                Ok(ev) => ev,
4924                Err(_) => continue,
4925            };
4926
4927            // Bracketed paste arrives as its own event; flatten into a
4928            // string of `Submit` text.
4929            let mut pasted = String::new();
4930            let mut key_event = None;
4931            match event {
4932                Event::Key(k) if k.kind == KeyEventKind::Press => key_event = Some(k),
4933                Event::Paste(p) => pasted = p,
4934                _ => {}
4935            }
4936
4937            // Modal hierarchy: `/issue` panel owns input while open.
4938            // Bracketed paste must NOT leak into the hidden composer — the
4939            // panel has no paste handler yet, so we absorb the paste here
4940            // (forwarding it into FilterInput/Form is a later enhancement,
4941            // out of scope today — per the controller's ledger note).
4942            if !pasted.is_empty() && state.lock().issues_panel.is_some() {
4943                continue;
4944            }
4945            if !pasted.is_empty() {
4946                // targets the masked input field instead of the main
4947                // composer buffer. Single-line filter (drops non-graphic
4948                // bytes, strips trailing newline) keeps secrets clean.
4949                let routed_to_secure = {
4950                    let mut s = state.lock();
4951                    if let Some(overlay) = s.overlay.as_mut() {
4952                        if let Some(secure) = overlay.secure_input.as_mut() {
4953                            // Bracketed paste ends in `\n`; strip it before
4954                            // filtering so the final newline never reaches
4955                            // the editor.
4956                            let trimmed = pasted.trim_end_matches('\n');
4957                            for ch in trimmed.chars() {
4958                                if ch.is_ascii_graphic() || ch == ' ' {
4959                                    let _ = secure
4960                                        .editor
4961                                        .apply(oxicode_textarea::EditCommand::Insert(ch));
4962                                }
4963                            }
4964                            true
4965                        } else {
4966                            false
4967                        }
4968                    } else {
4969                        false
4970                    }
4971                };
4972                if routed_to_secure {
4973                    continue;
4974                }
4975                let mut s = state.lock();
4976                s.composer.insert_str(&pasted);
4977                // Refresh popups so e.g. a paste that turns the buffer
4978                // into `/sessions <id>` closes the slash autocomplete
4979                // (it deactivates when `buf[1..].contains(' ')`). Without
4980                // this, the popup stays open with stale items and the
4981                // next Enter would replace the buffer with the bare
4982                // command name, dropping the pasted args.
4983                refresh_input_popups(&mut s);
4984                continue;
4985            }
4986            let Some(key) = key_event else { continue };
4987
4988            // Snapshot the live keymap for this keystroke: the settings
4989            // keybindings editor swaps `RenderState::keymap` in place, so
4990            // every key resolves against the current map (same RwLock the
4991            // editor writes). Cheap — the map is a small HashMap and key
4992            // events are human-paced.
4993            let keymap = state.lock().keymap.read().clone();
4994
4995            // Keybinding capture takes precedence over EVERYTHING — the
4996            // whole point is to grab the next combo verbatim, even one
4997            // that currently resolves to a global action (that's how
4998            // you re-examine an existing binding) or lands in the
4999            // overlay/search handling below.
5000            {
5001                let capturing = {
5002                    let s = state.lock();
5003                    s.overlay.as_ref().is_some_and(|o| o.key_capture.is_some())
5004                };
5005                if capturing {
5006                    let mut s = state.lock();
5007                    handle_key_capture(&mut s, key);
5008                    continue;
5009                }
5010            }
5011
5012            // Confirmation modal takes priority over composer keys, but the
5013            // keymap's Interrupt binding (default Ctrl+C) outranks it: the
5014            // event loop's Ctrl+C policy treats "confirmation open" as
5015            // confirm-quit, so the two-press quit path must see the second
5016            // Ctrl+C even with the modal up. Resolving through the keymap
5017            // (not a hardcoded Ctrl+C) keeps user rebinds working. The
5018            // `/issue` panel sits *below* confirmation so that when a
5019            // Ctrl+C-armed quit confirmation pops over the panel, y/n still
5020            // work and the dialog stays visible.
5021            {
5022                let s = state.lock();
5023                if s.confirmation.is_some() {
5024                    drop(s);
5025                    let interrupt = {
5026                        let s = state.lock();
5027                        matches!(s.keymap.read().resolve(key), Some(GlobalAction::Interrupt))
5028                    };
5029                    if interrupt {
5030                        let _ = evt_tx.send(InlineEvent::Interrupt);
5031                        continue;
5032                    }
5033                    handle_confirmation_key(&state, &evt_tx, &issue_action_tx, key.code);
5034                    continue;
5035                }
5036            }
5037
5038            // `/issue` panel — modal: while open it consumes navigation,
5039            // status-toggle, and dismissal keys so nothing leaks into the
5040            // composer underneath. Outranks the global-shortcut resolution
5041            // below so e.g. a remapped palette shortcut cannot open an
5042            // invisible command palette that would then outrank the panel.
5043            {
5044                let s = state.lock();
5045                if s.issues_panel.is_some() {
5046                    drop(s);
5047                    if crate::tui_vt::issues_panel::handle_issues_panel_key(
5048                        &state,
5049                        &issue_action_tx,
5050                        key,
5051                    ) {
5052                        continue;
5053                    }
5054                }
5055            }
5056
5057            // Global shortcuts: resolve through the live keymap. The
5058            // defaults match the historical hardcoded Ctrl-* bindings;
5059            // `settings.keybindings` can rebind any of them and the
5060            // keybindings editor swaps the map in place. The branch's
5061            // hardcoded dispatch was removed; `apply_global_action`
5062            // now handles every unified GlobalAction variant.
5063            {
5064                let action = {
5065                    let s = state.lock();
5066                    s.keymap.read().resolve(key)
5067                };
5068                if let Some(action) = action {
5069                    apply_global_action(action, &state, &evt_tx);
5070                    continue;
5071                }
5072            }
5073
5074            // Overlay key handling takes priority — when an overlay is
5075            // open, Up/Down navigate, Enter submits, Esc cancels, and any
5076            // printable char is captured for the search bar (if any).
5077            // All other keys are swallowed so the composer buffer stays
5078            // frozen while the user is interacting with the overlay.
5079            {
5080                let s = state.lock();
5081                if s.overlay.is_some() {
5082                    drop(s);
5083                    if handle_overlay_key(&state, &evt_tx, key.code) {
5084                        continue;
5085                    }
5086                }
5087            }
5088
5089            // @-file-search dropdown — when the picker is open, intercept
5090            // navigation and accept keys. Regular chars fall through to
5091            // normal buffer insertion so the user can keep typing.
5092            {
5093                let s = state.lock();
5094                if s.file_search.is_some() {
5095                    drop(s);
5096                    if handle_file_search_key(&state, &evt_tx, key.code) {
5097                        continue;
5098                    }
5099                }
5100            }
5101
5102            // Git TUI overlay has absolute key priority when open — keys
5103            // route through `match_git_key` first; commit-mode chars are
5104            // appended to the message; unmatched keys do NOT fall through
5105            // to the composer (the brief: overlay REPLACES the composer).
5106            if state.lock().git_tui.is_some() && handle_git_tui_key(&state, key.code, key.modifiers)
5107            {
5108                continue;
5109            }
5110
5111            // Generic keymap dispatch (final-review finding 6): the
5112            // four actions that historically lived only inside
5113            // hardcoded arms below — Submit, ScrollUp, ScrollDown,
5114            // Help — are consulted BEFORE those arms so a user
5115            // rebind (e.g. `submit: alt+s` in keybindings.yml)
5116            // actually fires. Keys the keymap does NOT bind to these
5117            // actions fall through to the arms, which act as the
5118            // fallback (Enter-as-newline in multiline, `?` on the
5119            // empty composer, …). Placed after the modal handlers
5120            // above so overlay/confirmation/git keys keep priority.
5121            let multiline_mode = state.lock().multiline_mode;
5122            match keymap_pre_match(&keymap, &key, multiline_mode) {
5123                KeymapDispatch::Submit => {
5124                    // Shell mode: submit the buffer as a bash command
5125                    // request.
5126                    if state.lock().shell_mode {
5127                        let submitted = {
5128                            let mut s = state.lock();
5129                            let buf = s.composer.text().to_string();
5130                            s.composer.set_text("");
5131                            s.shell_mode = false;
5132                            s.history_pos = None;
5133                            if !buf.is_empty() {
5134                                s.prompt_history.insert(0, buf.clone());
5135                                s.prompt_history.truncate(100);
5136                            }
5137                            buf
5138                        };
5139                        if !submitted.is_empty() {
5140                            let prompt = format!("Run this shell command: `{submitted}`");
5141                            let _ = evt_tx.send(InlineEvent::Submit(prompt.into()));
5142                        }
5143                        continue;
5144                    }
5145                    let submitted = harvest_and_clear_input(&state);
5146                    let _ = evt_tx.send(InlineEvent::Submit(submitted.into()));
5147                    continue;
5148                }
5149                KeymapDispatch::ScrollPageUp => {
5150                    let _ = evt_tx.send(InlineEvent::ScrollPageUp);
5151                    continue;
5152                }
5153                KeymapDispatch::ScrollPageDown => {
5154                    let _ = evt_tx.send(InlineEvent::ScrollPageDown);
5155                    continue;
5156                }
5157                KeymapDispatch::Help => {
5158                    let mut s = state.lock();
5159                    s.overlay = Some(cheatsheet_overlay());
5160                    continue;
5161                }
5162                KeymapDispatch::None => {}
5163            }
5164
5165            match key.code {
5166                // Shift+Tab — cycle autonomy mode Default <-> Auto.
5167                KeyCode::BackTab => {
5168                    if let Some(h) = &mode_handle {
5169                        let new_mode = Mode::load(h).toggle();
5170                        h.store(new_mode.as_u8(), std::sync::atomic::Ordering::SeqCst);
5171                        let label = new_mode.label();
5172                        let detail = if new_mode.is_auto() {
5173                            "autonomous — no questions, runs to completion"
5174                        } else {
5175                            "interactive — may ask questions"
5176                        };
5177                        let mut s = state.lock();
5178                        s.autonomy_mode = new_mode;
5179                        s.tip = Some(EphemeralTip {
5180                            text: format!("Mode: {label} — {detail}"),
5181                            born_tick: 0,
5182                            ttl_ticks: 240,
5183                            key: "mode_toggle",
5184                            ambient: false,
5185                        });
5186                    }
5187                    continue;
5188                }
5189                KeyCode::Enter => {
5190                    // Fallback arm (final-review finding 6): reached
5191                    // only when the keymap does NOT bind Submit to
5192                    // this key — the generic dispatch above consumed
5193                    // every Submit-bound keypress (including
5194                    // non-Enter rebinds like `submit: alt+s`). The
5195                    // newline-insert branch stays unconditional:
5196                    // while in multiline mode, plain Enter inserts a
5197                    // real `\n` regardless of how the user has
5198                    // rebound `submit`. Any other Enter is swallowed
5199                    // — submit is disabled at this key.
5200                    let multiline = state.lock().multiline_mode;
5201                    let shift = key
5202                        .modifiers
5203                        .contains(crossterm::event::KeyModifiers::SHIFT);
5204                    if multiline && !shift {
5205                        let mut s = state.lock();
5206                        s.composer.insert_str("\n");
5207                    }
5208                }
5209                KeyCode::Esc => {
5210                    // Esc ladder (grok-build-style):
5211                    // 1. Slash popup open → close popup
5212                    // 2. Input non-empty + 2nd Esc within 800ms → clear buffer
5213                    // 3. Input non-empty + 1st Esc → arm "press again to clear"
5214                    // 4. Empty input → cancel the run (with ~1s post-cancel
5215                    //    grace so mashing Esc doesn't fire repeated cancels)
5216                    let mut s = state.lock();
5217                    if s.shell_mode {
5218                        s.shell_mode = false;
5219                        s.composer.set_text("");
5220                    } else if s.slash_popup.open {
5221                        s.slash_popup = SlashPopup::default();
5222                    } else if !s.composer.is_empty() {
5223                        let now = std::time::Instant::now();
5224                        let is_double = s
5225                            .last_esc_at
5226                            .map(|t| now.duration_since(t).as_millis() < 800)
5227                            .unwrap_or(false);
5228                        if is_double {
5229                            s.composer.set_text("");
5230                            s.last_esc_at = None;
5231                        } else {
5232                            s.last_esc_at = Some(now);
5233                            // Ephemeral hint so the user learns the
5234                            // double-Esc-to-clear gesture.
5235                            s.tip = Some(EphemeralTip {
5236                                text: "Press Esc again to clear input".to_string(),
5237                                born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5238                                ttl_ticks: 120,
5239                                key: "esc_clear",
5240                                ambient: false,
5241                            });
5242                        }
5243                    } else {
5244                        let now = std::time::Instant::now();
5245                        let in_grace = s.cancel_grace_until.map(|t| t > now).unwrap_or(false);
5246                        if in_grace {
5247                            // Swallow — already cancelling.
5248                        } else {
5249                            s.cancel_grace_until = Some(now + std::time::Duration::from_secs(1));
5250                            s.last_esc_at = None;
5251                            drop(s);
5252                            let _ = evt_tx.send(InlineEvent::Cancel);
5253                        }
5254                    }
5255                }
5256                KeyCode::Tab => {
5257                    // Complete the selected slash command into the buffer
5258                    let mut s = state.lock();
5259                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5260                        let name = s.slash_popup.items[s.slash_popup.selected].name.clone();
5261                        s.composer.set_text(&format!("/{} ", name));
5262                        refresh_input_popups(&mut s);
5263                    }
5264                }
5265                KeyCode::Backspace => {
5266                    let mut s = state.lock();
5267                    s.composer.input(crossterm::event::KeyEvent::new(
5268                        KeyCode::Backspace,
5269                        KeyModifiers::NONE,
5270                    ));
5271                    refresh_input_popups(&mut s);
5272                }
5273                KeyCode::Delete => {
5274                    let mut s = state.lock();
5275                    s.composer.input(crossterm::event::KeyEvent::new(
5276                        KeyCode::Delete,
5277                        KeyModifiers::NONE,
5278                    ));
5279                    refresh_input_popups(&mut s);
5280                }
5281                KeyCode::Left => {
5282                    let mut s = state.lock();
5283                    s.composer.input(crossterm::event::KeyEvent::new(
5284                        KeyCode::Left,
5285                        KeyModifiers::NONE,
5286                    ));
5287                }
5288                KeyCode::Right => {
5289                    let mut s = state.lock();
5290                    s.composer.input(crossterm::event::KeyEvent::new(
5291                        KeyCode::Right,
5292                        KeyModifiers::NONE,
5293                    ));
5294                }
5295                KeyCode::Up => {
5296                    let mut s = state.lock();
5297                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5298                        let len = s.slash_popup.items.len();
5299                        s.slash_popup.selected = if s.slash_popup.selected == 0 {
5300                            len - 1
5301                        } else {
5302                            s.slash_popup.selected - 1
5303                        };
5304                    } else if s.queue_panel_open
5305                        && !s.queued_inputs.is_empty()
5306                        && s.composer.is_empty()
5307                    {
5308                        s.queue_selected = if s.queue_selected == 0 {
5309                            s.queued_inputs.len() - 1
5310                        } else {
5311                            s.queue_selected - 1
5312                        };
5313                    } else if s.composer.is_empty() && !s.prompt_history.is_empty() {
5314                        // History recall: fill the prompt with the previous entry.
5315                        let pos = s.history_pos.unwrap_or(0);
5316                        let next = (pos + 1).min(s.prompt_history.len() - 1);
5317                        s.history_pos = Some(next);
5318                        let entry = s.prompt_history[next].clone();
5319                        s.composer.set_text(&entry);
5320                        drop(s);
5321                        let _ = evt_tx.send(InlineEvent::ScrollLineUp);
5322                    }
5323                }
5324                KeyCode::Down => {
5325                    let mut s = state.lock();
5326                    if s.slash_popup.open && !s.slash_popup.items.is_empty() {
5327                        let len = s.slash_popup.items.len();
5328                        s.slash_popup.selected = if s.slash_popup.selected + 1 >= len {
5329                            0
5330                        } else {
5331                            s.slash_popup.selected + 1
5332                        };
5333                    } else if s.queue_panel_open
5334                        && !s.queued_inputs.is_empty()
5335                        && s.composer.is_empty()
5336                    {
5337                        s.queue_selected = if s.queue_selected + 1 >= s.queued_inputs.len() {
5338                            0
5339                        } else {
5340                            s.queue_selected + 1
5341                        };
5342                    } else {
5343                        drop(s);
5344                        let _ = evt_tx.send(InlineEvent::ScrollLineDown);
5345                    }
5346                }
5347                // (PageUp/PageDown scroll dispatch moved to the generic
5348                // keymap pre-match above — final-review finding 6. Keys
5349                // not bound to ScrollUp/ScrollDown fall through to the
5350                // catch-all below and are swallowed.)
5351                KeyCode::Char(ch) => {
5352                    let mut s = state.lock();
5353                    // @! hidden-file toggle: when the picker is open and '!'
5354                    // is typed immediately after '@', toggle hidden mode
5355                    // instead of inserting '!'.
5356                    if s.file_search.is_some()
5357                        && ch == '!'
5358                        && s.composer.text()[..s.composer.cursor()].ends_with('@')
5359                    {
5360                        let cwd = s.cwd.clone();
5361                        if let Some(fs) = s.file_search.as_mut() {
5362                            fs.toggle_hidden(&cwd);
5363                        }
5364                        continue;
5365                    }
5366                    if s.agent_hub_open && ch == 'q' {
5367                        s.agent_hub_open = false;
5368                    } else if s.vim_state.enabled() && !s.slash_popup.open {
5369                        // Route through the vim engine. Deref the guard so
5370                        // we can borrow multiple fields simultaneously.
5371                        let s = &mut *s;
5372                        let vkey =
5373                            crossterm::event::KeyEvent::new(KeyCode::Char(ch), key.modifiers);
5374                        let mut editor = InputEditor::new(&mut s.composer);
5375                        let outcome = crate::tui_vt::vim::handle_key(
5376                            &mut s.vim_state,
5377                            &mut editor,
5378                            &mut s.vim_clipboard,
5379                            &vkey,
5380                        );
5381                        if outcome.handled {
5382                            refresh_input_popups(s);
5383                        }
5384                    } else if s.composer.is_empty() && !s.slash_popup.open {
5385                        // Shell mode: `!` on empty buffer enters bash mode.
5386                        if ch == '!' && !s.shell_mode {
5387                            s.shell_mode = true;
5388                            continue;
5389                        }
5390                        // Queue panel interactive mode takes priority when
5391                        // open and the buffer is empty. Keys that don't
5392                        // match fall through to scrollback nav below.
5393                        if s.queue_panel_open && !s.queued_inputs.is_empty() {
5394                            let idx = s.queue_selected.min(s.queued_inputs.len() - 1);
5395                            match ch {
5396                                'x' | 'X' => {
5397                                    let _ = prompt_queue.remove(idx);
5398                                    s.queued_inputs.remove(idx);
5399                                    if s.queue_selected >= s.queued_inputs.len()
5400                                        && !s.queued_inputs.is_empty()
5401                                    {
5402                                        s.queue_selected = s.queued_inputs.len() - 1;
5403                                    }
5404                                    continue;
5405                                }
5406                                'e' => {
5407                                    if let Some(entry) = prompt_queue.remove(idx) {
5408                                        s.queued_inputs.remove(idx);
5409                                        s.composer.set_text(&entry);
5410                                        s.queue_panel_open = false;
5411                                        continue;
5412                                    }
5413                                }
5414                                'J' => {
5415                                    if prompt_queue.move_by(idx, 1)
5416                                        && idx + 1 < s.queued_inputs.len()
5417                                    {
5418                                        s.queued_inputs.swap(idx, idx + 1);
5419                                        s.queue_selected = idx + 1;
5420                                    }
5421                                    continue;
5422                                }
5423                                'K' => {
5424                                    if idx > 0 && prompt_queue.move_by(idx, -1) {
5425                                        s.queued_inputs.swap(idx, idx - 1);
5426                                        s.queue_selected = idx - 1;
5427                                    }
5428                                    continue;
5429                                }
5430                                _ => {} // fall through to scrollback nav
5431                            }
5432                        }
5433                        // When the prompt is empty, intercept scrollback
5434                        // navigation keys (matching grok-build's scrollback-
5435                        // focus semantics). Any other char falls through to
5436                        // normal insertion so the user can start typing.
5437                        // Printable Help bindings keep their historical
5438                        // empty-composer gate here (typing `?` inside
5439                        // text must insert it); non-printable rebinds
5440                        // dispatch via the generic pre-match above.
5441                        if keymap.matches(GlobalAction::Help, &key) {
5442                            s.overlay = Some(cheatsheet_overlay());
5443                        } else if matches!(ch, 'e') {
5444                            s.cycle_block_at_view();
5445                        } else if matches!(ch, 'E') {
5446                            s.expand_all();
5447                        } else if matches!(ch, 'J') {
5448                            s.jump_next_turn();
5449                        } else if matches!(ch, 'K') {
5450                            s.jump_prev_turn();
5451                        } else if matches!(ch, 'n') && s.search.is_some() {
5452                            s.search_next();
5453                        } else if matches!(ch, 'N') && s.search.is_some() {
5454                            s.search_prev();
5455                        } else {
5456                            s.composer.input(crossterm::event::KeyEvent::new(
5457                                KeyCode::Char(ch),
5458                                key.modifiers,
5459                            ));
5460                            refresh_input_popups(&mut s);
5461                        }
5462                    } else {
5463                        s.composer.input(crossterm::event::KeyEvent::new(
5464                            KeyCode::Char(ch),
5465                            key.modifiers,
5466                        ));
5467                        refresh_input_popups(&mut s);
5468                    }
5469                    // plan_nudge: surface /compact when user mentions "plan".
5470                    if s.tip.is_none() && s.composer.text().to_lowercase().contains("plan") {
5471                        s.show_tip(
5472                            "plan_nudge",
5473                            "Try /compact to summarize and plan ahead",
5474                            180,
5475                            true,
5476                        );
5477                    }
5478                }
5479                _ => {}
5480            }
5481        }
5482    })
5483}
5484
5485/// Resolve a keystroke against the active confirmation modal. `y`/Enter
5486/// confirms — dispatches the bound [`ConfirmationAction`]; `n`/`x`/Esc
5487/// cancels. Always consumes the key while a confirmation is open.
5488fn handle_confirmation_key(
5489    state: &Arc<parking_lot::Mutex<RenderState>>,
5490    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5491    issue_action_tx: &tokio::sync::mpsc::UnboundedSender<
5492        crate::tui_vt::issues_panel::IssueActionRequest,
5493    >,
5494    code: KeyCode,
5495) {
5496    let mut s = state.lock();
5497    let Some(confirm) = s.confirmation.clone() else {
5498        return;
5499    };
5500    match code {
5501        KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
5502            s.confirmation = None;
5503            drop(s);
5504            match confirm.action {
5505                ConfirmationAction::Quit => {
5506                    let _ = evt_tx.send(InlineEvent::Exit);
5507                }
5508                ConfirmationAction::ClearConversation => {
5509                    // Re-dispatch /clear with --yes so it flows through the
5510                    // normal command pipeline (where `session.reset()` is
5511                    // accessible). The sentinel arg bypasses the dialog.
5512                    let _ = evt_tx.send(InlineEvent::Submit("/clear --yes".into()));
5513                }
5514                ConfirmationAction::CloseIssue(id) => {
5515                    let (caller, hash, cwd) = {
5516                        let s = state.lock();
5517                        let hash = s
5518                            .issue_store
5519                            .as_ref()
5520                            .and_then(|store| store.read(id).ok())
5521                            .map(|(_, h)| h);
5522                        (s.ownership_session_id.clone(), hash, s.cwd.clone())
5523                    };
5524                    let _ = cwd; // store is already rooted; kept for clarity/future use
5525                    let _ = issue_action_tx.send(
5526                        crate::tui_vt::issues_panel::IssueActionRequest::Close { id, caller, hash },
5527                    );
5528                    let mut s = state.lock();
5529                    if let Some(panel) = s.issues_panel.as_mut() {
5530                        panel.pending = true;
5531                    }
5532                }
5533                ConfirmationAction::RemoveProviderKey(name) => {
5534                    // Re-dispatch /providers remove <name> --yes so it flows
5535                    // through the normal command pipeline. The sentinel arg
5536                    // bypasses the confirm dialog.
5537                    let _ = evt_tx.send(InlineEvent::Submit(
5538                        format!("/providers remove {name} --yes").into(),
5539                    ));
5540                }
5541            }
5542        }
5543        KeyCode::Char('n')
5544        | KeyCode::Char('N')
5545        | KeyCode::Char('x')
5546        | KeyCode::Char('X')
5547        | KeyCode::Esc => {
5548            s.confirmation = None;
5549        }
5550        _ => {}
5551    }
5552}
5553
5554/// Handle a single keystroke while an overlay is open. Returns `true` if the
5555/// key was consumed (whether it changed state or not). Always returns `false`
5556/// when no overlay is open so the caller can fall through to the regular
5557/// input-thread key dispatch.
5558fn handle_overlay_key(
5559    state: &Arc<parking_lot::Mutex<RenderState>>,
5560    evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5561    code: KeyCode,
5562) -> bool {
5563    use oxicode_vtui::tui::core::{OverlayEvent, OverlaySubmission};
5564
5565    let mut s = state.lock();
5566    let Some(overlay) = s.overlay.as_mut() else {
5567        return false;
5568    };
5569    // Secure (masked) single-line prompt: takes precedence over list
5570    // navigation. Char / Backspace / Left / Right / Enter / Esc route
5571    if let Some(secure) = overlay.secure_input.as_mut() {
5572        use oxicode_textarea::EditCommand;
5573        match code {
5574            KeyCode::Backspace => {
5575                // Delete the grapheme (or atomic element) immediately before
5576                // the cursor. When the cursor sits at the end of the masked
5577                // element, this removes the whole value in one operation.
5578                if secure.editor.cursor_byte() > 0 {
5579                    let _ = secure.editor.apply(EditCommand::DeleteGraphemeBackward);
5580                }
5581            }
5582            KeyCode::Left => {
5583                let _ = secure.editor.apply(EditCommand::MoveGraphemeLeft);
5584            }
5585            KeyCode::Right => {
5586                let _ = secure.editor.apply(EditCommand::MoveGraphemeRight);
5587            }
5588            KeyCode::Enter => {
5589                // Submit the editor's text — this is the only path that
5590                // reaches the real secret value, and it leaves the editor
5591                // intact for any render that follows before the overlay is
5592                // torn down.
5593                let submission = OverlaySubmission::SecureInput(secure.editor.text().to_string());
5594                drop(s);
5595                state.lock().overlay = None;
5596                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(submission)));
5597            }
5598            KeyCode::Esc => {
5599                drop(s);
5600                state.lock().overlay = None;
5601                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5602            }
5603            KeyCode::Char(ch) if ch.is_ascii_graphic() || ch == ' ' => {
5604                // Single-line ASCII filter; the renderer never paints the
5605                // underlying text, so this just keeps the buffer predictable.
5606                let _ = secure.editor.apply(EditCommand::Insert(ch));
5607            }
5608            _ => {} // ignore other keys while the secure prompt is open
5609        }
5610        return true;
5611    }
5612
5613    // Settings map-editor hotkeys. These operate on `RenderState`
5614    // directly (they persist + rebuild the panel), so the overlay
5615    // borrow from the secure branch must end first. Only active with no
5616    // search filter — while filtering, letters keep typing into the
5617    // search box (the helpers re-check that the tabbed panel is open
5618    // and the selected row is a map row).
5619    let search_empty = s
5620        .overlay
5621        .as_ref()
5622        .and_then(|o| o.search.as_ref())
5623        .is_none_or(|search| search.value.is_empty());
5624    let map_row_consumed = match code {
5625        KeyCode::Enter if try_edit_model_role(&mut s) => true,
5626        KeyCode::Char('d') if search_empty && try_remove_settings_map_row(&mut s) => true,
5627        KeyCode::Char('n') if search_empty && try_start_new_model_role(&mut s) => true,
5628        _ => false,
5629    };
5630    if map_row_consumed {
5631        return true;
5632    }
5633    let Some(overlay) = s.overlay.as_mut() else {
5634        return false;
5635    };
5636
5637    match code {
5638        KeyCode::Esc => {
5639            // Cancel the overlay and notify the harness.
5640            drop(s);
5641            state.lock().overlay = None;
5642            let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5643        }
5644        KeyCode::Enter => {
5645            // Submit the currently selected item. If no item is selected
5646            // (empty list), we still close the overlay with a cancel.
5647            let submission = if let Some(item) = overlay.items.get(overlay.selected) {
5648                match item.selection.clone() {
5649                    Some(sel) => sel,
5650                    None => {
5651                        // Read-only / informational item (no InlineListSelection,
5652                        // e.g. /tools, /mcp, the /settings Model row): Enter is
5653                        // a no-op — keep the overlay open so the user can keep
5654                        // browsing (Esc closes). Avoids polluting the prompt
5655                        // with a synthetic "/overlay:N" command.
5656                        return true;
5657                    }
5658                }
5659            } else {
5660                drop(s);
5661                state.lock().overlay = None;
5662                let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Cancelled));
5663                return true;
5664            };
5665            let title = overlay.title.clone();
5666            let selected = overlay.selected;
5667            drop(s);
5668            state.lock().overlay = None;
5669            tracing::debug!(
5670                overlay = %title,
5671                selected,
5672                "overlay submitted"
5673            );
5674            let _ = evt_tx.send(InlineEvent::Overlay(OverlayEvent::Submitted(
5675                OverlaySubmission::Selection(submission),
5676            )));
5677        }
5678        KeyCode::Up => {
5679            let len = overlay_filtered_indices(overlay).len();
5680            if len == 0 {
5681                return true;
5682            }
5683            let pos = overlay_filtered_indices(overlay)
5684                .iter()
5685                .position(|&i| i == overlay.selected)
5686                .unwrap_or(0);
5687            let new_pos = if pos == 0 { len - 1 } else { pos - 1 };
5688            overlay.selected = overlay_filtered_indices(overlay)[new_pos];
5689        }
5690        KeyCode::Down => {
5691            let filtered = overlay_filtered_indices(overlay);
5692            let len = filtered.len();
5693            if len == 0 {
5694                return true;
5695            }
5696            let pos = filtered
5697                .iter()
5698                .position(|&i| i == overlay.selected)
5699                .unwrap_or(0);
5700            let new_pos = if pos + 1 >= len { 0 } else { pos + 1 };
5701            overlay.selected = filtered[new_pos];
5702        }
5703        KeyCode::Backspace => {
5704            if let Some(search) = overlay.search.as_mut() {
5705                search.value.pop();
5706                overlay.selected = 0;
5707            }
5708        }
5709        KeyCode::Char(ch) => {
5710            if let Some(search) = overlay.search.as_mut() {
5711                search.value.push(ch);
5712                overlay.selected = 0;
5713            }
5714        }
5715        KeyCode::Left | KeyCode::Right => {
5716            // Tabbed overlays (the settings panel): ←/→ cycle the tab
5717            // bar, rebuilding items/sections for the new tab. The search
5718            // filter survives the switch.
5719            let tab_count = overlay.tabs.len();
5720            if tab_count > 1 {
5721                let next = if code == KeyCode::Right {
5722                    (overlay.active_tab + 1) % tab_count
5723                } else {
5724                    overlay.active_tab.checked_sub(1).unwrap_or(tab_count - 1)
5725                };
5726                switch_settings_tab(&mut s, next);
5727            }
5728        }
5729        _ => {
5730            // Swallow all other keys while an overlay is open.
5731        }
5732    }
5733    true
5734}
5735
5736/// Return the indices of `overlay.items` that match the current search filter.
5737/// When no search is configured (or the search field is empty), returns every
5738/// index. Used by both the renderer and the input thread so they agree on
5739/// which item is "selected" after navigation or filter changes.
5740fn overlay_filtered_indices(overlay: &OverlayState) -> Vec<usize> {
5741    let needle = overlay
5742        .search
5743        .as_ref()
5744        .map(|s| s.value.to_lowercase())
5745        .unwrap_or_default();
5746    if needle.is_empty() {
5747        return (0..overlay.items.len()).collect();
5748    }
5749    overlay
5750        .items
5751        .iter()
5752        .enumerate()
5753        .filter_map(|(idx, item)| {
5754            let title_hit = item.title.to_lowercase().contains(&needle);
5755            let sv_hit = item
5756                .search_value
5757                .as_deref()
5758                .map(|v| v.to_lowercase().contains(&needle))
5759                .unwrap_or(false);
5760            if title_hit || sv_hit { Some(idx) } else { None }
5761        })
5762        .collect()
5763}
5764
5765/// Handle a single keystroke while the @-file-search dropdown is open.
5766/// Returns `true` if the key was consumed. Up/Down navigate, Tab/Enter
5767/// accept the selection (inserting `@path ` without submitting), Esc
5768/// cancels. Regular chars fall through (`false`) so they enter the buffer
5769/// and trigger `refresh_file_search` to re-filter.
5770fn handle_file_search_key(
5771    state: &Arc<parking_lot::Mutex<RenderState>>,
5772    _evt_tx: &tokio::sync::mpsc::UnboundedSender<InlineEvent>,
5773    code: KeyCode,
5774) -> bool {
5775    match code {
5776        KeyCode::Up => {
5777            let mut s = state.lock();
5778            if let Some(fs) = s.file_search.as_mut() {
5779                fs.up();
5780                true
5781            } else {
5782                false
5783            }
5784        }
5785        KeyCode::Down => {
5786            let mut s = state.lock();
5787            if let Some(fs) = s.file_search.as_mut() {
5788                fs.down();
5789                true
5790            } else {
5791                false
5792            }
5793        }
5794        KeyCode::Tab | KeyCode::Enter => {
5795            let mut s = state.lock();
5796            if s.file_search
5797                .as_ref()
5798                .and_then(|fs| fs.selected_result())
5799                .is_some()
5800            {
5801                accept_file_search(&mut s, false);
5802                true
5803            } else {
5804                // No results: close the picker, let Enter fall through.
5805                s.file_search = None;
5806                false
5807            }
5808        }
5809        KeyCode::Esc => {
5810            let mut s = state.lock();
5811            s.file_search = None;
5812            true
5813        }
5814        _ => false,
5815    }
5816}
5817
5818/// Route one keystroke through the git TUI overlay. Returns `true` when
5819/// the overlay consumed it (so the caller must `continue` and not fall
5820/// through to the composer), `false` when the overlay wasn't open (or
5821/// when commit-mode refused to handle the key — never happens today).
5822///
5823/// Commit-mode text input is handled here too: printable characters
5824/// append to the message, Backspace pops, Enter commits, Esc cancels.
5825fn handle_git_tui_key(
5826    state: &Arc<parking_lot::Mutex<RenderState>>,
5827    code: KeyCode,
5828    modifiers: KeyModifiers,
5829) -> bool {
5830    use crate::tui_vt::git_tui::{GitKeyAction, match_git_key};
5831    use crossterm::event::KeyEvent;
5832
5833    let mut s = state.lock();
5834    if s.git_tui.is_none() {
5835        return false;
5836    }
5837    let cwd = s.cwd.clone();
5838    let Some(git) = s.git_tui.as_mut() else {
5839        unreachable!("checked above");
5840    };
5841    if git.commit_mode {
5842        match code {
5843            KeyCode::Esc => {
5844                git.commit_mode = false;
5845                git.commit_msg.clear();
5846                return true;
5847            }
5848            KeyCode::Enter => {
5849                if let Err(err) = git.commit(&cwd) {
5850                    tracing::warn!(?err, "git commit failed");
5851                    // Surface as a tip so the user sees the reason.
5852                    s.tip = Some(EphemeralTip {
5853                        text: format!("git commit failed: {err}"),
5854                        born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5855                        ttl_ticks: 240,
5856                        key: "git-commit-error",
5857                        ambient: false,
5858                    });
5859                }
5860                return true;
5861            }
5862            KeyCode::Backspace => {
5863                git.commit_backspace();
5864                return true;
5865            }
5866            KeyCode::Char(c) => {
5867                if !modifiers.contains(KeyModifiers::CONTROL)
5868                    && !modifiers.contains(KeyModifiers::ALT)
5869                {
5870                    git.commit_input_char(c);
5871                }
5872                return true;
5873            }
5874            _ => return true, // swallow anything else while in commit mode
5875        }
5876    }
5877
5878    // Map raw key to an overlay action. Unmatched keys are dropped (do
5879    // NOT fall through to the composer per the brief).
5880    let key = KeyEvent::new(code, modifiers);
5881    let Some(action) = match_git_key(&key) else {
5882        return true;
5883    };
5884    if matches!(action, GitKeyAction::Close) {
5885        // Closing clears the overlay entirely.
5886        s.git_tui = None;
5887        return true;
5888    }
5889    if let Err(err) = git.apply_action(&cwd, action) {
5890        s.tip = Some(EphemeralTip {
5891            text: format!("/git: {err}"),
5892            born_tick: FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed),
5893            ttl_ticks: 240,
5894            key: "git-action-error",
5895            ambient: false,
5896        });
5897    }
5898    true
5899}
5900
5901// ───────────────────────────────────────────────────────────────────────
5902// Agent worker thread — owns the agent run loop, forwards events to the
5903// session bus, and accepts new prompts from a tokio channel.
5904// ─────────────────────────────────────────────────────────────────────────
5905
5906fn spawn_agent_worker(
5907    session_swapper: Arc<crate::app::agent_session_handle::SessionSwapper>,
5908    prompt_queue: Arc<PromptQueue>,
5909) {
5910    std::thread::spawn(move || {
5911        let runtime = match tokio::runtime::Builder::new_current_thread()
5912            .enable_all()
5913            .build()
5914        {
5915            Ok(rt) => rt,
5916            Err(err) => {
5917                tracing::error!(?err, "failed to build agent worker runtime");
5918                return;
5919            }
5920        };
5921
5922        runtime.block_on(async move {
5923            let local = tokio::task::LocalSet::new();
5924            local
5925                .run_until(async move {
5926                    loop {
5927                        let prompt = prompt_queue.next().await;
5928                        run_one_prompt(&session_swapper.current(), prompt).await;
5929                    }
5930                })
5931                .await;
5932        });
5933    });
5934}
5935
5936async fn run_one_prompt(session: &crate::app::agent_session::AgentSessionHandle, prompt: String) {
5937    let session_for_forward = session.clone();
5938    let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
5939
5940    // Forwarder thread — runs `forward_event_to_extensions` on each event
5941    // so the AgentSession's subscribers (and therefore the main loop)
5942    // observe it.
5943    let forwarder = std::thread::spawn(move || {
5944        while let Ok(event) = event_rx.recv() {
5945            session_for_forward.forward_event_to_extensions(&event);
5946        }
5947    });
5948
5949    // Reset the stop flag (a previous Ctrl+C may have left it set) and
5950    // mark streaming so the Ctrl+C policy can distinguish "interrupt"
5951    // from "quit". The guard clears the flag on any exit path.
5952    use std::sync::atomic::Ordering;
5953    session.reset_should_stop();
5954    let streaming = session.streaming_flag();
5955    streaming.store(true, Ordering::SeqCst);
5956    let _stream_guard = StreamingGuard(&streaming);
5957
5958    let agent = session.agent_ref();
5959    let local = tokio::task::LocalSet::new();
5960    let result = local
5961        .run_until(agent.run_with_channel(prompt, event_tx))
5962        .await;
5963
5964    // Wait for the forwarder to drain the channel (sender dropped when
5965    // `run_with_channel` returns).
5966    let _ = forwarder.join();
5967    if let Err(err) = result {
5968        tracing::warn!(?err, "agent run failed");
5969    }
5970}
5971
5972// ─────────────────────────────────────────────────────────────────────────
5973// Header / AgentSession construction
5974// ─────────────────────────────────────────────────────────────────────────
5975
5976// ─────────────────────────────────────────────────────────────────────────
5977// Header / AgentSession construction
5978// ─────────────────────────────────────────────────────────────────────────
5979
5980fn build_header_context(
5981    app: &App,
5982    cwd: &std::path::Path,
5983    git_branch: Option<&str>,
5984) -> InlineHeaderContext {
5985    let workspace_name = cwd
5986        .file_name()
5987        .map(|n| n.to_string_lossy().into_owned())
5988        .unwrap_or_else(|| "oxicode".to_string());
5989    let model_id = app.model_id();
5990    let provider = model_id
5991        .split_once('/')
5992        .map(|(p, _)| p.to_string())
5993        .unwrap_or_else(|| "Provider".to_string());
5994    let branch = git_branch.unwrap_or("\u{2014}").to_string();
5995    let mut ctx = InlineHeaderContext::default();
5996    ctx.app_name = "oxicode".to_string();
5997    ctx.provider = provider;
5998    ctx.model = model_id.clone();
5999    ctx.git = format!("git: {workspace_name}@{branch}");
6000    ctx.tools = "Tools: ready".to_string();
6001    ctx.search_tools = Some(InlineHeaderStatusBadge {
6002        text: workspace_name,
6003        tone: InlineHeaderStatusTone::Ready,
6004    });
6005    ctx.persistent_memory = Some(InlineHeaderStatusBadge {
6006        text: branch,
6007        tone: InlineHeaderStatusTone::Ready,
6008    });
6009    ctx.editor_context = Some(model_id);
6010    ctx
6011}
6012
6013/// Construct an `AgentSession` for the TUI using the runtime helpers from
6014/// `agent_session_runtime`. Mirrors the wiring in the legacy `tui/` harness.
6015async fn build_agent_session(app: &App) -> Result<crate::app::agent_session::AgentSession> {
6016    use crate::app::agent_session_runtime::{
6017        CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
6018        create_agent_session_from_services, create_agent_session_services,
6019    };
6020    use crate::store::session::SessionManager;
6021
6022    let cwd: PathBuf = std::env::current_dir().unwrap_or_default();
6023    let hook_runner = Arc::clone(&app.oxicode().ports().hooks);
6024    let services = create_agent_session_services(
6025        CreateAgentSessionServicesOptions::new(cwd.clone()),
6026        Some(hook_runner),
6027    )?;
6028    let services = Arc::new(services);
6029
6030    let model_id = app.model_id();
6031    let tools = app.agent_tools();
6032
6033    let session_manager = SessionManager::create(&cwd.to_string_lossy(), None);
6034
6035    let result = create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
6036        services,
6037        session_manager,
6038        model_id: if model_id.is_empty() {
6039            None
6040        } else {
6041            Some(model_id)
6042        },
6043        thinking_level: None,
6044        scoped_models: Vec::new(),
6045        tool_registry: Some(tools),
6046        oxicode: Some(app.oxicode().clone()),
6047        // TUI runtime: share the App's session state so /steer, /follow_up,
6048        // and Ctrl+C continue to take effect across the session.
6049        session_state: Some(app.session_state().clone()),
6050    })
6051    .await?;
6052
6053    if let Some(msg) = result.model_fallback_message {
6054        tracing::warn!(message = %msg, "agent session model fallback");
6055    }
6056    Ok(result.session)
6057}
6058
6059// ─────────────────────────────────────────────────────────────────────────
6060// Rendering
6061// ─────────────────────────────────────────────────────────────────────────
6062
6063/// Lines for the keyboard shortcuts cheatsheet overlay.
6064fn cheatsheet_lines() -> Vec<String> {
6065    vec![
6066        "".into(),
6067        "  Navigation".into(),
6068        "  j / ↓        Scroll down".into(),
6069        "  k / ↑        Scroll up".into(),
6070        "  J (Shift+j)  Next turn".into(),
6071        "  K (Shift+k)  Previous turn".into(),
6072        "  PgDn / PgUp  Page scroll".into(),
6073        "  g / G        Top / bottom".into(),
6074        "".into(),
6075        "  Blocks".into(),
6076        "  e            Cycle block (collapse/truncate/expand)".into(),
6077        "  E            Expand all blocks".into(),
6078        "  Ctrl+E       Collapse all blocks".into(),
6079        "".into(),
6080        "  Search".into(),
6081        "  /find <q>    Search transcript".into(),
6082        "  n / N        Next / previous match".into(),
6083        "".into(),
6084        "  Commands".into(),
6085        "  /theme       Cycle color theme".into(),
6086        "  /model       Pick a model".into(),
6087        "  /vim         Toggle vim mode".into(),
6088        "  /compact     Compact context".into(),
6089        "  /clear       Clear conversation".into(),
6090        "  Ctrl+C       Cancel run (then y to quit)".into(),
6091        "  Ctrl+Enter   Send now (abort + submit)".into(),
6092        "  Ctrl+M       Toggle multiline input".into(),
6093        "  Shift+Tab    Toggle Auto mode (no questions, runs to end)".into(),
6094        "  Ctrl+;       Toggle queue panel".into(),
6095        "".into(),
6096        "  Special Input".into(),
6097        "  @           File picker (fuzzy search)".into(),
6098        "  @!          Toggle hidden files in picker".into(),
6099        "  !           Shell mode (bash command)".into(),
6100    ]
6101}
6102
6103/// Build the command palette overlay — a searchable list of all slash
6104/// commands plus quick actions. Triggered by Ctrl+P.
6105fn build_command_palette() -> OverlayState {
6106    use oxicode_vtui::tui::core::{InlineListItem, InlineListSelection};
6107
6108    let catalog = SlashRegistry::builtin_commands();
6109    let mut items: Vec<InlineListItem> = catalog
6110        .iter()
6111        .map(|(name, desc, aliases)| {
6112            let title = if aliases.is_empty() {
6113                format!("/{name}")
6114            } else {
6115                format!(
6116                    "/{name} ({})",
6117                    aliases
6118                        .iter()
6119                        .map(|a| format!("/{a}"))
6120                        .collect::<Vec<_>>()
6121                        .join(", ")
6122                )
6123            };
6124            InlineListItem {
6125                title,
6126                subtitle: Some(desc.to_string()),
6127                badge: None,
6128                indent: 0,
6129                selection: Some(InlineListSelection::SlashCommand(name.to_string())),
6130                search_value: Some(format!("{name} {desc}")),
6131            }
6132        })
6133        .collect();
6134    items.sort_by(|a, b| a.title.cmp(&b.title));
6135
6136    OverlayState {
6137        title: "Command Palette".into(),
6138        lines: vec!["Type to filter, Enter to select".into()],
6139        items: items
6140            .into_iter()
6141            .map(|item| OverlayListItem {
6142                title: item.title,
6143                subtitle: item.subtitle,
6144                badge: item.badge,
6145                indent: item.indent,
6146                search_value: item.search_value,
6147                selection: item.selection,
6148            })
6149            .collect(),
6150        selected: 0,
6151        search: Some(OverlaySearchState {
6152            label: "search".into(),
6153            placeholder: Some("filter commands\u{2026}".into()),
6154            value: String::new(),
6155        }),
6156        secure_input: None,
6157        ..Default::default()
6158    }
6159}
6160
6161/// Global frame tick counter for animations (incremented per render).
6162static FRAME_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
6163/// Animation epoch for time-keyed animation frames. Spinner frames key
6164/// on wall-clock time — NOT the draw count — because event bursts
6165/// during streaming drive many draws per interval and made count-keyed
6166/// spinners visibly race.
6167static ANIMATION_T0: std::sync::LazyLock<std::time::Instant> =
6168    std::sync::LazyLock::new(std::time::Instant::now);
6169
6170/// The animation frame index for a spinner with the given frame period
6171/// (milliseconds). Deterministic in wall-clock time: rapid
6172/// back-to-back draws within one period show the same frame.
6173fn animation_frame(period_ms: u64) -> u64 {
6174    ANIMATION_T0.elapsed().as_millis() as u64 / period_ms.max(1)
6175}
6176/// Tracks whether the terminal title currently shows a running state.
6177static TITLE_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
6178/// ASCII spinner frames for the tab title. They remain readable in every font.
6179const TITLE_SPINNER: &[&str] = &["-", "\\", "|", "/"];
6180
6181/// Braille spinner frames for the in-TUI run indicator (the row above
6182/// the composer). Braille is plain Unicode (U+2800 block) — no font or
6183/// emoji caveats — and animates on the frame tick.
6184const RUN_SPINNER: &[&str] = &[
6185    "\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}", "\u{2827}",
6186    "\u{2807}", "\u{280F}",
6187];
6188
6189/// `59s` under a minute, `2m 05s` beyond it — for the run indicator's
6190/// elapsed readout.
6191fn format_elapsed_secs(secs: u64) -> String {
6192    if secs < 60 {
6193        format!("{secs}s")
6194    } else {
6195        format!("{}m {:02}s", secs / 60, secs % 60)
6196    }
6197}
6198
6199/// Linear-interpolate between two RGB colors. `ratio` 0 = base, 1 = target.
6200fn blend_rgb(base: Color, target: Color, ratio: f64) -> Color {
6201    match (base, target) {
6202        (Color::Rgb(br, bg, bb), Color::Rgb(tr, tg, tb)) => {
6203            let r = (br as f64 + (tr as f64 - br as f64) * ratio).round() as u8;
6204            let g = (bg as f64 + (tg as f64 - bg as f64) * ratio).round() as u8;
6205            let b = (bb as f64 + (tb as f64 - bb as f64) * ratio).round() as u8;
6206            Color::Rgb(r, g, b)
6207        }
6208        _ => base,
6209    }
6210}
6211
6212/// Accent rail color for a transcript line kind.
6213fn accent_color_for_kind(kind: InlineMessageKind, styles: &ThemeStyles) -> Color {
6214    match kind {
6215        InlineMessageKind::User => color_from_anstyle(styles.user.get_fg_color()),
6216        InlineMessageKind::Agent => color_from_anstyle(styles.response.get_fg_color()),
6217        InlineMessageKind::Tool => color_from_anstyle(styles.tool.get_fg_color()),
6218        InlineMessageKind::Error => color_from_anstyle(styles.error.get_fg_color()),
6219        InlineMessageKind::Warning => color_from_anstyle(styles.status.get_fg_color()),
6220        InlineMessageKind::Info => color_from_anstyle(styles.info.get_fg_color()),
6221        InlineMessageKind::Policy => color_from_anstyle(styles.mcp.get_fg_color()),
6222        InlineMessageKind::Pty => color_from_anstyle(styles.pty_output.get_fg_color()),
6223    }
6224}
6225
6226/// Compose one frame using the agent view layout (grok-build-style):
6227/// Scrollback (dominant, top) → Prompt → ShortcutsBar (bottom).
6228/// Chrome geometry and the shortcuts bar are rendered by
6229/// [`render_chrome`](crate::tui_vt::frame_layout::render_chrome); the
6230/// transcript and composer are placed into the returned layout rects.
6231fn render_frame(frame: &mut Frame<'_>, state: &RenderState, _handle: &InlineHandle) {
6232    let area = frame.area();
6233    // Paint the theme background across the whole frame first. Without this
6234    // every span renders against the host terminal's transparent default bg,
6235    // so fg-only text can read as invisible when it clashes with that default
6236    // — the user only saw it after drag-selecting (which inverts colors).
6237    let bg = active_styles().background;
6238    frame
6239        .buffer_mut()
6240        .set_style(area, Style::default().bg(color_from_anstyle(Some(bg))));
6241    let layout = super::frame_layout::compute_chrome(area);
6242    let tick = FRAME_TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6243    // Update terminal tab title: spinner while running, plain when idle.
6244    {
6245        let running = state.active_run.is_some() || state.reasoning_stage.is_some();
6246        let was_running = TITLE_RUNNING.swap(running, std::sync::atomic::Ordering::Relaxed);
6247        if running || was_running {
6248            let title = if running {
6249                let spin = TITLE_SPINNER[(animation_frame(120) as usize) % TITLE_SPINNER.len()];
6250                let model = state
6251                    .header_context
6252                    .editor_context
6253                    .as_deref()
6254                    .unwrap_or("oxicode");
6255                format!("{spin} oxicode \u{2014} {model}")
6256            } else {
6257                "oxicode".to_string()
6258            };
6259            use std::io::Write;
6260            let _ = write!(std::io::stderr(), "\x1b]2;{}\x07", title);
6261            let _ = std::io::stderr().flush();
6262        }
6263    }
6264    // `/issue` panel — full-screen modal overlay (design §5). Short-circuits
6265    // the chat render entirely: nothing underneath is drawn while open.
6266    // Higher-priority modals (overlay, confirmation) still stack on top so
6267    // e.g. Ctrl+C-armed quit confirmation remains visible while its gate
6268    // owns input.
6269    if let Some(panel) = &state.issues_panel {
6270        crate::tui_vt::issues_panel::render_issues_panel(frame, area, panel);
6271        if let Some(overlay) = &state.overlay {
6272            render_overlay(frame, area, overlay);
6273        }
6274        if let Some(confirm) = &state.confirmation {
6275            render_confirmation(frame, area, confirm);
6276        }
6277        return;
6278    }
6279    // Git TUI overlay REPLACES the scrollback + composer region when
6280    // open. Skip both so the transcript doesn't bleed through, then
6281    // draw the overlay across the full frame area.
6282    if let Some(git) = &state.git_tui {
6283        crate::tui_vt::git_tui::render::render_overlay_lines(frame, area, git);
6284    } else {
6285        render_transcript(frame, layout.scrollback, state);
6286        let mut pinned_area = layout.scrollback;
6287        if !state.queued_inputs.is_empty() {
6288            let used = render_queue_pane(frame, pinned_area, state);
6289            pinned_area.y = pinned_area.y.saturating_add(used);
6290            pinned_area.height = pinned_area.height.saturating_sub(used);
6291        }
6292        if !state.todo_phases.is_empty() {
6293            if frame.area().height < TODO_COMPACT_ROWS_THRESHOLD {
6294                let line = render_todo_compact_line(&state.todo_phases);
6295                frame.render_widget(
6296                    Paragraph::new(vec![line]),
6297                    Rect {
6298                        height: 1,
6299                        ..pinned_area
6300                    },
6301                );
6302            } else {
6303                let is_matched = build_matched_closure(state.hub.as_ref());
6304                render_todo_pane(
6305                    frame,
6306                    pinned_area,
6307                    &state.todo_phases,
6308                    state.todo_expanded,
6309                    is_matched,
6310                );
6311            }
6312        }
6313        // The row above the composer has one owner per frame. A live run
6314        // (tracker or stage) takes it — the tracker spans turn boundaries.
6315        if state.active_run.is_some() || state.reasoning_stage.is_some() {
6316            render_reasoning_indicator(frame, layout.prompt, state);
6317        } else if state.pending_quit {
6318            render_pending_quit_hint(frame, layout.prompt);
6319        } else if !state.follow_ups.is_empty() {
6320            render_follow_ups(frame, layout.prompt, &state.follow_ups);
6321        } else {
6322            // Ephemeral tip banner above the composer (auto-dismissed by tick TTL).
6323            let occluded = state.overlay.is_some() || state.confirmation.is_some();
6324            if let Some(tip) = &state.tip
6325                && tip_is_visible(tip, tick)
6326                && !(tip.ambient && occluded)
6327            {
6328                render_tip(frame, layout.prompt, &tip.text);
6329            }
6330        }
6331        render_composer(frame, layout.prompt, state);
6332        if state.slash_popup.open {
6333            render_slash_popup(frame, layout.prompt, state);
6334        }
6335        if state.file_search.is_some() {
6336            render_file_search_dropdown(frame, layout.prompt, state);
6337        }
6338    }
6339    if state.agent_hub_open {
6340        render_agent_hub(frame, area, state);
6341    }
6342    if let Some(overlay) = &state.overlay {
6343        render_overlay(frame, area, overlay);
6344    }
6345    if let Some(confirm) = &state.confirmation {
6346        render_confirmation(frame, area, confirm);
6347    }
6348    // (Git TUI overlay is drawn inside the `if let Some(git)` arm
6349    // above; nothing more to paint here.)
6350}
6351/// Render the y/n/x confirmation modal centered on top of everything else.
6352fn render_confirmation(frame: &mut Frame, area: Rect, confirm: &ModalConfirmation) {
6353    let styles = active_styles();
6354    let accent = color_from_anstyle(styles.error.get_fg_color());
6355    let inner_w = confirm
6356        .title
6357        .chars()
6358        .count()
6359        .max(confirm.message.chars().count())
6360        .max(36) as u16;
6361    let width = inner_w + 4;
6362    let height = 5;
6363    let x = area.x + area.width.saturating_sub(width) / 2;
6364    let y = area.y + area.height.saturating_sub(height) / 2;
6365    let popup_area = Rect {
6366        x,
6367        y,
6368        width,
6369        height,
6370    };
6371    let block = Block::default()
6372        .borders(Borders::ALL)
6373        .border_type(BorderType::Rounded)
6374        .title(Span::styled(
6375            format!(" {} ", confirm.title),
6376            Style::default().fg(accent).bold(),
6377        ))
6378        .border_style(Style::default().fg(accent));
6379    let msg = Line::styled(
6380        confirm.message.clone(),
6381        Style::default().fg(color_from_anstyle(Some(styles.foreground))),
6382    );
6383    frame.render_widget(
6384        Paragraph::new(vec![Line::default(), msg]).block(block),
6385        popup_area,
6386    );
6387}
6388
6389/// Render the Agent Hub overlay — a centered panel listing every registered
6390/// agent (kind, name, status). Populated from `RenderState::hub_entries`,
6391/// snapshotted when `/agents` fired. `q` (input thread Char arm) closes it.
6392fn render_agent_hub(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6393    let rows = state.hub_entries.len() as u16;
6394    let height = rows.saturating_add(4).min(area.height.saturating_sub(1));
6395    let width = area.width.clamp(30, 80);
6396    let rect = Rect {
6397        x: area.x + (area.width.saturating_sub(width)) / 2,
6398        y: area.y + (area.height.saturating_sub(height)) / 2,
6399        width,
6400        height,
6401    };
6402    frame.render_widget(Clear, rect);
6403
6404    let title = Line::from(Span::styled(
6405        " Agent Hub ",
6406        Style::default().add_modifier(Modifier::BOLD),
6407    ));
6408    let block = Block::default().borders(Borders::ALL).title(title);
6409
6410    let items: Vec<ListItem<'_>> = if state.hub_entries.is_empty() {
6411        vec![ListItem::new(Line::from(Span::raw(
6412            "No agents registered.",
6413        )))]
6414    } else {
6415        state
6416            .hub_entries
6417            .iter()
6418            .map(|(id, e)| {
6419                ListItem::new(Line::from(vec![
6420                    Span::raw(format!("{:?} ", e.kind)),
6421                    Span::raw(e.display_name.clone()),
6422                    Span::raw(format!("  — {:?} ({})", e.status, id)),
6423                ]))
6424            })
6425            .collect()
6426    };
6427    frame.render_widget(List::new(items).block(block), rect);
6428}
6429
6430/// Render an overlay (Modal / List) as a centered, bordered panel. Modals
6431/// show only their title + descriptive lines; lists also render a search bar
6432/// (when configured) and a scrollable item list with the selected item
6433/// marked by a plain-text cursor.
6434fn render_overlay(frame: &mut Frame<'_>, area: Rect, overlay: &OverlayState) {
6435    let styles = active_styles();
6436    // Secure-input overlays draw a compact frame: title + lines + a single
6437    // masked input box. List overlays take the longer path below.
6438    if let Some(secure) = &overlay.secure_input {
6439        // Reserve the line just below `overlay.lines` for the input box.
6440        let lines_count = overlay.lines.len();
6441        let desired_h = (lines_count as u16).saturating_add(1).saturating_add(2); // input row + borders
6442        let height = desired_h.min(area.height.saturating_sub(2));
6443        let width = area.width.clamp(30, 80);
6444        let rect = Rect {
6445            x: area.x + (area.width.saturating_sub(width)) / 2,
6446            y: area.y + (area.height.saturating_sub(height)) / 2,
6447            width,
6448            height,
6449        };
6450        frame.render_widget(Clear, rect);
6451
6452        let title = Line::from(Span::styled(
6453            format!(" {} ", overlay.title),
6454            Style::default()
6455                .fg(color_from_anstyle(styles.primary.get_fg_color()))
6456                .add_modifier(Modifier::BOLD),
6457        ));
6458        let block = Block::default()
6459            .borders(Borders::ALL)
6460            .border_type(BorderType::Plain)
6461            .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6462            .title(title);
6463        let inner = block.inner(rect);
6464        frame.render_widget(&block, rect);
6465
6466        let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6467
6468        let mut row = inner.top();
6469        for line_text in &overlay.lines {
6470            let row_area = Rect {
6471                x: inner.left(),
6472                y: row,
6473                width: inner.width,
6474                height: 1,
6475            };
6476            let line = Line::from(Span::styled(
6477                line_text.clone(),
6478                Style::default().fg(secondary),
6479            ));
6480            frame.render_widget(Paragraph::new(line), row_area);
6481            row = row.saturating_add(1);
6482        }
6483
6484        // Secure input box — paint either the placeholder (empty buffer) or
6485        // a `TextArea` whose whole buffer is a single masked `TextElement`.
6486        // The real value lives only in `secure.editor.text()`; the element
6487        // `display` (one asterisk per char when `mask_input` is on) is what
6488        // actually reaches the terminal — the editor's text never enters a
6489        // rendered `Line` when `mask_input` is true.
6490        let label = &secure.config.label;
6491        let label_prefix = format!("{label}: ");
6492        let prefix_columns = UnicodeWidthStr::width(label_prefix.as_str()) as u16;
6493        let prefix_area = Rect {
6494            x: inner.left(),
6495            y: row,
6496            width: prefix_columns.min(inner.width),
6497            height: 1,
6498        };
6499        frame.render_widget(
6500            Paragraph::new(Line::from(Span::styled(
6501                label_prefix.clone(),
6502                Style::default().fg(secondary),
6503            ))),
6504            prefix_area,
6505        );
6506        let textarea_area = Rect {
6507            x: inner.left().saturating_add(prefix_columns),
6508            y: row,
6509            width: inner.width.saturating_sub(prefix_columns),
6510            height: 1,
6511        };
6512        let inner_left = textarea_area.left();
6513        let inner_right = textarea_area.right().saturating_sub(1);
6514
6515        let value = secure.editor.text();
6516        if value.is_empty() {
6517            // Empty buffer: dim placeholder + caret at column 0 of the
6518            // body area (matches the pre-port look).
6519            if let Some(placeholder) = secure.config.placeholder.as_deref() {
6520                frame.render_widget(
6521                    Paragraph::new(Line::from(Span::styled(
6522                        placeholder.to_string(),
6523                        Style::default().fg(secondary).dim(),
6524                    ))),
6525                    textarea_area,
6526                );
6527            }
6528            if textarea_area.width > 0 {
6529                frame.set_cursor_position(Position::new(inner_left, row));
6530            }
6531            return;
6532        }
6533
6534        // Build a fresh masked TextArea per render. Re-using the editor's
6535        // exact text avoids per-frame bookkeeping of element ids.
6536        let display_line: Line<'static> = if secure.config.mask_input {
6537            Line::from("*".repeat(value.chars().count()))
6538        } else {
6539            // Unmasked mode: the user has opted in to seeing the secret,
6540            // so the element's `display` is the value itself. The element
6541            // still gives atomic cursor navigation, and the editor still
6542            // owns the source of truth.
6543            Line::from(value.to_string())
6544        };
6545        let mut ta = TextArea::new();
6546        ta.set_text(value);
6547        ta.replace_range_with_element(
6548            0..value.len(),
6549            value,
6550            MASKED_ELEMENT_KIND,
6551            Some(display_line),
6552        );
6553        // `set_cursor` snaps to the nearest element boundary. Since the
6554        // masked element covers the whole buffer, the rendered caret lands
6555        // at 0 or `value.len()` — the two atomic positions for the field.
6556        ta.set_cursor(secure.editor.cursor_byte());
6557        frame.render_widget_ref(&ta, textarea_area);
6558        // `cursor_pos_with_state` returns ABSOLUTE coordinates (it already
6559        // adds `textarea_area.x`/`.y`). Do NOT re-add the area origin.
6560        if let Some((cx, cy)) = ta.cursor_pos_with_state(textarea_area, TextAreaState::default()) {
6561            let caret_x = cx.min(inner_right);
6562            frame.set_cursor_position(Position::new(caret_x, cy));
6563        }
6564        return;
6565    }
6566    // Keep space for the title, contextual content, and a stable key-help
6567    // footer. The item viewport itself scrolls around the active item.
6568    let visible_max = (area.height as usize).saturating_sub(7).max(3);
6569
6570    // Filter items by the search value when search is enabled.
6571    let filtered: Vec<usize> = match &overlay.search {
6572        Some(search) if !search.value.is_empty() => {
6573            let needle = search.value.to_lowercase();
6574            overlay
6575                .items
6576                .iter()
6577                .enumerate()
6578                .filter_map(|(idx, item)| {
6579                    let title_match = item.title.to_lowercase().contains(&needle);
6580                    let sv_match = item
6581                        .search_value
6582                        .as_deref()
6583                        .map(|v| v.to_lowercase().contains(&needle))
6584                        .unwrap_or(false);
6585                    if title_match || sv_match {
6586                        Some(idx)
6587                    } else {
6588                        None
6589                    }
6590                })
6591                .collect()
6592        }
6593        _ => (0..overlay.items.len()).collect(),
6594    };
6595
6596    let has_search = overlay.search.is_some();
6597    let has_tabs = overlay.tabs.len() > 1;
6598    let lines_count = overlay.lines.len();
6599    let items_count = filtered.len().min(visible_max);
6600    let height_inner =
6601        (lines_count + items_count + usize::from(has_search) + usize::from(has_tabs)) as u16;
6602    let desired_h = height_inner.saturating_add(3); // borders + key-help footer
6603    let height = desired_h.min(area.height.saturating_sub(2));
6604    let width = area.width.clamp(30, 80);
6605    let rect = Rect {
6606        x: area.x + (area.width.saturating_sub(width)) / 2,
6607        y: area.y + (area.height.saturating_sub(height)) / 2,
6608        width,
6609        height,
6610    };
6611    frame.render_widget(Clear, rect);
6612
6613    let title = Line::from(Span::styled(
6614        format!(" {} ", overlay.title),
6615        Style::default()
6616            .fg(color_from_anstyle(styles.primary.get_fg_color()))
6617            .add_modifier(Modifier::BOLD),
6618    ));
6619    let block = Block::default()
6620        .borders(Borders::ALL)
6621        .border_type(BorderType::Plain)
6622        .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
6623        .title(title);
6624    let inner = block.inner(rect);
6625    frame.render_widget(&block, rect);
6626
6627    let primary = color_from_anstyle(styles.primary.get_fg_color());
6628    let fg = color_from_anstyle(Some(styles.foreground));
6629    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
6630
6631    // Compute where the selected item is in the filtered list.
6632    let selected_filtered_pos = filtered
6633        .iter()
6634        .position(|&idx| idx == overlay.selected)
6635        .unwrap_or(0);
6636
6637    let mut row = inner.top();
6638
6639    // Tab bar (settings panel): one line of tab names, the active tab
6640    // bold+accent; ←/→ switch tabs.
6641    if has_tabs {
6642        let mut spans: Vec<Span> = Vec::new();
6643        for (i, name) in overlay.tabs.iter().enumerate() {
6644            if i > 0 {
6645                spans.push(Span::raw("  "));
6646            }
6647            let style = if i == overlay.active_tab {
6648                Style::default().fg(primary).add_modifier(Modifier::BOLD)
6649            } else {
6650                Style::default().fg(secondary).add_modifier(Modifier::DIM)
6651            };
6652            spans.push(Span::styled(name.clone(), style));
6653        }
6654        let row_area = Rect {
6655            x: inner.left(),
6656            y: row,
6657            width: inner.width,
6658            height: 1,
6659        };
6660        frame.render_widget(Paragraph::new(Line::from(spans)), row_area);
6661        row = row.saturating_add(1);
6662    }
6663    // Search bar (if present).
6664    if let Some(search) = &overlay.search {
6665        let prompt = format!("{}: {}", search.label, search.value);
6666        let line = Line::from(vec![
6667            Span::styled(
6668                format!("{}: ", search.label),
6669                Style::default().fg(secondary),
6670            ),
6671            Span::styled(
6672                if search.value.is_empty() {
6673                    search
6674                        .placeholder
6675                        .clone()
6676                        .unwrap_or_else(|| "type to filter\u{2026}".to_string())
6677                } else {
6678                    search.value.clone()
6679                },
6680                if search.value.is_empty() {
6681                    Style::default().fg(secondary).add_modifier(Modifier::DIM)
6682                } else {
6683                    Style::default().fg(fg)
6684                },
6685            ),
6686        ]);
6687        let _ = prompt; // suppress unused warning
6688        let row_area = Rect {
6689            x: inner.left(),
6690            y: row,
6691            width: inner.width,
6692            height: 1,
6693        };
6694        frame.render_widget(Paragraph::new(line), row_area);
6695        row = row.saturating_add(1);
6696    }
6697
6698    // Descriptive lines.
6699    for line_text in &overlay.lines {
6700        let row_area = Rect {
6701            x: inner.left(),
6702            y: row,
6703            width: inner.width,
6704            height: 1,
6705        };
6706        let line = Line::from(Span::styled(
6707            line_text.clone(),
6708            Style::default().fg(secondary),
6709        ));
6710        frame.render_widget(Paragraph::new(line), row_area);
6711        row = row.saturating_add(1);
6712    }
6713
6714    // Sidebar split (settings panel): with >= 2 sections and enough
6715    // width, the left column lists section names (active bold+accent)
6716    // and the item list moves to the right column with rows outside the
6717    // active section dimmed. Falls back to the flat list while a filter
6718    // is active (results cross sections) or when narrow.
6719    let searching = overlay.search.as_ref().is_some_and(|s| !s.value.is_empty());
6720    let use_sidebar = overlay.sections.len() >= 2 && inner.width >= 60 && !searching;
6721    let sidebar_w = if use_sidebar {
6722        let longest = overlay
6723            .sections
6724            .iter()
6725            .map(|s| s.chars().count())
6726            .max()
6727            .unwrap_or(0);
6728        (22usize.min(longest) + 4) as u16
6729    } else {
6730        0
6731    };
6732    // The active section tracks the selected item's group, not a stored
6733    // index — selection moves across sections via Up/Down.
6734    let active_section = if use_sidebar {
6735        item_section_idx(overlay, overlay.selected).unwrap_or(overlay.active_section)
6736    } else {
6737        overlay.active_section
6738    };
6739    let list_x = inner.left() + sidebar_w;
6740    let list_w = inner.width.saturating_sub(sidebar_w);
6741    if use_sidebar {
6742        let mut srow = row;
6743        for (i, name) in overlay.sections.iter().enumerate() {
6744            let style = if i == active_section {
6745                Style::default().fg(primary).add_modifier(Modifier::BOLD)
6746            } else {
6747                Style::default().fg(secondary)
6748            };
6749            let marker = if i == active_section { "> " } else { "  " };
6750            let row_area = Rect {
6751                x: inner.left(),
6752                y: srow,
6753                width: sidebar_w,
6754                height: 1,
6755            };
6756            frame.render_widget(
6757                Paragraph::new(Line::from(vec![
6758                    Span::styled(marker, style),
6759                    Span::styled(name.clone(), style),
6760                ])),
6761                row_area,
6762            );
6763            srow = srow.saturating_add(1);
6764        }
6765    }
6766
6767    // Items.
6768    if filtered.is_empty() {
6769        // The key-capture prompt is items-free by design — the prompt
6770        // line above IS the UI; a "(no items)" placeholder would be
6771        // noise.
6772        if overlay.key_capture.is_some() {
6773            return;
6774        }
6775        let row_area = Rect {
6776            x: inner.left(),
6777            y: row,
6778            width: inner.width,
6779            height: 1,
6780        };
6781        let empty_text = if overlay.search.is_some() {
6782            "  (no matches)"
6783        } else {
6784            "  (no items)"
6785        };
6786        frame.render_widget(
6787            Paragraph::new(Line::from(Span::styled(
6788                empty_text,
6789                Style::default().fg(secondary).add_modifier(Modifier::DIM),
6790            ))),
6791            row_area,
6792        );
6793    } else {
6794        let first_visible = selected_filtered_pos
6795            .saturating_sub(visible_max / 2)
6796            .min(filtered.len().saturating_sub(visible_max));
6797        for &item_idx in filtered.iter().skip(first_visible).take(visible_max) {
6798            let item = &overlay.items[item_idx];
6799            let is_selected = item_idx == overlay.selected;
6800            let marker = if is_selected { "> " } else { "  " };
6801            let indent = "  ".repeat(item.indent as usize);
6802            let mut item_style = if is_selected {
6803                Style::default().fg(primary).add_modifier(Modifier::BOLD)
6804            } else {
6805                Style::default().fg(fg)
6806            };
6807            // Rows outside the active section recede while the sidebar
6808            // is up.
6809            if use_sidebar
6810                && item_section_idx(overlay, item_idx).is_some_and(|sec| sec != active_section)
6811            {
6812                item_style = item_style.add_modifier(Modifier::DIM);
6813            }
6814            let mut spans = vec![
6815                Span::styled(marker, item_style),
6816                Span::styled(indent, item_style),
6817                Span::styled(item.title.clone(), item_style),
6818            ];
6819            if let Some(badge) = &item.badge {
6820                spans.push(Span::raw("  "));
6821                spans.push(Span::styled(
6822                    badge.clone(),
6823                    Style::default().fg(secondary).add_modifier(Modifier::DIM),
6824                ));
6825            }
6826            if let Some(subtitle) = &item.subtitle {
6827                spans.push(Span::raw("  "));
6828                spans.push(Span::styled(
6829                    subtitle.clone(),
6830                    Style::default().fg(secondary),
6831                ));
6832            }
6833            let line = Line::from(spans);
6834            let row_area = Rect {
6835                x: list_x,
6836                y: row,
6837                width: list_w,
6838                height: 1,
6839            };
6840            frame.render_widget(Paragraph::new(line), row_area);
6841            row = row.saturating_add(1);
6842        }
6843    }
6844
6845    // A panel should always explain how to leave it and how to commit a
6846    // choice. This avoids hiding essential controls in a separate help view.
6847    if row < inner.bottom() {
6848        let hint = if overlay.items.iter().any(|item| item.selection.is_some()) {
6849            if has_tabs {
6850                "Enter select | Up/Down move | ←/→ tabs | Esc close"
6851            } else {
6852                "Enter select | Up/Down move | Esc close"
6853            }
6854        } else {
6855            "Esc close"
6856        };
6857        frame.render_widget(
6858            Paragraph::new(Line::from(Span::styled(
6859                hint,
6860                Style::default().fg(secondary).add_modifier(Modifier::DIM),
6861            ))),
6862            Rect {
6863                x: inner.left(),
6864                y: inner.bottom().saturating_sub(1),
6865                width: inner.width,
6866                height: 1,
6867            },
6868        );
6869    }
6870}
6871
6872fn render_transcript(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
6873    if state.transcript.is_empty() {
6874        render_welcome(frame, area, state);
6875        return;
6876    }
6877    let styles = active_styles();
6878    let bg_color = color_from_anstyle(Some(styles.background));
6879
6880    // Plain transcript surface (omp-style): no rail column, no speaker
6881    // chrome, no in-app scrollbar — the host terminal's native
6882    // scrollback owns history now. Content spans the full area.
6883    let content_area = Rect {
6884        x: area.x,
6885        y: area.y,
6886        width: area.width,
6887        height: area.height,
6888    };
6889
6890    let display =
6891        build_transcript_display(state, &styles, state.committed_entries, content_area.width);
6892
6893    // Resolve scroll offset into the display list.
6894    let total = display.len();
6895    let raw_start = if state.scroll_offset == usize::MAX {
6896        total.saturating_sub(content_area.height as usize)
6897    } else {
6898        display
6899            .iter()
6900            .position(|d| d.source_index >= state.scroll_offset)
6901            .unwrap_or(total.saturating_sub(1))
6902    };
6903    let start = effective_scroll_offset(raw_start, total, content_area.height as usize);
6904
6905    // Sticky header (grok-build parity): when the viewport top sits inside a
6906    // block's body (not on its head), pin the block's first line at the top
6907    // so the user can tell which block they are scrolling through.
6908    let sticky_first: Option<usize> = display.get(start).and_then(|d| {
6909        let bid = state.transcript.get(d.source_index)?.block_id;
6910        let first_idx = state.transcript.iter().position(|l| l.block_id == bid)?;
6911        (first_idx != d.source_index).then_some(first_idx)
6912    });
6913    let sticky_h: u16 = if sticky_first.is_some() { 1 } else { 0 };
6914    let body_top = content_area.top() + sticky_h;
6915
6916    // Push/fade (grok-build iOS-style 1D): detect the next block boundary
6917    // within the viewport. As it approaches the sticky row, fade the current
6918    // sticky header toward the background — a smooth handoff to the next
6919    // block's header. FADE_ROWS controls the transition width.
6920    const FADE_ROWS: usize = 5;
6921    let sticky_opacity: f64 = if let Some(sidx) = sticky_first {
6922        let sticky_bid = state.transcript[sidx].block_id;
6923        // Walk display from `start` to find the first visual row belonging to
6924        // a different block.
6925        let next_offset = display.iter().skip(start).position(|d| {
6926            state
6927                .transcript
6928                .get(d.source_index)
6929                .map(|l| l.block_id != sticky_bid)
6930                .unwrap_or(false)
6931        });
6932        match next_offset {
6933            Some(off) if off <= FADE_ROWS => off as f64 / FADE_ROWS as f64,
6934            _ => 1.0,
6935        }
6936    } else {
6937        1.0
6938    };
6939
6940    // Sticky header row: head line + faint bg highlight, no rail. Opacity
6941    // fades as the next block pushes in.
6942    if let Some(sidx) = sticky_first {
6943        let tl = &state.transcript[sidx];
6944        let accent_base = accent_color_for_kind(tl.kind, &styles);
6945        let bg_blend = 0.1 * sticky_opacity;
6946        let line =
6947            transcript_line_marked(tl, &styles, false, false, false, true, content_area.width);
6948        let row = Rect {
6949            x: content_area.x,
6950            y: content_area.top(),
6951            width: content_area.width,
6952            height: 1,
6953        };
6954        if bg_blend > 0.01 {
6955            frame.buffer_mut().set_style(
6956                row,
6957                Style::default().bg(blend_rgb(bg_color, accent_base, bg_blend)),
6958            );
6959        }
6960        frame.render_widget(Paragraph::new(line), row);
6961    }
6962    // Pressure-driven allocation ladder (peer parity with omp): when
6963    // there are more visible items than rows in the live region, fold
6964    // older blocks to a glyph row, then a folded card, and finally
6965    // hide them with a banner. The ladder is pure (`allocate_rows`)
6966    // and resolved here once per frame; the render loop below applies
6967    // it per item.
6968    let live_budget = content_area.height.saturating_sub(sticky_h) as usize;
6969    let (alloc_by_block, hidden_count, natural_by_block) =
6970        compute_block_allocations(state, state.committed_entries, live_budget);
6971    // the live region visibly breathes while tools are running.
6972    let pulse = animation_frame(1000).is_multiple_of(2);
6973    // for `… N earlier blocks hidden` whenever any block is hidden.
6974    let banner_row_used = hidden_count > 0;
6975    let banner_y = content_area.bottom().saturating_sub(1);
6976    // Render top-down, wrapping each line into multiple visual rows.
6977    let mut y = body_top;
6978    let width = content_area.width.max(1) as usize;
6979    // Inline image previews: resolve each pending image's transcript row
6980    // to its block and pre-compute the block's visual height (same wrap
6981    // math the commit path uses) so the render loop can anchor a
6982    // placement at the block's top row, sized to the tool box.
6983    // (block_id, image id, block height, fallback-row index)
6984    let image_block_plans: Vec<(usize, u32, u16, usize)> = state
6985        .image_previews
6986        .pending()
6987        .iter()
6988        .filter_map(|p| {
6989            // Resolve the fallback row by its embedded label — the row
6990            // only exists after the append command applied.
6991            let row_index = state
6992                .transcript
6993                .iter()
6994                .position(|l| l.segments.iter().any(|s| s.text.contains(&p.label)))?;
6995            let bid = state.transcript[row_index].block_id;
6996            let mut block_rows: u16 = 0;
6997            for d in &display {
6998                let Some(l) = state.transcript.get(d.source_index) else {
6999                    continue;
7000                };
7001                if l.block_id != bid {
7002                    continue;
7003                }
7004                block_rows = block_rows.saturating_add(match &d.line {
7005                    None => 1,
7006                    Some(line) => {
7007                        let lw = line.width();
7008                        if lw == 0 {
7009                            1
7010                        } else {
7011                            lw.div_ceil(width).max(1) as u16
7012                        }
7013                    }
7014                });
7015            }
7016            (block_rows > 0).then_some((bid, p.id, block_rows, row_index))
7017        })
7018        .collect();
7019    let mut current_block: Option<usize> = None;
7020    let mut skipped_blocks: std::collections::HashSet<usize> = std::collections::HashSet::new();
7021    for d in display.into_iter().skip(start) {
7022        if y >= content_area.bottom() {
7023            break;
7024        }
7025        // Banner reservation: never paint over the reserved banner
7026        // row at the bottom of the live region.
7027        if banner_row_used && y >= banner_y {
7028            break;
7029        }
7030        let d_bid = state.transcript.get(d.source_index).map(|l| l.block_id);
7031        let Some(d_bid) = d_bid else {
7032            continue;
7033        };
7034        // Block transition: pick a ladder policy for the new block.
7035        if current_block != Some(d_bid) {
7036            // Inline image preview: this block is a pending image's tool
7037            // box — record where its top row landed so the post-draw
7038            // step can place the transmitted pixels here. Placement is
7039            // clamped to the visible window.
7040            if y < content_area.bottom()
7041                && let Some((_, pid, prows, row_index)) =
7042                    image_block_plans.iter().find(|(b, _, _, _)| *b == d_bid)
7043            {
7044                let visible_rows = content_area.bottom().saturating_sub(y).max(1);
7045                state.image_previews.record_anchor(
7046                    *pid,
7047                    content_area.x,
7048                    y,
7049                    (*prows).min(visible_rows),
7050                    *row_index,
7051                );
7052            }
7053            current_block = Some(d_bid);
7054            let alloc = alloc_by_block
7055                .get(&d_bid)
7056                .copied()
7057                .unwrap_or(BlockAlloc { rows: 0 });
7058            // The ladder only intervenes when the block is being
7059            // squeezed (alloc.rows < natural). When alloc.rows >=
7060            // natural (roomy), the natural rendering already fits
7061            // — leave the existing wrap logic alone so explicit
7062            // newlines and word-wrap behave the way they always
7063            // did.
7064            let natural = natural_by_block.get(&d_bid).copied().unwrap_or(0);
7065            if alloc.rows < natural {
7066                // Pressure / emergency: ladder overrides the
7067                // natural rendering. Reserve the first row(s) for
7068                // a glyph / folded card; the rest of the block's
7069                // natural items are skipped entirely.
7070                if skipped_blocks.contains(&d_bid) {
7071                    continue;
7072                }
7073                match alloc.rows {
7074                    0 => {
7075                        skipped_blocks.insert(d_bid);
7076                        continue;
7077                    }
7078                    1 => {
7079                        let activity = block_activity(&state.transcript, d_bid);
7080                        render_glyph_row(frame, content_area, y, &activity, &styles, pulse);
7081                        y += 1;
7082                        skipped_blocks.insert(d_bid);
7083                        continue;
7084                    }
7085                    2 => {
7086                        let activity = block_activity(&state.transcript, d_bid);
7087                        y += render_folded_card(frame, content_area, y, &activity, &styles, pulse);
7088                        skipped_blocks.insert(d_bid);
7089                        continue;
7090                    }
7091                    _ => {}
7092                }
7093            }
7094            // Roomy (alloc.rows >= natural): fall through and render
7095            // the natural items — every display item for the block
7096            // gets painted (and ratatui handles wrapping / explicit
7097            // newlines as before).
7098        }
7099        let Some(line) = d.line else {
7100            y += 1;
7101            continue;
7102        };
7103        let text_w = line.width();
7104        let wrapped_h = if text_w == 0 {
7105            1
7106        } else {
7107            text_w.div_ceil(width).max(1) as u16
7108        };
7109        let row = Rect {
7110            x: content_area.x,
7111            y,
7112            width: content_area.width,
7113            height: wrapped_h.min(content_area.bottom().saturating_sub(y)),
7114        };
7115        frame.render_widget(Paragraph::new(line).wrap(Wrap { trim: false }), row);
7116        y += wrapped_h;
7117    }
7118
7119    // Banner: paint the `… N earlier blocks hidden` summary in the
7120    // reserved row at the bottom of the live region (if any block
7121    // was hidden).
7122    if banner_row_used {
7123        render_hidden_banner(frame, content_area, banner_y, hidden_count);
7124    }
7125
7126    let _ = (total, sticky_h);
7127}
7128
7129/// One visible row of the transcript: a rendered line (or a turn
7130/// spacer, `line: None`) plus the transcript entry it belongs to.
7131#[derive(Clone)]
7132struct TranscriptDisplayItem<'a> {
7133    source_index: usize,
7134    /// `None` marks a turn spacer: a blank breathing row.
7135    line: Option<Line<'a>>,
7136}
7137
7138/// Short, present-tense descriptor for a block: the first non-empty
7139/// text in its leading line. Falls back to the block's kind label
7140/// (e.g. "tool", "agent") when nothing is derivable. The ladder
7141/// uses this in the glyph row and folded card so a half-shown
7142/// block still tells the user what it was.
7143fn block_activity(transcript: &[TranscriptLine], block_id: usize) -> String {
7144    let mut activity = String::new();
7145    for line in transcript.iter().filter(|l| l.block_id == block_id) {
7146        for seg in &line.segments {
7147            let text = seg.text.trim();
7148            if !text.is_empty() {
7149                activity.push_str(text);
7150                break;
7151            }
7152        }
7153        if !activity.is_empty() {
7154            break;
7155        }
7156    }
7157    if !activity.is_empty() {
7158        return activity;
7159    }
7160    // Fallback: kind label.
7161    transcript
7162        .iter()
7163        .find(|l| l.block_id == block_id)
7164        .map(|l| kind_label(l.kind))
7165        .unwrap_or_else(|| "block".to_string())
7166}
7167
7168/// Lower-case kind label (e.g. "tool", "agent", "user") used as a
7169/// last-resort activity descriptor.
7170fn kind_label(kind: InlineMessageKind) -> String {
7171    match kind {
7172        InlineMessageKind::Agent => "agent".to_string(),
7173        InlineMessageKind::User => "user".to_string(),
7174        InlineMessageKind::Tool => "tool".to_string(),
7175        InlineMessageKind::Error => "error".to_string(),
7176        InlineMessageKind::Warning => "warning".to_string(),
7177        InlineMessageKind::Info => "info".to_string(),
7178        InlineMessageKind::Policy => "policy".to_string(),
7179        InlineMessageKind::Pty => "pty".to_string(),
7180    }
7181}
7182
7183/// Build per-block natural heights from `visible_items`. The natural
7184/// height is the number of items `visible_items` would surface for
7185/// that block (each `Line` or `Gap` counts as one logical row).
7186/// Blocks with no visible items get height 0.
7187fn block_natural_heights(
7188    transcript: &[TranscriptLine],
7189    mode_for: impl Fn(usize) -> BlockDisplayMode,
7190    from_entry: usize,
7191) -> (Vec<usize>, Vec<usize>) {
7192    let mut block_ids: Vec<usize> = Vec::new();
7193    let mut heights: Vec<usize> = Vec::new();
7194    let mut index_of: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
7195    for item in visible_items(transcript, mode_for) {
7196        let bid = match item {
7197            VisibleItem::Line { source_index, .. } => transcript[source_index].block_id,
7198            VisibleItem::Gap { source_index, .. } => transcript[source_index].block_id,
7199        };
7200        // Frozen rows (already committed to host scrollback) don't
7201        // participate in live-region budgeting — the live region is
7202        // bounded to what's still in the viewport, not what's already
7203        // gone to scrollback.
7204        let live_source = match item {
7205            VisibleItem::Line { source_index, .. } => source_index,
7206            VisibleItem::Gap { source_index, .. } => source_index,
7207        };
7208        if live_source < from_entry {
7209            continue;
7210        }
7211        let idx = *index_of.entry(bid).or_insert_with(|| {
7212            block_ids.push(bid);
7213            heights.push(0);
7214            block_ids.len() - 1
7215        });
7216        heights[idx] += 1;
7217    }
7218    (block_ids, heights)
7219}
7220
7221/// Resolve a per-block allocation map. The ladder applies only to
7222/// blocks without a manual override; manual `Collapsed` /
7223/// `Truncated` modes override the ladder for that block (manual
7224/// wins — the user already chose how this block should fold).
7225///
7226/// Returns `alloc_by_block_id`, `hidden_count`, `natural_by_block_id`.
7227fn compute_block_allocations(
7228    state: &RenderState,
7229    from_entry: usize,
7230    budget: usize,
7231) -> (
7232    std::collections::HashMap<usize, BlockAlloc>,
7233    usize,
7234    std::collections::HashMap<usize, usize>,
7235) {
7236    let (block_ids, heights) =
7237        block_natural_heights(&state.transcript, |bid| state.block_mode(bid), from_entry);
7238    let total_blocks = block_ids.len();
7239    let allocs = allocate_rows(&heights, budget);
7240    let mut by_block: std::collections::HashMap<usize, BlockAlloc> =
7241        std::collections::HashMap::with_capacity(total_blocks);
7242    let mut natural_by_block: std::collections::HashMap<usize, usize> =
7243        std::collections::HashMap::with_capacity(total_blocks);
7244    let mut hidden = 0usize;
7245    for (i, &bid) in block_ids.iter().enumerate() {
7246        // Manual override wins. The ladder ONLY applies to blocks
7247        // without a manual override; a Collapsed block's natural
7248        // item is a single `[+] <line>` (built by
7249        // `transcript_line_marked(folded=true)`), which is exactly
7250        // what we want for the user's "folded" affordance. Truncated
7251        // / Expanded get the ladder output unchanged — those
7252        // policies already control folding, so the ladder has
7253        // nothing to add.
7254        let alloc = match state.block_mode(bid) {
7255            // Collapsed: skip the ladder and route through the
7256            // natural render. Set `rows = natural` so the roomy
7257            // branch in the render loop paints the single `[+]`
7258            // line that `visible_items(Collapsed)` emitted.
7259            BlockDisplayMode::Collapsed => BlockAlloc { rows: heights[i] },
7260            BlockDisplayMode::Truncated | BlockDisplayMode::Expanded => allocs[i],
7261        };
7262        if alloc.rows == 0 {
7263            hidden += 1;
7264        }
7265        natural_by_block.insert(bid, heights[i]);
7266        by_block.insert(bid, alloc);
7267    }
7268    (by_block, hidden, natural_by_block)
7269}
7270
7271/// Truncate `text` so its unicode display width (after the supplied
7272/// prefix) fits inside `width` cells. When the text overflows, an
7273/// ellipsis replaces the trailing chars. Mirrors the rule that
7274/// `clamp_segments_to_width` enforces on rendered rows: never let a
7275/// single row spill past the terminal width.
7276fn clamp_fold_text(text: &str, prefix_w: usize, width: usize, ellipsis: &str) -> String {
7277    let budget = width.saturating_sub(prefix_w);
7278    if budget == 0 || width == 0 {
7279        return String::new();
7280    }
7281    let text_w = text.width();
7282    if text_w <= budget {
7283        return text.to_string();
7284    }
7285    // Leave room for the ellipsis. Walk char-by-char on display
7286    // width; stop one cell before the budget overflows.
7287    let ell_w = ellipsis.width();
7288    let cap = budget.saturating_sub(ell_w);
7289    let mut out = String::new();
7290    let mut used = 0usize;
7291    for ch in text.chars() {
7292        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
7293        if used + w > cap {
7294            break;
7295        }
7296        out.push(ch);
7297        used += w;
7298    }
7299    out.push_str(ellipsis);
7300    out
7301}
7302
7303/// Render a single folded-card row (2-row form: `╭─ <activity>` /
7304/// `╰─ …`) into `frame` at `(x, y)`, honoring `width`. The activity
7305/// string is clamped to fit the live content width — long
7306/// descriptors never break the box-drawing affordance. Returns the
7307/// number of rows consumed (always 2).
7308fn render_folded_card(
7309    frame: &mut Frame<'_>,
7310    area: Rect,
7311    y: u16,
7312    activity: &str,
7313    styles: &ThemeStyles,
7314    pulse: bool,
7315) -> u16 {
7316    let tool_color = styles
7317        .tool
7318        .get_fg_color()
7319        .or_else(|| styles.secondary.get_fg_color())
7320        .or(styles.response.get_fg_color());
7321    let style = Style::default().fg(color_from_anstyle(tool_color));
7322    let pulse_mark = if pulse { " \u{2022}" } else { "" };
7323    // Box head is `╭─ ` (3 cells) plus an optional pulse mark.
7324    // Clamp the activity to the remaining cells so the row never
7325    // wraps onto a third visual row.
7326    let head_prefix_w = "\u{256D}\u{2500} ".width() + pulse_mark.width();
7327    let head_activity = clamp_fold_text(activity, head_prefix_w, area.width as usize, "\u{2026}");
7328    let head = Line::from(vec![Span::styled(
7329        format!("\u{256D}\u{2500} {head_activity}{pulse_mark}"),
7330        style,
7331    )]);
7332    let tail = Line::from(vec![Span::styled("\u{2570}\u{2500} \u{2026}", style)]);
7333    if y < area.bottom() {
7334        let row = Rect {
7335            x: area.x,
7336            y,
7337            width: area.width,
7338            height: 1,
7339        };
7340        frame.render_widget(Paragraph::new(head), row);
7341    }
7342    let y2 = y.saturating_add(1);
7343    if y2 < area.bottom() {
7344        let row = Rect {
7345            x: area.x,
7346            y: y2,
7347            width: area.width,
7348            height: 1,
7349        };
7350        frame.render_widget(Paragraph::new(tail), row);
7351    }
7352    2
7353}
7354
7355/// Render a single glyph row (`▸ <activity>`) into `frame` at `(x,
7356/// y)`, honoring `width`. The activity is clamped to the live
7357/// content width so a long descriptor never wraps. The shared
7358/// wall-clock pulse animates the trailing `•` on a 1-second period
7359/// so the live region breathes.
7360fn render_glyph_row(
7361    frame: &mut Frame<'_>,
7362    area: Rect,
7363    y: u16,
7364    activity: &str,
7365    styles: &ThemeStyles,
7366    pulse: bool,
7367) -> u16 {
7368    let tool_color = styles
7369        .tool
7370        .get_fg_color()
7371        .or_else(|| styles.secondary.get_fg_color())
7372        .or(styles.response.get_fg_color());
7373    let style = Style::default().fg(color_from_anstyle(tool_color));
7374    let pulse_mark = if pulse { " \u{2022}" } else { "" };
7375    // Glyph prefix is `▸ ` (2 cells) plus an optional pulse mark.
7376    let glyph_prefix_w = "\u{25B8} ".width() + pulse_mark.width();
7377    let glyph_activity = clamp_fold_text(activity, glyph_prefix_w, area.width as usize, "\u{2026}");
7378    let line = Line::from(vec![Span::styled(
7379        format!("\u{25B8} {glyph_activity}{pulse_mark}"),
7380        style,
7381    )]);
7382    if y < area.bottom() {
7383        let row = Rect {
7384            x: area.x,
7385            y,
7386            width: area.width,
7387            height: 1,
7388        };
7389        frame.render_widget(Paragraph::new(line), row);
7390    }
7391    1
7392}
7393/// Render a one-row banner `… N earlier blocks hidden` in the dim
7394/// secondary style. The text is clamped to the live content width
7395/// so a very large `N` never overflows the row.
7396fn render_hidden_banner(frame: &mut Frame<'_>, area: Rect, y: u16, hidden: usize) -> u16 {
7397    let style = Style::default().fg(color_from_anstyle(active_styles().secondary.get_fg_color()));
7398    let text = if hidden == 1 {
7399        "\u{2026} 1 earlier block hidden".to_string()
7400    } else {
7401        format!("\u{2026} {hidden} earlier blocks hidden")
7402    };
7403    let clamped = clamp_fold_text(&text, 0, area.width as usize, "\u{2026}");
7404    let line = Line::from(vec![Span::styled(clamped, style)]);
7405    if y < area.bottom() {
7406        let row = Rect {
7407            x: area.x,
7408            y,
7409            width: area.width,
7410            height: 1,
7411        };
7412        frame.render_widget(Paragraph::new(line), row);
7413    }
7414    1
7415}
7416/// and turn rhythm. Entries below `from_entry` (committed to the host
7417/// scrollback) are skipped — they are frozen and must not render in
7418/// the live viewport again.
7419fn build_transcript_display<'a>(
7420    state: &'a RenderState,
7421    styles: &'a ThemeStyles,
7422    from_entry: usize,
7423    width: u16,
7424) -> Vec<TranscriptDisplayItem<'a>> {
7425    let search_set: std::collections::HashSet<usize> = state
7426        .search
7427        .as_ref()
7428        .map(|s| s.matches.iter().copied().collect())
7429        .unwrap_or_default();
7430    let current_match = state
7431        .search
7432        .as_ref()
7433        .and_then(|s| (!s.matches.is_empty()).then(|| s.matches[s.current]));
7434
7435    let mut display = Vec::with_capacity(state.transcript.len());
7436    let dim_style = Style::default()
7437        .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7438        .add_modifier(Modifier::DIM);
7439    let mut prev_block: Option<usize> = None;
7440    let mut prev_kind: Option<InlineMessageKind> = None;
7441    for item in visible_items(&state.transcript, |block_id| state.block_mode(block_id)) {
7442        match item {
7443            VisibleItem::Line {
7444                source_index,
7445                folded,
7446            } => {
7447                if source_index < from_entry {
7448                    continue;
7449                }
7450                let tl = &state.transcript[source_index];
7451                let is_block_start = prev_block != Some(tl.block_id);
7452                // Turn rhythm: breathe before a user block and after one,
7453                // so a request and its response never glue together.
7454                let needs_spacer = is_block_start
7455                    && prev_block.is_some()
7456                    && (tl.kind == InlineMessageKind::User
7457                        || prev_kind == Some(InlineMessageKind::User));
7458                if needs_spacer {
7459                    display.push(TranscriptDisplayItem {
7460                        source_index,
7461                        line: None,
7462                    });
7463                }
7464                let is_match = search_set.contains(&source_index);
7465                let line = transcript_line_marked(
7466                    tl,
7467                    styles,
7468                    folded,
7469                    is_match,
7470                    current_match == Some(source_index),
7471                    is_block_start,
7472                    width,
7473                );
7474                display.push(TranscriptDisplayItem {
7475                    source_index,
7476                    line: Some(line),
7477                });
7478                prev_block = Some(tl.block_id);
7479                prev_kind = Some(tl.kind);
7480            }
7481            VisibleItem::Gap {
7482                source_index,
7483                hidden_lines,
7484            } => {
7485                if source_index < from_entry {
7486                    continue;
7487                }
7488                let gap = Line::styled(format!("  \u{2026} +{hidden_lines} lines"), dim_style);
7489                display.push(TranscriptDisplayItem {
7490                    source_index,
7491                    line: Some(gap),
7492                });
7493            }
7494        }
7495    }
7496    display
7497}
7498/// Decide whether the host scrollback must be wiped and rebuilt after a
7499/// terminal resize. Only width changes invalidate the frozen transcript
7500/// (rows were printed at the original width and cannot re-wrap). A
7501/// height-only resize leaves the printed history intact — the live
7502/// viewport just grows or shrinks beneath it.
7503///
7504/// `prev_w == 0` is the "never measured" sentinel (no frame has been
7505/// drawn at a known width): there is no stale-width scrollback to
7506/// invalidate, so the answer is always false. Without this, the
7507/// 80-column `RenderState::default()` would fire CSI 3J on the first
7508/// draw of any wider terminal and wipe the user's pre-TUI shell
7509/// scrollback on every launch (final-review finding 1).
7510pub(crate) fn should_rebuild_scrollback(
7511    prev_w: u16,
7512    new_w: u16,
7513    _prev_h: u16,
7514    _new_h: u16,
7515) -> bool {
7516    prev_w != 0 && prev_w != new_w
7517}
7518
7519/// Force-flush boundary — commit the entire finalized prefix regardless
7520/// of viewport fit. Used at exit to land every committable row into the
7521/// host scrollback before raw mode is dropped. Returns the number of
7522/// display rows to commit (= `display_len`). `display_len` itself comes
7523/// from the caller (the same `build_transcript_display` output the live
7524/// commit plan uses) so the boundary stays in lockstep with what the
7525/// user has actually been seeing on screen.
7526pub(crate) fn plan_full_flush(display_len: usize) -> usize {
7527    display_len
7528}
7529
7530/// A planned flush of finalized rows into the host terminal's real
7531/// scrollback.
7532struct ScrollbackCommit {
7533    /// Display rows to print above the viewport.
7534    rows: u16,
7535    /// Display items [0, boundary_item) are the committed chunk.
7536    boundary_item: usize,
7537    /// New `committed_entries`: transcript index of the first live entry.
7538    new_committed_entries: usize,
7539}
7540/// Decide which leading display rows to shed into the host scrollback so
7541/// the live region keeps only `keep_rows` (the viewport). The boundary is
7542/// **block-atomic** (never splits a block) and never touches the anchored
7543/// streaming block or anything below it — those lines are still being
7544/// rewritten by `ReplaceLast`.
7545fn scrollback_commit_plan(
7546    display: &[TranscriptDisplayItem<'_>],
7547    transcript: &[TranscriptLine],
7548    width: usize,
7549    keep_rows: usize,
7550    anchor_entry: Option<usize>,
7551) -> Option<ScrollbackCommit> {
7552    let width = width.max(1);
7553    // Cumulative display rows through each item (spacers cost 1 row;
7554    // lines wrap to ceil(width / content width)).
7555    let mut ends: Vec<usize> = Vec::with_capacity(display.len());
7556    let mut y = 0usize;
7557    for d in display {
7558        let h = match &d.line {
7559            None => 1,
7560            Some(line) => {
7561                let w = line.width();
7562                if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7563            }
7564        };
7565        y += h;
7566        ends.push(y);
7567    }
7568    let total_rows = y;
7569    if total_rows <= keep_rows || display.is_empty() {
7570        return None;
7571    }
7572    let limit = total_rows - keep_rows;
7573
7574    // Everything whose last row ends at/below the keep window stays live.
7575    let mut boundary_item = ends.iter().rposition(|&e| e <= limit)? + 1;
7576
7577    // The anchored streaming block (and everything after it) never
7578    // commits: its lines are still being rewritten in place.
7579    if let Some(anchor) = anchor_entry
7580        && let Some(anchor_item) = display.iter().position(|d| d.source_index >= anchor)
7581    {
7582        boundary_item = boundary_item.min(anchor_item);
7583    }
7584
7585    // Block-atomic: shrink until the boundary sits between blocks.
7586    boundary_item = boundary_item.min(display.len().saturating_sub(1));
7587    let bid_of = |i: usize| transcript.get(display[i].source_index).map(|t| t.block_id);
7588    while boundary_item > 0 {
7589        let last = display[boundary_item - 1].source_index;
7590        let next = display[boundary_item].source_index;
7591        let same_block = matches!(
7592            (transcript.get(last), transcript.get(next)),
7593            (Some(a), Some(b)) if a.block_id == b.block_id
7594        );
7595        if !same_block {
7596            break;
7597        }
7598        // Block-atomic by default — but a FINALIZED block taller than
7599        // the viewport can never fit the live region; committing its
7600        // head at a line boundary is the only way it reaches the host
7601        // scrollback (long messages; Claude Code / Ink print the same
7602        // way). The anchor cap above already keeps the streaming block
7603        // out, so anything this split touches is final.
7604        let block_bid = bid_of(boundary_item);
7605        let block_start = (0..boundary_item)
7606            .rev()
7607            .find(|&i| bid_of(i) != block_bid)
7608            .map_or(0, |i| i + 1);
7609        let block_end = (boundary_item..display.len())
7610            .find(|&i| bid_of(i) != block_bid)
7611            .unwrap_or(display.len());
7612        let before_rows = if block_start == 0 {
7613            0
7614        } else {
7615            ends[block_start - 1]
7616        };
7617        if ends[block_end - 1] - before_rows > keep_rows {
7618            break; // oversized: keep the line boundary inside it
7619        }
7620        boundary_item -= 1;
7621    }
7622    if boundary_item == 0 {
7623        return None;
7624    }
7625
7626    // Committed entries run to the first live row: normally the START of
7627    // the next block (a folded block's gap row can point inside its
7628    // block), but an oversized split commits at line granularity.
7629    let first_live = display[boundary_item].source_index;
7630    let last_committed = display[boundary_item - 1].source_index;
7631    let new_committed_entries = if matches!(
7632        (transcript.get(last_committed), transcript.get(first_live)),
7633        (Some(a), Some(b)) if a.block_id == b.block_id
7634    ) {
7635        first_live
7636    } else {
7637        let live_bid = transcript.get(first_live)?.block_id;
7638        transcript.iter().position(|t| t.block_id == live_bid)?
7639    };
7640
7641    let rows = ends[boundary_item - 1].min(u16::MAX as usize) as u16;
7642    Some(ScrollbackCommit {
7643        rows,
7644        boundary_item,
7645        new_committed_entries,
7646    })
7647}
7648
7649/// Render the committed chunk into the `insert_before` buffer. Mirrors
7650/// the viewport's wrapping math so the frozen rows match what the live
7651/// region showed.
7652fn render_committed_chunk(
7653    buf: &mut Buffer,
7654    items: &[TranscriptDisplayItem<'_>],
7655    x: u16,
7656    width: u16,
7657) {
7658    use ratatui::widgets::Widget;
7659    let width = width.max(1);
7660    let mut y = 0u16;
7661    for item in items {
7662        let Some(line) = &item.line else {
7663            y += 1;
7664            continue;
7665        };
7666        let text_w = line.width();
7667        let wrapped_h = if text_w == 0 {
7668            1
7669        } else {
7670            text_w.div_ceil(width as usize).max(1) as u16
7671        };
7672        let area = Rect {
7673            x,
7674            y,
7675            width,
7676            height: wrapped_h,
7677        };
7678        Paragraph::new(line.clone())
7679            .wrap(Wrap { trim: false })
7680            .render(area, buf);
7681        y += wrapped_h;
7682    }
7683}
7684
7685/// Flush finalized transcript rows into the host terminal's real
7686/// scrollback (inline-viewport pattern — peer parity with Claude Code /
7687/// pi). Runs only when the live content overflows the viewport and the
7688/// user is not browsing: streaming buffers must be empty (the anchored
7689/// block is still being rewritten otherwise) and manual scrolling /
7690/// overlays / search pause committing so the live region stays put.
7691/// Committed blocks are frozen — block-mode cycling applies to live
7692/// blocks only (Claude Code behaves the same way).
7693fn commit_scrollback(
7694    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
7695    state: &mut RenderState,
7696    force_all: bool,
7697) {
7698    if state.scroll_offset != usize::MAX
7699        || !state.message_buffer.is_empty()
7700        || !state.thinking_buffer.is_empty()
7701        || state.overlay.is_some()
7702        || state.confirmation.is_some()
7703        || state.agent_hub_open
7704        || state.slash_popup.open
7705        || state.file_search.is_some()
7706        || state.search.is_some()
7707        || state.transcript.is_empty()
7708    {
7709        return;
7710    }
7711    let Ok(size) = terminal.size() else {
7712        return;
7713    };
7714    let area = Rect {
7715        x: 0,
7716        y: 0,
7717        width: size.width,
7718        height: size.height,
7719    };
7720    let keep_rows = super::frame_layout::scrollback_height(area) as usize;
7721    if keep_rows == 0 && !force_all {
7722        return;
7723    }
7724    let styles = active_styles();
7725    let (gutter_x, scrollback_w) = super::frame_layout::scrollback_geometry(area);
7726    let content_w = scrollback_w as usize;
7727    let display =
7728        build_transcript_display(state, &styles, state.committed_entries, content_w as u16);
7729    if force_all {
7730        // On exit: commit the entire committable prefix in one shot.
7731        // Streaming buffers are already empty at this point; unfinalized
7732        // stream content simply stays in the live viewport. The boundary
7733        // comes from `plan_full_flush` (trivial: display length).
7734        let boundary_item = plan_full_flush(display.len()).min(display.len());
7735        if boundary_item == 0 {
7736            return;
7737        }
7738        // Cumulative display rows through `boundary_item` — needed for
7739        // `insert_before`'s height hint.
7740        let mut total_rows = 0usize;
7741        let width = content_w.max(1);
7742        for d in &display[..boundary_item] {
7743            total_rows += match &d.line {
7744                None => 1,
7745                Some(line) => {
7746                    let w = line.width();
7747                    if w == 0 { 1 } else { w.div_ceil(width).max(1) }
7748                }
7749            };
7750        }
7751        let rows = total_rows.min(u16::MAX as usize) as u16;
7752        let chunk = &display[..boundary_item];
7753        let res = terminal.insert_before(rows, |buf| {
7754            render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7755        });
7756        if res.is_ok() {
7757            // After a force-flush, every committed row is in scrollback;
7758            // advance the marker to the end of the transcript so the
7759            // final draw pass doesn't try to re-commit anything.
7760            state.committed_entries = state.transcript.len();
7761        }
7762        return;
7763    }
7764    let Some(plan) = scrollback_commit_plan(
7765        &display,
7766        &state.transcript,
7767        content_w,
7768        keep_rows,
7769        state.stream_anchor,
7770    ) else {
7771        return;
7772    };
7773    let chunk = &display[..plan.boundary_item];
7774    let res = terminal.insert_before(plan.rows, |buf| {
7775        render_committed_chunk(buf, chunk, gutter_x, content_w as u16);
7776    });
7777    if res.is_ok() {
7778        state.committed_entries = plan.new_committed_entries;
7779    }
7780}
7781
7782/// Build a ratatui `Line` from a transcript line, with optional fold marker
7783/// and search-match highlighting.
7784///
7785/// Plain transcript (omp-style): speaker identity is weight and color, not
7786/// chrome. There is no rail column, no speaker label, and no prefix glyph —
7787/// the user's input is the only bold body text, in the primary color, and
7788/// the agent's response reads in the default ink. System severities keep a
7789/// short colored label on the block's first line because severity is data.
7790fn transcript_line_marked<'a>(
7791    line: &'a TranscriptLine,
7792    styles: &'a ThemeStyles,
7793    folded: bool,
7794    is_match: bool,
7795    is_current: bool,
7796    is_block_start: bool,
7797    width: u16,
7798) -> Line<'a> {
7799    let kind_style = match line.kind {
7800        InlineMessageKind::Agent => {
7801            Style::default().fg(color_from_anstyle(styles.response.get_fg_color()))
7802        }
7803        InlineMessageKind::User => {
7804            Style::default().fg(color_from_anstyle(styles.user.get_fg_color()))
7805        }
7806        InlineMessageKind::Tool => {
7807            Style::default().fg(color_from_anstyle(styles.tool.get_fg_color()))
7808        }
7809        InlineMessageKind::Error => {
7810            Style::default().fg(color_from_anstyle(styles.error.get_fg_color()))
7811        }
7812        InlineMessageKind::Warning => {
7813            Style::default().fg(color_from_anstyle(styles.status.get_fg_color()))
7814        }
7815        InlineMessageKind::Info => {
7816            Style::default().fg(color_from_anstyle(styles.info.get_fg_color()))
7817        }
7818        InlineMessageKind::Policy => {
7819            Style::default().fg(color_from_anstyle(styles.mcp.get_fg_color()))
7820        }
7821        InlineMessageKind::Pty => {
7822            Style::default().fg(color_from_anstyle(styles.pty_output.get_fg_color()))
7823        }
7824    };
7825
7826    // Severity labels appear on the block's first line only; folded heads
7827    // always show the marker so a collapsed block stays identifiable.
7828    let severity_label = match line.kind {
7829        InlineMessageKind::Error => Some("error: "),
7830        InlineMessageKind::Warning => Some("warning: "),
7831        InlineMessageKind::Info => Some("info: "),
7832        InlineMessageKind::Policy => Some("policy: "),
7833        _ => None,
7834    };
7835    let mut prefix = String::new();
7836    if folded {
7837        prefix.push_str("[+] ");
7838    }
7839    if let Some(label) = severity_label
7840        && (folded || is_block_start)
7841    {
7842        prefix.push_str(label);
7843    }
7844
7845    // Highlight background for search matches.
7846    let highlight = if is_current {
7847        Some(Style::default().reversed())
7848    } else if is_match {
7849        Some(Style::default().add_modifier(Modifier::UNDERLINED))
7850    } else {
7851        None
7852    };
7853    // Write-path width invariant (omp tui-core-renderer.md §4): every row
7854    // must fit inside the terminal width. Reserve the prefix's display
7855    // width first so the segments never push the row past `width`. The
7856    // prefix is always ASCII (`"[+] "`, `"error: "`, ...) so `.len()` is
7857    // a faithful display-width measure here.
7858    let prefix_w = prefix.len() as u16;
7859    let budget = width.saturating_sub(prefix_w);
7860    let clamped = clamp_segments_to_width(&line.segments, budget);
7861    let mut spans = Vec::with_capacity(clamped.len() + 1);
7862    if !prefix.is_empty() {
7863        spans.push(Span::styled(prefix, kind_style));
7864    }
7865    for segment in &clamped {
7866        let mut style = segment_style(segment, kind_style, styles);
7867        // Weight-led hierarchy: user input is the only bold body text.
7868        if line.kind == InlineMessageKind::User {
7869            style = style.add_modifier(Modifier::BOLD);
7870        }
7871        if let Some(h) = highlight {
7872            style = style.patch(h);
7873        }
7874        spans.push(Span::styled(segment.text.clone(), style));
7875    }
7876    Line::from(spans)
7877}
7878
7879pub(crate) fn segment_style(
7880    segment: &InlineSegment,
7881    fallback: Style,
7882    _styles: &ThemeStyles,
7883) -> Style {
7884    let mut style = fallback;
7885    let inline = segment.style.as_ref();
7886    if let Some(color) = inline.color {
7887        style = style.fg(color_from_anstyle(Some(color)));
7888    }
7889    // No inline color: keep the kind fallback (`fallback`). Overriding
7890    // with a fixed `response` ink made user turns indistinguishable from
7891    // agent output — the kind color is the speaker signal in plain style.
7892    if inline.effects.contains(anstyle::Effects::BOLD) {
7893        style = style.add_modifier(Modifier::BOLD);
7894    }
7895    if inline.effects.contains(anstyle::Effects::ITALIC) {
7896        style = style.add_modifier(Modifier::ITALIC);
7897    }
7898    if inline.effects.contains(anstyle::Effects::UNDERLINE) {
7899        style = style.add_modifier(Modifier::UNDERLINED);
7900    }
7901    if inline.effects.contains(anstyle::Effects::DIMMED) {
7902        style = style.add_modifier(Modifier::DIM);
7903    }
7904    style
7905}
7906fn render_composer(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
7907    let styles = active_styles();
7908    let prefix_style = Style::default()
7909        .fg(color_from_anstyle(styles.primary.get_fg_color()))
7910        .bold();
7911
7912    let prefix = state.prompt_prefix.clone();
7913    let placeholder = state.placeholder.clone();
7914
7915    // Build prefix spans. The prefix lives in a static leading region of the
7916    // composer box; the textarea renders the editable body in the
7917    // remaining area (right of `prefix_w`). The textarea's own
7918    // `cursor_pos_with_state` reports the cursor relative to that area.
7919    // All prefix segments are ASCII-only today (">[auto] ", "[vim] ", "! ");
7920    // using UnicodeWidthStr keeps the math correct if any of them grows a
7921    // wide glyph in the future (e.g. a status emoji in the vim label).
7922    let mut prefix_w: u16 = 0;
7923    let mut line_spans = Vec::new();
7924    if let Some(label) = state.vim_state.status_label() {
7925        let seg = format!("[{label}] ");
7926        prefix_w = prefix_w.saturating_add(seg.width() as u16);
7927        line_spans.push(Span::styled(
7928            seg,
7929            Style::default()
7930                .fg(color_from_anstyle(styles.tool.get_fg_color()))
7931                .add_modifier(Modifier::BOLD),
7932        ));
7933    }
7934    if state.autonomy_mode.is_auto() {
7935        let seg = "[auto] ";
7936        prefix_w = prefix_w.saturating_add(seg.width() as u16);
7937        line_spans.push(Span::styled(
7938            seg,
7939            Style::default()
7940                .fg(Color::Yellow)
7941                .add_modifier(Modifier::BOLD),
7942        ));
7943    }
7944    prefix_w = prefix_w.saturating_add(UnicodeWidthStr::width(prefix.as_str()) as u16);
7945    line_spans.push(Span::styled(prefix, prefix_style));
7946    if state.shell_mode {
7947        let seg = "! ";
7948        prefix_w = prefix_w.saturating_add(seg.width() as u16);
7949        line_spans.push(Span::styled(
7950            seg,
7951            Style::default()
7952                .fg(Color::Yellow)
7953                .add_modifier(Modifier::BOLD),
7954        ));
7955    }
7956
7957    let context_line = composer_context_line(state, area.width);
7958    let used: usize = context_line.spans.iter().map(|s| s.width()).sum();
7959    let mut block = Block::default()
7960        .borders(Borders::ALL)
7961        .border_type(BorderType::Plain)
7962        .border_style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())))
7963        // The top border is useful real estate. It carries the active session
7964        // context instead of spending a full row on a generic "MESSAGE"
7965        // label, while the border still makes the input target unmistakable.
7966        .title(context_line);
7967    if let Some(chip) = composer_brain_chip(state, area.width, used) {
7968        block = block.title(chip);
7969    }
7970
7971    // Place the prefix in a leading line, then render the textarea in the
7972    // remaining width. When the body is empty AND a placeholder is
7973    // configured, render the placeholder as dimmed text (preserving the
7974    // pre-port look) and put the caret at the placeholder start.
7975    let inner = area.inner(Margin::new(1, 1));
7976    let textarea_area = Rect {
7977        x: inner.left().saturating_add(prefix_w),
7978        y: inner.top(),
7979        width: inner.width.saturating_sub(prefix_w),
7980        height: inner.height,
7981    };
7982    if state.composer.is_empty()
7983        && let Some(ph) = placeholder.as_deref()
7984    {
7985        // Prefix + placeholder as a single paragraph (no body).
7986        line_spans.push(Span::styled(
7987            ph.to_string(),
7988            Style::default()
7989                .fg(color_from_anstyle(styles.secondary.get_fg_color()))
7990                .dim(),
7991        ));
7992        let paragraph = Paragraph::new(Line::from(line_spans))
7993            .block(block)
7994            .wrap(Wrap { trim: false });
7995        frame.render_widget(paragraph, area);
7996        if state.input_enabled {
7997            // Caret sits at the start of the placeholder so the user sees
7998            // where typing will land — same behavior as before the port.
7999            frame.set_cursor_position(Position::new(
8000                inner.left().saturating_add(prefix_w),
8001                area.top().saturating_add(1),
8002            ));
8003        }
8004        return;
8005    }
8006    // Paint the prefix in the first `prefix_w` columns of the inner box,
8007    // then the textarea paints the editable body. The textarea reports
8008    // its caret position relative to `textarea_area`; we add the
8009    // area origin at the end.
8010    let prefix_area = Rect {
8011        x: inner.left(),
8012        y: inner.top(),
8013        width: prefix_w,
8014        height: inner.height,
8015    };
8016    // Render the bordered box (with no body content) and the prefix
8017    // spans inside it.
8018    let frame_paragraph = Paragraph::new(Line::from(Vec::<Span>::new()))
8019        .block(block)
8020        .wrap(Wrap { trim: false });
8021    frame.render_widget(frame_paragraph, area);
8022    frame.render_widget(Paragraph::new(Line::from(line_spans)), prefix_area);
8023    frame.render_widget_ref(&state.composer, textarea_area);
8024
8025    if state.input_enabled
8026        && let Some((cx, cy)) = state
8027            .composer
8028            .cursor_pos_with_state(textarea_area, TextAreaState::default())
8029    {
8030        // `cursor_pos_with_state` returns the ABSOLUTE screen position:
8031        // it already adds `area.x` and `area.y` to the cursor's column/row
8032        // inside the area (see oxicode-textarea `cursor_pos_with_state`:
8033        // `Some((area.x + col, area.y + screen_row))`). Do NOT add the
8034        // area origin again — that double-offset pushed the caret off the
8035        // frame (e.g. row 38 on a 24-row terminal).
8036        frame.set_cursor_position(Position::new(cx, cy));
8037    }
8038}
8039
8040/// Compact session facts embedded in the composer's top border.
8041///
8042/// The field order is deliberately task-oriented: model and reasoning first,
8043/// then place/version-control context, then the capacity signal.
8044/// At narrower widths lower-priority facts disappear as complete fields
8045/// rather than being clipped halfway through a path or branch name.
8046fn composer_context_line<'a>(state: &'a RenderState, width: u16) -> Line<'a> {
8047    let styles = active_styles();
8048    let primary = color_from_anstyle(styles.primary.get_fg_color());
8049    let fg = color_from_anstyle(Some(styles.foreground));
8050    let muted = color_from_anstyle(styles.secondary.get_fg_color());
8051    let info = color_from_anstyle(styles.info.get_fg_color());
8052
8053    let model = state
8054        .header_context
8055        .model
8056        .strip_prefix(&format!("{}/", state.header_context.provider))
8057        .unwrap_or(&state.header_context.model);
8058    let workspace = state
8059        .cwd
8060        .file_name()
8061        .map(|name| name.to_string_lossy().into_owned())
8062        .filter(|name| !name.is_empty())
8063        .unwrap_or_else(|| "workspace".to_string());
8064    let branch = state
8065        .header_context
8066        .persistent_memory
8067        .as_ref()
8068        .map(|badge| badge.text.as_str())
8069        .filter(|branch| !branch.is_empty())
8070        .unwrap_or("—");
8071    let context = match state.context_tokens {
8072        Some(used) => {
8073            let percent = used.saturating_mul(100) / state.context_window.max(1);
8074            format!(
8075                "{}/{} {percent}%",
8076                compact_token_count(used),
8077                compact_token_count(state.context_window)
8078            )
8079        }
8080        None => format!("0/{}", compact_token_count(state.context_window)),
8081    };
8082
8083    // (label, value, value style, minimum width). The first surviving field
8084    // renders without a leading separator — there is no app badge. With
8085    // `glyph_set = "nerd"`, labels become Nerd Font icons (never emoji).
8086    use crate::symbols::nerd as icons;
8087    let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8088    let label =
8089        |text: &'static str, icon: &'static str| -> &'static str { if nerd { icon } else { text } };
8090    let mut fields: Vec<(&str, String, Style, u16)> = vec![
8091        (
8092            label("MODEL ", icons::MODEL),
8093            model.to_string(),
8094            Style::default().fg(fg).add_modifier(Modifier::BOLD),
8095            0,
8096        ),
8097        (
8098            label("THINK ", icons::THINK),
8099            state.thinking_level.clone(),
8100            Style::default().fg(info),
8101            58,
8102        ),
8103        (
8104            label("DIR ", icons::DIR),
8105            workspace,
8106            Style::default().fg(fg),
8107            82,
8108        ),
8109        (
8110            label("GIT ", icons::GIT),
8111            branch.to_string(),
8112            Style::default().fg(fg),
8113            104,
8114        ),
8115        (
8116            label("CTX ", icons::CTX),
8117            context,
8118            Style::default().fg(info),
8119            124,
8120        ),
8121    ];
8122    if state.active_run.is_some() || state.reasoning_stage.is_some() {
8123        fields.push((
8124            label("RUN ", icons::RUN),
8125            state
8126                .reasoning_stage
8127                .clone()
8128                .unwrap_or_else(|| "working\u{2026}".to_string()),
8129            Style::default().fg(primary).add_modifier(Modifier::BOLD),
8130            148,
8131        ));
8132    }
8133
8134    let mut spans = Vec::new();
8135    for (i, (label, value, value_style, min_width)) in fields.into_iter().enumerate() {
8136        if width < min_width {
8137            break;
8138        }
8139        if i > 0 {
8140            spans.push(Span::styled(" | ", Style::default().fg(muted)));
8141        }
8142        spans.push(Span::styled(
8143            (*label).to_string(),
8144            Style::default().fg(muted),
8145        ));
8146        spans.push(Span::styled(value, value_style));
8147    }
8148    Line::from(spans)
8149}
8150
8151/// Right-aligned oxibrain health chip rendered as its own border title
8152/// (moved off the removed shortcuts bar): healthy reads info, unreachable
8153/// error, absent when memory is disabled. Nerd mode swaps the prefix for
8154/// the brain glyph.
8155///
8156/// The chip must NOT be space-padded into [`composer_context_line`]: a
8157/// title overwrites the border row for its full width, and the padding
8158/// would erase the `─` rule between the facts and the chip (it did —
8159/// see `brain_chip_does_not_erase_the_border_rule`). A separate
8160/// right-aligned title covers only the chip's own cells.
8161fn composer_brain_chip<'a>(state: &'a RenderState, width: u16, used: usize) -> Option<Line<'a>> {
8162    let styles = active_styles();
8163    let (chip_label, healthy) = state.brain.chip_label()?;
8164    let chip_color = if healthy {
8165        color_from_anstyle(styles.info.get_fg_color())
8166    } else {
8167        color_from_anstyle(styles.error.get_fg_color())
8168    };
8169    let nerd = state.glyph_set == crate::symbols::GlyphSet::Nerd;
8170    let text = if nerd {
8171        let state_word = chip_label.trim_start_matches("brain\u{b7}");
8172        format!("{} {}", crate::symbols::nerd::BRAIN, state_word)
8173    } else {
8174        chip_label.to_string()
8175    };
8176    let chip = format!(" {text} ");
8177    // The border's title row is two cells narrower than the block.
8178    let usable = width.saturating_sub(2) as usize;
8179    (used + chip.width() < usable)
8180        .then(|| Line::from(Span::styled(chip, Style::default().fg(chip_color))).right_aligned())
8181}
8182
8183fn compact_token_count(tokens: usize) -> String {
8184    if tokens >= 1_000 {
8185        let whole = tokens / 1_000;
8186        let decimal = (tokens % 1_000) / 100;
8187        if decimal == 0 {
8188            format!("{whole}K")
8189        } else {
8190            format!("{whole}.{decimal}K")
8191        }
8192    } else {
8193        tokens.to_string()
8194    }
8195}
8196
8197/// Render a compact onboarding card when the transcript is empty.
8198///
8199/// The card answers the three questions a fresh terminal should answer at a
8200/// glance: where am I, which model will answer, and what can I do next.
8201fn render_welcome(frame: &mut Frame<'_>, area: Rect, state: &RenderState) {
8202    let styles = active_styles();
8203    let primary = color_from_anstyle(styles.primary.get_fg_color());
8204    let fg = color_from_anstyle(Some(styles.foreground));
8205    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8206    let workspace = state
8207        .cwd
8208        .file_name()
8209        .map(|name| name.to_string_lossy().into_owned())
8210        .filter(|name| !name.is_empty())
8211        .unwrap_or_else(|| "workspace".to_string());
8212    let lines = vec![
8213        Line::from(Span::styled(
8214            "OXICODE",
8215            Style::default().fg(primary).add_modifier(Modifier::BOLD),
8216        )),
8217        Line::from(Span::styled(
8218            "Terminal coding assistant",
8219            Style::default().fg(secondary).add_modifier(Modifier::DIM),
8220        )),
8221        Line::from(""),
8222        Line::from(vec![
8223            Span::styled("WORKSPACE  ", Style::default().fg(secondary)),
8224            Span::styled(
8225                workspace,
8226                Style::default().fg(fg).add_modifier(Modifier::BOLD),
8227            ),
8228        ]),
8229        Line::from(vec![
8230            Span::styled("MODEL      ", Style::default().fg(secondary)),
8231            Span::styled(
8232                format!(
8233                    "{} / {}",
8234                    state.header_context.provider, state.header_context.model
8235                ),
8236                Style::default().fg(fg),
8237            ),
8238        ]),
8239        Line::from(""),
8240        Line::from(Span::styled(
8241            "Enter  send     /  commands     @  attach a file",
8242            Style::default().fg(fg),
8243        )),
8244        Line::from(Span::styled(
8245            "?  shortcuts     /model  change model     /help  all commands",
8246            Style::default().fg(secondary),
8247        )),
8248    ];
8249    let height = lines.len().min(area.height as usize) as u16;
8250    let card = Rect {
8251        x: area.x,
8252        y: area.y + area.height.saturating_sub(height) / 2,
8253        width: area.width,
8254        height,
8255    };
8256    frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), card);
8257}
8258
8259/// Render a 1-row run indicator just above the composer.
8260///
8261/// While a run is live this row is continuously owned by the indicator:
8262/// turn boundaries clear `reasoning_stage` but the run tracker keeps the
8263/// row up (falling back to `working…`), so it never flickers to the idle
8264/// row mid-loop. The spinner animates on the frame tick and the suffix
8265/// carries progress facts (turn count, tool calls, elapsed time).
8266fn render_reasoning_indicator(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8267    let styles = active_styles();
8268    let indicator_area = Rect {
8269        x: composer_area.x,
8270        y: composer_area.top().saturating_sub(1),
8271        width: composer_area.width,
8272        height: 1,
8273    };
8274    let primary = color_from_anstyle(styles.primary.get_fg_color());
8275    let muted = color_from_anstyle(styles.secondary.get_fg_color());
8276    // 12.5 fps: lively, but keyed on wall-clock so draw-count bursts
8277    // during streaming can't make it race.
8278    let spin = RUN_SPINNER[(animation_frame(80) as usize) % RUN_SPINNER.len()];
8279    let stage = state
8280        .reasoning_stage
8281        .as_deref()
8282        .unwrap_or("working\u{2026}");
8283    let mut spans = vec![
8284        Span::styled(spin, Style::default().fg(primary)),
8285        Span::styled(
8286            " RUNNING",
8287            Style::default().fg(primary).add_modifier(Modifier::BOLD),
8288        ),
8289        Span::styled(" | ", Style::default().fg(muted)),
8290        Span::styled(
8291            stage.to_string(),
8292            Style::default().fg(muted).add_modifier(Modifier::DIM),
8293        ),
8294    ];
8295    if let Some(run) = &state.active_run {
8296        let elapsed = format_elapsed_secs(run.started_at.elapsed().as_secs());
8297        let facts = if run.turn > 0 {
8298            format!(
8299                " \u{b7} turn {} \u{b7} {} tool call{} \u{b7} {elapsed}",
8300                run.turn,
8301                run.tool_calls,
8302                if run.tool_calls == 1 { "" } else { "s" },
8303            )
8304        } else {
8305            format!(" \u{b7} {elapsed}")
8306        };
8307        spans.push(Span::styled(
8308            facts,
8309            Style::default().fg(muted).add_modifier(Modifier::DIM),
8310        ));
8311    }
8312    // Contextual abort hint (Claude Code pattern): shown only while
8313    // a run is live — the static shortcuts bar is gone.
8314    spans.push(Span::styled(
8315        "  Esc abort \u{b7} Ctrl+C quit",
8316        Style::default().fg(muted).add_modifier(Modifier::DIM),
8317    ));
8318    frame.render_widget(Paragraph::new(Line::from(spans)), indicator_area);
8319}
8320
8321/// Pending-quit hint: shown in the row above the composer after the
8322/// first Ctrl+C aborted a stream — the next press opens the quit
8323/// confirmation. Submitting a new prompt cancels it.
8324fn render_pending_quit_hint(frame: &mut Frame<'_>, composer_area: Rect) {
8325    let styles = active_styles();
8326    let hint_area = Rect {
8327        x: composer_area.x,
8328        y: composer_area.top().saturating_sub(1),
8329        width: composer_area.width,
8330        height: 1,
8331    };
8332    let line = Line::from(Span::styled(
8333        "press Ctrl+C again to quit",
8334        Style::default()
8335            .fg(color_from_anstyle(styles.error.get_fg_color()))
8336            .add_modifier(Modifier::BOLD),
8337    ));
8338    frame.render_widget(Paragraph::new(line), hint_area);
8339}
8340
8341/// Render queued input prompts as a compact pane at the top of the scrollback.
8342fn render_queue_pane(frame: &mut Frame<'_>, scrollback: Rect, state: &RenderState) -> u16 {
8343    let styles = active_styles();
8344    let entries = &state.queued_inputs;
8345    let interactive = state.queue_panel_open;
8346    let selected = state.queue_selected.min(entries.len().saturating_sub(1));
8347    let height = if interactive {
8348        entries.len() as u16 + 1
8349    } else {
8350        1
8351    };
8352    let area = Rect {
8353        x: scrollback.x,
8354        y: scrollback.y,
8355        width: scrollback.width,
8356        height,
8357    };
8358    let info = color_from_anstyle(styles.info.get_fg_color());
8359    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8360    let primary = color_from_anstyle(styles.primary.get_fg_color());
8361    if !interactive {
8362        frame.render_widget(
8363            Paragraph::new(Line::from(vec![
8364                Span::styled(
8365                    format!("QUEUED {}", entries.len()),
8366                    Style::default().fg(primary).add_modifier(Modifier::BOLD),
8367                ),
8368                Span::styled(
8369                    " | Ctrl+; manage",
8370                    Style::default().fg(secondary).add_modifier(Modifier::DIM),
8371                ),
8372            ])),
8373            area,
8374        );
8375        return height;
8376    }
8377    let items: Vec<Line<'_>> = entries
8378        .iter()
8379        .enumerate()
8380        .map(|(i, e)| {
8381            let prefix = format!("#{} ", i + 1);
8382            let prefix_style = if i == selected {
8383                Style::default().fg(primary).add_modifier(Modifier::BOLD)
8384            } else {
8385                Style::default().fg(info)
8386            };
8387            let text_style = if i == selected {
8388                Style::default().fg(primary).add_modifier(Modifier::BOLD)
8389            } else {
8390                Style::default().fg(secondary)
8391            };
8392            let marker = if i == selected { "> " } else { "  " };
8393            Line::from(vec![
8394                Span::styled(prefix, prefix_style),
8395                Span::styled(marker, prefix_style),
8396                Span::styled(e.clone(), text_style),
8397            ])
8398        })
8399        .collect();
8400    frame.render_widget(
8401        Paragraph::new(items).block(Block::default().borders(Borders::TOP).border_style(
8402            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8403        )),
8404        area,
8405    );
8406    height
8407}
8408
8409/// Format one todo row: marker + content + status-specific suffix + notes
8410/// marker. Ports omp's `#formatTodoLine` (`interactive-mode.ts:2326-2341`).
8411fn format_todo_line(todo: &TodoItem, matched: bool, styles: &ThemeStyles) -> Line<'static> {
8412    let notes_marker = match todo.notes.as_ref().map(|n| n.len()).unwrap_or(0) {
8413        0 => String::new(),
8414        n => format!(" ·{n}"),
8415    };
8416    let (marker, color, strike, suffix) = match todo.status {
8417        TodoStatus::Completed => ("✓", styles.foreground, true, String::new()),
8418        TodoStatus::InProgress => (
8419            "▸",
8420            styles.primary.get_fg_color().unwrap_or(styles.foreground),
8421            false,
8422            String::new(),
8423        ),
8424        TodoStatus::Abandoned => (
8425            "☐",
8426            styles.error.get_fg_color().unwrap_or(styles.foreground),
8427            true,
8428            String::new(),
8429        ),
8430        TodoStatus::Blocked => {
8431            let reason = todo
8432                .block_reason
8433                .as_deref()
8434                .map(|r| format!(" (blocked: {r})"))
8435                .unwrap_or_else(|| " (blocked)".to_string());
8436            (
8437                "☐",
8438                styles.info.get_fg_color().unwrap_or(styles.foreground),
8439                false,
8440                reason,
8441            )
8442        }
8443        TodoStatus::Pending if matched => (
8444            "☐",
8445            styles.primary.get_fg_color().unwrap_or(styles.foreground),
8446            false,
8447            String::new(),
8448        ),
8449        TodoStatus::Pending => (
8450            "☐",
8451            styles.secondary.get_fg_color().unwrap_or(styles.foreground),
8452            false,
8453            String::new(),
8454        ),
8455    };
8456    let mut text_style = Style::default().fg(color_from_anstyle(Some(color)));
8457    if strike {
8458        text_style = text_style.add_modifier(Modifier::CROSSED_OUT);
8459    }
8460    Line::from(vec![
8461        Span::styled(
8462            format!("{marker} "),
8463            Style::default().fg(color_from_anstyle(Some(color))),
8464        ),
8465        Span::styled(
8466            format!("{}{}{}", todo.content, suffix, notes_marker),
8467            text_style,
8468        ),
8469    ])
8470}
8471
8472const TREE_BRANCH: &str = "├─";
8473const TREE_VERTICAL: &str = "│ ";
8474const TREE_HOOK: &str = "└";
8475const SUBSEQUENT_STAGE_CAP: usize = 4;
8476const ACTIVE_TASK_CAP: usize = 5;
8477
8478/// Index of the first phase with pending/in-progress work; falls back to the
8479/// last phase. Ports omp's `#getActivePhase` (`interactive-mode.ts:2489`).
8480fn active_phase_index(phases: &[&TodoPhase]) -> usize {
8481    phases
8482        .iter()
8483        .position(|p| {
8484            p.tasks
8485                .iter()
8486                .any(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
8487        })
8488        .unwrap_or_else(|| phases.len().saturating_sub(1))
8489}
8490
8491/// Closed = completed or abandoned (the collapsed window hides both).
8492fn closed_count(tasks: &[TodoItem]) -> usize {
8493    tasks
8494        .iter()
8495        .filter(|t| matches!(t.status, TodoStatus::Completed | TodoStatus::Abandoned))
8496        .count()
8497}
8498
8499/// "I. Foundation", "II. Auth", … Reuses `roman_numeral` from `todo.rs`.
8500fn phase_display_name(name: &str, one_based: usize) -> String {
8501    format!(
8502        "{}. {name}",
8503        oxicode_agent::tools::todo::roman_numeral(one_based)
8504    )
8505}
8506
8507/// Render the sticky todo HUD: phase tree + progress spine. Ports omp's
8508/// `#renderTodoList` (`interactive-mode.ts:2529-2643`). Returns rows used so
8509/// callers can reserve the space (mirrors `render_queue_pane`).
8510fn render_todo_pane(
8511    frame: &mut Frame<'_>,
8512    area: Rect,
8513    phases: &[TodoPhase],
8514    expanded: bool,
8515    is_matched: impl Fn(&TodoItem) -> bool,
8516) -> u16 {
8517    let phases: Vec<&TodoPhase> = phases.iter().filter(|p| !p.tasks.is_empty()).collect();
8518    if phases.is_empty() {
8519        return 0;
8520    }
8521    let styles = active_styles();
8522    let multi_phase = phases.len() > 1;
8523    let active_idx = active_phase_index(&phases);
8524
8525    let render_tasks = |phase: &TodoPhase| -> Vec<Line<'static>> {
8526        if expanded {
8527            phase
8528                .tasks
8529                .iter()
8530                .map(|t| format_todo_line(t, is_matched(t), &styles))
8531                .collect()
8532        } else {
8533            let sel = oxicode_agent::tools::todo::select_collapsed_todos(
8534                &phase.tasks,
8535                &is_matched,
8536                ACTIVE_TASK_CAP,
8537            );
8538            let mut lines: Vec<Line<'static>> = sel
8539                .items
8540                .iter()
8541                .map(|t| format_todo_line(t, is_matched(t), &styles))
8542                .collect();
8543            if let Some(summary) = sel.summary {
8544                lines.push(Line::from(Span::styled(
8545                    summary,
8546                    Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8547                )));
8548            }
8549            lines
8550        }
8551    };
8552
8553    let base_idx = if expanded { 0 } else { active_idx };
8554    let phase_slice: &[&TodoPhase] = if expanded {
8555        &phases[base_idx..]
8556    } else {
8557        &phases[base_idx..(base_idx + 1 + SUBSEQUENT_STAGE_CAP).min(phases.len())]
8558    };
8559    let hidden_stages = phases.len() - base_idx - phase_slice.len();
8560
8561    let mut content_lines: Vec<Line<'static>> = Vec::new();
8562    for (i, phase) in phase_slice.iter().enumerate() {
8563        let one_based = base_idx + i + 1;
8564        let is_active = base_idx + i == active_idx;
8565        let done = closed_count(&phase.tasks);
8566        let header_text = if multi_phase {
8567            format!(
8568                "{} · {done}/{}",
8569                phase_display_name(&phase.name, one_based),
8570                phase.tasks.len()
8571            )
8572        } else {
8573            phase.name.clone()
8574        };
8575        let header_style = if is_active {
8576            Style::default()
8577                .fg(color_from_anstyle(styles.primary.get_fg_color()))
8578                .add_modifier(Modifier::BOLD)
8579        } else {
8580            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))
8581        };
8582        content_lines.push(Line::from(Span::styled(header_text, header_style)));
8583        if is_active || expanded {
8584            content_lines.extend(render_tasks(phase));
8585        }
8586    }
8587    if hidden_stages > 0 {
8588        content_lines.push(Line::from(Span::styled(
8589            format!(
8590                "… {hidden_stages} more stage{}",
8591                if hidden_stages == 1 { "" } else { "s" }
8592            ),
8593            Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
8594        )));
8595    }
8596
8597    // Progress spine: `closed / total` across every phase fills the tree path
8598    // (content rows + 1 closing-hook row) in accent, clamped so a partial
8599    // plan lights at least one cell and a closed plan never overfills.
8600    let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8601    let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8602    let path_len = content_lines.len() + 1;
8603    let mut filled = (closed * path_len).checked_div(total).unwrap_or(0);
8604    if closed > 0 {
8605        filled = filled.max(1);
8606    }
8607    if closed < total {
8608        filled = filled.min(path_len.saturating_sub(1));
8609    }
8610
8611    let mut lines: Vec<Line<'static>> = vec![Line::from(Span::styled(
8612        "TODO",
8613        Style::default()
8614            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8615            .add_modifier(Modifier::BOLD),
8616    ))];
8617    for (i, content) in content_lines.into_iter().enumerate() {
8618        let glyph = if i == 0 { TREE_BRANCH } else { TREE_VERTICAL };
8619        let glyph_color = if i < filled {
8620            styles.primary.get_fg_color()
8621        } else {
8622            styles.secondary.get_fg_color()
8623        };
8624        let mut spans = vec![Span::styled(
8625            format!(" {glyph}"),
8626            Style::default().fg(color_from_anstyle(glyph_color)),
8627        )];
8628        spans.extend(content.spans);
8629        lines.push(Line::from(spans));
8630    }
8631    // path_len = content rows + 1 hook row; the hook fills only when every
8632    // cell (including it) is lit, i.e. the whole list is closed.
8633    let hook_color = if filled >= path_len {
8634        styles.primary.get_fg_color()
8635    } else {
8636        styles.secondary.get_fg_color()
8637    };
8638    lines.push(Line::from(Span::styled(
8639        format!(" {TREE_HOOK}"),
8640        Style::default().fg(color_from_anstyle(hook_color)),
8641    )));
8642
8643    let height = lines.len() as u16;
8644    frame.render_widget(
8645        Paragraph::new(lines),
8646        Rect {
8647            x: area.x,
8648            y: area.y,
8649            width: area.width,
8650            height,
8651        },
8652    );
8653    height
8654}
8655
8656const TODO_COMPACT_ROWS_THRESHOLD: u16 = 18;
8657
8658/// First in-progress task, else the first pending task, else `None`. Ports
8659/// omp's `nextActionableTask` (`todo.ts:164-172`).
8660fn next_actionable_task(phases: &[TodoPhase]) -> Option<&TodoItem> {
8661    let mut first_pending = None;
8662    for phase in phases {
8663        for task in &phase.tasks {
8664            if task.status == TodoStatus::InProgress {
8665                return Some(task);
8666            }
8667            if first_pending.is_none() && task.status == TodoStatus::Pending {
8668                first_pending = Some(task);
8669            }
8670        }
8671    }
8672    first_pending
8673}
8674
8675/// Single-line HUD used on short terminals (< 18 rows): "TODO N/M · <task>".
8676/// Ports omp's `renderCompactStatusLine` (`interactive-mode.ts:2645+`).
8677fn render_todo_compact_line(phases: &[TodoPhase]) -> Line<'static> {
8678    let styles = active_styles();
8679    let total: usize = phases.iter().map(|p| p.tasks.len()).sum();
8680    let closed: usize = phases.iter().map(|p| closed_count(&p.tasks)).sum();
8681    let mut spans = vec![Span::styled(
8682        format!("TODO {closed}/{total} "),
8683        Style::default()
8684            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8685            .add_modifier(Modifier::BOLD),
8686    )];
8687    match next_actionable_task(phases) {
8688        Some(task) => spans.extend(format_todo_line(task, false, &styles).spans),
8689        None => spans.push(Span::styled(
8690            "✓ done",
8691            Style::default().fg(color_from_anstyle(Some(styles.foreground))),
8692        )),
8693    }
8694    Line::from(spans)
8695}
8696
8697/// Pull the latest todo phases, auto-reconciling against the hub's *idle*
8698/// sub-agents (a transition Running → Idle is a successful completion) and
8699/// committing the reconciled result back when it changed. Ports omp's
8700/// `#reconcileTodosWithSubagents` (`interactive-mode.ts:2369-2404`).
8701fn refresh_todo_phases(
8702    provider: &std::sync::Arc<dyn TodoStateProvider>,
8703    hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8704) -> Vec<TodoPhase> {
8705    let phases = provider.get_phases();
8706    let Some(hub) = hub else {
8707        return phases;
8708    };
8709    let completed: Vec<String> = hub
8710        .snapshot()
8711        .into_iter()
8712        .filter(|(_, e)| {
8713            e.kind == oxicode_sdk::HubKind::Subagent && e.status == oxicode_sdk::HubStatus::Idle
8714        })
8715        .filter_map(|(_, e)| e.current_task)
8716        .collect();
8717    let (updated, mutated) =
8718        oxicode_agent::tools::todo::reconcile_with_subagents(&phases, &completed);
8719    if mutated {
8720        provider.set_phases_sync(updated.clone());
8721    }
8722    updated
8723}
8724
8725/// Whether every task in the list is closed (`Completed`/`Abandoned`) and at
8726/// least one task exists. A list with zero phases or zero tasks is not
8727/// "settled" — there's nothing meaningful to auto-clear.
8728fn is_todo_list_settled(phases: &[TodoPhase]) -> bool {
8729    let mut seen_task = false;
8730    for phase in phases {
8731        for task in &phase.tasks {
8732            if !matches!(task.status, TodoStatus::Completed | TodoStatus::Abandoned) {
8733                return false;
8734            }
8735            seen_task = true;
8736        }
8737    }
8738    seen_task
8739}
8740
8741/// HUD-only auto-clear: does not touch the underlying `TodoState`, so a
8742/// `/todo` or `todo` tool call after clearing still sees the historical
8743/// phases. `delay_secs < 0` disables clearing entirely. Called every frame
8744/// after `refresh_todo_phases`, so a settled list stays visually cleared.
8745fn sync_todo_clear_timer(state: &mut RenderState, delay_secs: i64) {
8746    if delay_secs < 0 || !is_todo_list_settled(&state.todo_phases) {
8747        state.todo_clear_deadline = None;
8748        return;
8749    }
8750    if delay_secs == 0 {
8751        state.todo_phases.clear();
8752        state.todo_clear_deadline = None;
8753        return;
8754    }
8755    let deadline = state.todo_clear_deadline.get_or_insert_with(|| {
8756        std::time::Instant::now() + std::time::Duration::from_secs(delay_secs as u64)
8757    });
8758    if std::time::Instant::now() >= *deadline {
8759        state.todo_phases.clear();
8760        state.todo_clear_deadline = None;
8761    }
8762}
8763
8764/// Closure that lights a pending todo up (accent) when a *running* sub-agent
8765/// is executing it, matched by normalized content overlap. Ports omp's
8766/// `isMatched` (`interactive-mode.ts:2543`).
8767fn build_matched_closure(
8768    hub: Option<&crate::app::agent_hub_registry::SharedHubRegistry>,
8769) -> impl Fn(&TodoItem) -> bool + '_ {
8770    let active_descs: Vec<String> = hub
8771        .map(|h| {
8772            h.snapshot()
8773                .into_iter()
8774                .filter(|(_, e)| {
8775                    e.kind == oxicode_sdk::HubKind::Subagent
8776                        && e.status == oxicode_sdk::HubStatus::Running
8777                })
8778                .filter_map(|(_, e)| e.current_task)
8779                .collect()
8780        })
8781        .unwrap_or_default();
8782    move |t| {
8783        !active_descs.is_empty()
8784            && oxicode_agent::tools::todo::todo_matches_any_description(&t.content, &active_descs)
8785    }
8786}
8787
8788/// Render follow-up suggestion chips just above the composer.
8789fn render_follow_ups(frame: &mut Frame<'_>, composer_area: Rect, chips: &[String]) {
8790    let styles = active_styles();
8791    let area = Rect {
8792        x: composer_area.x,
8793        y: composer_area.top().saturating_sub(1),
8794        width: composer_area.width,
8795        height: 1,
8796    };
8797    let mut spans = vec![Span::styled(
8798        "Suggestions: ",
8799        Style::default()
8800            .fg(color_from_anstyle(styles.secondary.get_fg_color()))
8801            .add_modifier(Modifier::DIM),
8802    )];
8803    for (i, chip) in chips.iter().enumerate() {
8804        if i > 0 {
8805            spans.push(Span::raw("  "));
8806        }
8807        spans.push(Span::styled(
8808            format!("[{}]", chip),
8809            Style::default().fg(color_from_anstyle(styles.primary.get_fg_color())),
8810        ));
8811    }
8812    frame.render_widget(Paragraph::new(Line::from(spans)), area);
8813}
8814
8815/// Whether an ephemeral tip is still within its visible TTL window.
8816fn tip_is_visible(tip: &EphemeralTip, now_tick: u64) -> bool {
8817    now_tick.saturating_sub(tip.born_tick) < tip.ttl_ticks
8818}
8819
8820/// Render the ephemeral tip banner one row above the composer.
8821fn render_tip(frame: &mut Frame, composer_area: Rect, text: &str) {
8822    let styles = active_styles();
8823    let area = Rect {
8824        x: composer_area.x,
8825        y: composer_area.top().saturating_sub(1),
8826        width: composer_area.width,
8827        height: 1,
8828    };
8829    let line = Line::styled(
8830        format!(" note: {text}"),
8831        Style::default()
8832            .fg(color_from_anstyle(styles.info.get_fg_color()))
8833            .add_modifier(Modifier::DIM),
8834    );
8835    frame.render_widget(Paragraph::new(line), area);
8836}
8837
8838/// Render the slash-command autocomplete popup as a floating panel above the
8839/// composer. Anchored to the composer's left edge, grows upward.
8840fn render_slash_popup(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8841    let styles = active_styles();
8842    let items = &state.slash_popup.items;
8843    if items.is_empty() {
8844        return;
8845    }
8846
8847    let max_visible = 7usize;
8848    let visible = items.len().min(max_visible);
8849    let popup_h = visible as u16 + 3; // borders + persistent key-help row
8850    let width = composer_area.width.min(64);
8851    let popup_area = Rect {
8852        x: composer_area.left(),
8853        y: composer_area.top().saturating_sub(popup_h),
8854        width,
8855        height: popup_h,
8856    };
8857    frame.render_widget(Clear, popup_area);
8858
8859    let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8860    let title = Line::from(Span::styled(
8861        " COMMANDS ",
8862        Style::default()
8863            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8864            .add_modifier(Modifier::BOLD),
8865    ));
8866    let block = Block::default()
8867        .borders(Borders::ALL)
8868        .border_type(BorderType::Plain)
8869        .border_style(Style::default().fg(border_color))
8870        .title(title);
8871    let inner = block.inner(popup_area);
8872    frame.render_widget(&block, popup_area);
8873
8874    // Column-align labels by padding to the widest visible label.
8875    let max_label = items
8876        .iter()
8877        .take(visible)
8878        .map(|i| i.label.chars().count())
8879        .max()
8880        .unwrap_or(0);
8881
8882    let primary = color_from_anstyle(styles.primary.get_fg_color());
8883    let fg = color_from_anstyle(Some(styles.foreground));
8884    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8885
8886    for (i, item) in items.iter().take(visible).enumerate() {
8887        let is_selected = i == state.slash_popup.selected;
8888        let y = inner.top() + i as u16;
8889        let row_area = Rect {
8890            x: inner.left(),
8891            y,
8892            width: inner.width,
8893            height: 1,
8894        };
8895
8896        let marker = if is_selected { "> " } else { "  " };
8897        let label_style = if is_selected {
8898            Style::default().fg(primary).add_modifier(Modifier::BOLD)
8899        } else {
8900            Style::default().fg(fg)
8901        };
8902        let label_padded = format!("{:<width$}", item.label, width = max_label);
8903        let line = Line::from(vec![
8904            Span::styled(marker, label_style),
8905            Span::styled(label_padded, label_style),
8906            Span::raw("  "),
8907            Span::styled(&item.description, Style::default().fg(secondary)),
8908        ]);
8909        frame.render_widget(Paragraph::new(line), row_area);
8910    }
8911    frame.render_widget(
8912        Paragraph::new(Line::from(Span::styled(
8913            "Enter insert | Up/Down move | Esc close",
8914            Style::default().fg(secondary).add_modifier(Modifier::DIM),
8915        ))),
8916        Rect {
8917            x: inner.left(),
8918            y: inner.bottom().saturating_sub(1),
8919            width: inner.width,
8920            height: 1,
8921        },
8922    );
8923}
8924
8925/// Render the @-file-search dropdown as a floating panel above the
8926/// composer, mirroring `render_slash_popup`'s geometry. Shows up to 10
8927/// fuzzy-matched file paths with the selected one highlighted.
8928fn render_file_search_dropdown(frame: &mut Frame<'_>, composer_area: Rect, state: &RenderState) {
8929    let styles = active_styles();
8930    let Some(fs) = &state.file_search else {
8931        return;
8932    };
8933    let items = &fs.results;
8934    if items.is_empty() {
8935        return;
8936    }
8937
8938    let max_visible = 10usize;
8939    let visible = items.len().min(max_visible);
8940    let popup_h = visible as u16 + 3; // borders + persistent key-help row
8941    let width = composer_area.width.min(72);
8942    let popup_area = Rect {
8943        x: composer_area.left(),
8944        y: composer_area.top().saturating_sub(popup_h),
8945        width,
8946        height: popup_h,
8947    };
8948    frame.render_widget(Clear, popup_area);
8949
8950    let border_color = color_from_anstyle(styles.secondary.get_fg_color());
8951    let title_str = if fs.hidden_mode {
8952        " FILES: HIDDEN "
8953    } else {
8954        " FILES "
8955    };
8956    let title = Line::from(Span::styled(
8957        title_str,
8958        Style::default()
8959            .fg(color_from_anstyle(styles.primary.get_fg_color()))
8960            .add_modifier(Modifier::BOLD),
8961    ));
8962    let block = Block::default()
8963        .borders(Borders::ALL)
8964        .border_type(BorderType::Plain)
8965        .border_style(Style::default().fg(border_color))
8966        .title(title);
8967    let inner = block.inner(popup_area);
8968    frame.render_widget(&block, popup_area);
8969
8970    let primary = color_from_anstyle(styles.primary.get_fg_color());
8971    let fg = color_from_anstyle(Some(styles.foreground));
8972    let secondary = color_from_anstyle(styles.secondary.get_fg_color());
8973
8974    for (i, result) in items.iter().take(visible).enumerate() {
8975        let is_selected = i == fs.selected;
8976        let y = inner.top() + i as u16;
8977        let row_area = Rect {
8978            x: inner.left(),
8979            y,
8980            width: inner.width,
8981            height: 1,
8982        };
8983
8984        let marker = if is_selected { "> " } else { "  " };
8985        let path_style = if is_selected {
8986            Style::default().fg(primary).add_modifier(Modifier::BOLD)
8987        } else {
8988            Style::default().fg(fg)
8989        };
8990        let line = Line::from(vec![
8991            Span::styled(marker, path_style),
8992            Span::styled(&result.path, path_style),
8993        ]);
8994        frame.render_widget(Paragraph::new(line), row_area);
8995    }
8996
8997    // Footer hint: show result count + key bindings.
8998    if popup_h >= 4 {
8999        let hint_y = inner.bottom().saturating_sub(1);
9000        let hint_area = Rect {
9001            x: inner.left(),
9002            y: hint_y,
9003            width: inner.width,
9004            height: 1,
9005        };
9006        let count = items.len();
9007        let hint = format!("{count} files | Tab accept | Esc cancel");
9008        let _ = secondary; // suppress unused warning
9009        frame.render_widget(
9010            Paragraph::new(Line::from(Span::styled(
9011                hint,
9012                Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color())),
9013            )))
9014            .style(Style::default().fg(color_from_anstyle(styles.secondary.get_fg_color()))),
9015            hint_area,
9016        );
9017    }
9018}
9019
9020// ─────────────────────────────────────────────────────────────────────────
9021// Vim mode — Editor adapter for the input buffer
9022// ─────────────────────────────────────────────────────────────────────────
9023
9024/// Adapter that lets the vim engine operate on the composer's [`TextArea`].
9025///
9026/// The host TUI keeps a single [`TextArea`](oxicode_textarea::TextArea)
9027/// (the composer) as the source of truth for editable text; the vim engine
9028/// still wants a `&str` + byte-cursor handle. This adapter forwards each
9029/// trait call to the textarea so cursor math, grapheme boundaries, and
9030/// undo history are owned by the textarea.
9031struct InputEditor<'a> {
9032    composer: &'a mut oxicode_textarea::TextArea,
9033}
9034
9035impl<'a> InputEditor<'a> {
9036    fn new(composer: &'a mut oxicode_textarea::TextArea) -> Self {
9037        Self { composer }
9038    }
9039}
9040
9041impl<'a> crate::tui_vt::vim::Editor for InputEditor<'a> {
9042    fn content(&self) -> &str {
9043        self.composer.text()
9044    }
9045    fn cursor(&self) -> usize {
9046        self.composer.cursor()
9047    }
9048    fn set_cursor(&mut self, pos: usize) {
9049        self.composer.set_cursor(pos);
9050    }
9051    fn move_left(&mut self) {
9052        // The textarea's `set_cursor` clamps to the nearest grapheme
9053        // boundary, so we just step back one byte and let it clean up.
9054        let new_pos = self.composer.cursor().saturating_sub(1);
9055        self.composer.set_cursor(new_pos);
9056    }
9057    fn move_right(&mut self) {
9058        let new_pos = self.composer.cursor().saturating_add(1);
9059        self.composer.set_cursor(new_pos);
9060    }
9061    fn delete_char_forward(&mut self) {
9062        self.composer.input(crossterm::event::KeyEvent::new(
9063            crossterm::event::KeyCode::Delete,
9064            crossterm::event::KeyModifiers::NONE,
9065        ));
9066    }
9067    fn insert_text(&mut self, text: &str) {
9068        self.composer.insert_str(text);
9069    }
9070    fn replace(&mut self, content: String, cursor: usize) {
9071        self.composer.set_text(&content);
9072        self.composer.set_cursor(cursor);
9073    }
9074    fn replace_range(&mut self, start: usize, end: usize, text: &str) {
9075        self.composer.replace_range(start..end, text);
9076    }
9077}
9078
9079// ─────────────────────────────────────────────────────────────────────────
9080// Small helpers
9081// ─────────────────────────────────────────────────────────────────────────
9082
9083pub(crate) fn plain_segment(text: impl Into<String>) -> InlineSegment {
9084    InlineSegment {
9085        text: text.into(),
9086        style: Arc::new(InlineTextStyle::default()),
9087    }
9088}
9089
9090pub(super) fn effective_scroll_offset(offset: usize, total: usize, viewport: usize) -> usize {
9091    if offset == usize::MAX {
9092        return total.saturating_sub(viewport);
9093    }
9094    // Clamp into [0, total.saturating_sub(viewport)].
9095    let max_start = total.saturating_sub(viewport);
9096    offset.min(max_start)
9097}
9098
9099// Slash-command autocomplete popup
9100// ─────────────────────────────────────────────────────────────────────────
9101/// Filter slash commands by `token` (the text after `/`). An empty token
9102/// returns every command. Matching is prefix-based against the canonical
9103/// name and all aliases.
9104///
9105/// Built-in commands are listed first; user-defined file commands are
9106/// appended afterwards. Any file command whose name shadows a built-in is
9107/// dropped — built-ins always win, so file commands cannot redefine
9108/// `/quit`, `/clear`, etc.
9109fn slash_filter(token: &str, file_commands: &[FileCommand]) -> Vec<SlashPopupItem> {
9110    let builtins = SlashRegistry::builtin_commands();
9111    let builtin_names: std::collections::HashSet<&str> =
9112        builtins.iter().map(|(n, _, _)| *n).collect();
9113
9114    let mut items: Vec<SlashPopupItem> = builtins
9115        .into_iter()
9116        .filter(|(name, _, aliases)| {
9117            token.is_empty()
9118                || name.starts_with(token)
9119                || aliases.iter().any(|a| a.starts_with(token))
9120        })
9121        .map(|(name, desc, aliases)| {
9122            let mut label = format!("/{name}");
9123            for a in &aliases {
9124                label.push_str(&format!(", /{a}"));
9125            }
9126            SlashPopupItem {
9127                label,
9128                description: desc.to_string(),
9129                name: name.to_string(),
9130            }
9131        })
9132        .collect();
9133
9134    // Append file commands (skip names shadowed by builtins).
9135    for fc in file_commands {
9136        if builtin_names.contains(fc.name.as_str())
9137            || fc
9138                .aliases
9139                .iter()
9140                .any(|alias| builtin_names.contains(alias.as_str()))
9141        {
9142            continue;
9143        }
9144        if token.is_empty()
9145            || fc.name.starts_with(token)
9146            || fc.aliases.iter().any(|a| a.starts_with(token))
9147        {
9148            let mut label = format!("/{}", fc.name);
9149            for a in &fc.aliases {
9150                label.push_str(&format!(", /{a}"));
9151            }
9152            items.push(SlashPopupItem {
9153                label,
9154                description: fc.description.clone(),
9155                name: fc.name.clone(),
9156            });
9157        }
9158    }
9159
9160    items
9161}
9162
9163/// Recompute the slash popup from the current input buffer. The popup is
9164/// active when the buffer starts with `/` and has no space yet (the user is
9165/// still composing the command token, not its arguments). Called after every
9166/// buffer mutation in the input thread.
9167fn refresh_slash_popup(state: &mut RenderState) {
9168    let buf = state.composer.text();
9169    let active = buf.starts_with('/') && !buf[1..].contains(' ');
9170    if !active {
9171        state.slash_popup.open = false;
9172        state.slash_popup.items.clear();
9173        state.slash_popup.selected = 0;
9174        return;
9175    }
9176    let token = &buf[1..];
9177    let items = slash_filter(token, &state.file_commands);
9178    state.slash_popup.open = !items.is_empty();
9179    if items.is_empty() {
9180        state.slash_popup.selected = 0;
9181    } else {
9182        state.slash_popup.selected = state.slash_popup.selected.min(items.len() - 1);
9183    }
9184    state.slash_popup.items = items;
9185}
9186/// Combined popup refresher — calls both the slash-command popup and the
9187/// @-file-search picker. Called after every input buffer mutation in the
9188/// input thread so both popups stay in sync with the cursor position.
9189fn refresh_input_popups(state: &mut RenderState) {
9190    refresh_slash_popup(state);
9191    refresh_file_search(state);
9192}
9193
9194/// Recompute the @-file-search dropdown from the current input buffer.
9195/// Called after every buffer mutation in the input thread. The filesystem
9196/// walk (building the index) happens only on the `None → Some` transition
9197/// (when `@` is first typed); subsequent keystrokes just re-filter the
9198/// cached index via [`FileSearchState::refresh`](crate::tui_vt::file_search::FileSearchState::refresh).
9199fn refresh_file_search(state: &mut RenderState) {
9200    use crate::tui_vt::file_search;
9201    // Never open the file picker while a slash command is being composed.
9202    if state.slash_popup.open {
9203        state.file_search = None;
9204        return;
9205    }
9206    match file_search::parse_at_cursor(state.composer.text(), state.composer.cursor()) {
9207        Some(token) => match &mut state.file_search {
9208            None => {
9209                let cwd = state.cwd.clone();
9210                state.file_search = Some(file_search::open(&cwd, token.at_offset, false));
9211            }
9212            Some(fs) => {
9213                if fs.query != token.path_query {
9214                    fs.refresh(&token.path_query);
9215                }
9216            }
9217        },
9218        None => state.file_search = None,
9219    }
9220}
9221
9222/// Accept the currently-selected file-search result: replace the `@query`
9223/// token in the buffer with the canonical `@path ` (or `@path:N-M ` in
9224/// line mode), advance the cursor past it, and close the picker.
9225/// Returns `true` if a result was accepted.
9226fn accept_file_search(state: &mut RenderState, line_mode: bool) -> bool {
9227    use crate::tui_vt::file_search;
9228    let Some(fs) = &state.file_search else {
9229        return false;
9230    };
9231    let Some(result) = fs.selected_result().cloned() else {
9232        return false;
9233    };
9234    let at_offset = fs.at_offset;
9235    let text = file_search::insertion_text(&result.path, None, line_mode);
9236    let cursor_end = state.composer.cursor();
9237    // Replace everything from `@` to the current cursor with the insertion.
9238    state.composer.replace_range(
9239        at_offset..cursor_end.min(state.composer.text().len()),
9240        &text,
9241    );
9242    state.composer.set_cursor(at_offset + text.len());
9243    state.file_search = None;
9244    true
9245}
9246
9247fn preview_tool_result(content: &str) -> String {
9248    const MAX: usize = 500;
9249    if content.chars().count() <= MAX {
9250        return content.to_string();
9251    }
9252    let truncated: String = content.chars().take(MAX).collect();
9253    format!("{truncated}\u{2026}")
9254}
9255
9256/// Extract the first embedded PNG from a `generate_image` tool result.
9257///
9258/// The tool's output embeds images as
9259/// `Image N (<bytes> bytes, base64):\n<base64>`. Returns the decoded
9260/// bytes of the first image, or `None` when no marker/base64 payload is
9261/// present or the payload does not decode.
9262fn extract_generated_png(content: &str) -> Option<Vec<u8>> {
9263    use base64::{Engine, engine::general_purpose};
9264    const MARKER: &str = "base64):";
9265    let rest = &content[content.find(MARKER)? + MARKER.len()..];
9266    // The base64 blob is the first non-empty line after the marker.
9267    let blob = rest.lines().map(str::trim).find(|l| !l.is_empty())?;
9268    if blob.is_empty() {
9269        return None;
9270    }
9271    let bytes = general_purpose::STANDARD.decode(blob).ok()?;
9272    // Sanity floor: a real PNG header is 8 bytes. Shorter payloads are
9273    // parse noise, not an image.
9274    (bytes.len() >= 8).then_some(bytes)
9275}
9276
9277fn color_from_anstyle(color: Option<anstyle::Color>) -> Color {
9278    match color {
9279        Some(anstyle::Color::Ansi(a)) => ansi_to_ratatui(a),
9280        Some(anstyle::Color::Ansi256(idx)) => Color::Indexed(idx.0),
9281        Some(anstyle::Color::Rgb(rgb)) => Color::Rgb(rgb.0, rgb.1, rgb.2),
9282        None => Color::Reset,
9283    }
9284}
9285fn ansi_to_ratatui(color: anstyle::AnsiColor) -> Color {
9286    use anstyle::AnsiColor as A;
9287    match color {
9288        A::Black => Color::Black,
9289        A::Red => Color::Red,
9290        A::Green => Color::Green,
9291        A::Yellow => Color::Yellow,
9292        A::Blue => Color::Blue,
9293        A::Magenta => Color::Magenta,
9294        A::Cyan => Color::Cyan,
9295        A::White => Color::Gray,
9296        A::BrightBlack => Color::DarkGray,
9297        A::BrightRed => Color::LightRed,
9298        A::BrightGreen => Color::LightGreen,
9299        A::BrightYellow => Color::LightYellow,
9300        A::BrightBlue => Color::LightBlue,
9301        A::BrightMagenta => Color::LightMagenta,
9302        A::BrightCyan => Color::LightCyan,
9303        A::BrightWhite => Color::White,
9304    }
9305}
9306
9307// Suppress the unused-import warning while keeping the AtomicBool/Ordering
9308// available for future control flags (e.g. SIGINT safety net).
9309#[allow(dead_code, clippy::declare_interior_mutable_const)]
9310const _ATOMIC_REFS: (AtomicBool, Ordering) = (AtomicBool::new(false), Ordering::SeqCst);
9311
9312#[cfg(test)]
9313mod slash_popup_tests {
9314    use super::*;
9315
9316    #[test]
9317    fn empty_token_lists_all_commands() {
9318        let items = slash_filter("", &[]);
9319        // 7 built-in commands.
9320        assert!(items.len() >= 7);
9321        assert!(items.iter().any(|i| i.name == "quit"));
9322        assert!(items.iter().any(|i| i.name == "clear"));
9323        assert!(items.iter().any(|i| i.name == "model"));
9324    }
9325
9326    #[test]
9327    fn prefix_filter_matches_name() {
9328        let items = slash_filter("qu", &[]);
9329        assert_eq!(items.len(), 1);
9330        assert_eq!(items[0].name, "quit");
9331        assert!(items[0].label.contains("/quit"));
9332    }
9333
9334    #[test]
9335    fn prefix_filter_matches_alias() {
9336        // "cl" should match "clear" (alias "cls") and "compact".
9337        let items = slash_filter("cl", &[]);
9338        let names: Vec<&str> = items.iter().map(|i| i.name.as_str()).collect();
9339        assert!(names.contains(&"clear"));
9340    }
9341
9342    #[test]
9343    fn file_commands_appear_in_filter() {
9344        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9345            "review",
9346            "---\ndescription: proj cmd\naliases: cr\n---\nbody",
9347        );
9348        let items = slash_filter("", &[fc]);
9349        assert!(items.iter().any(|i| i.name == "review"));
9350        assert!(items.iter().any(|i| i.name == "quit")); // builtins still present
9351    }
9352
9353    #[test]
9354    fn file_commands_filtered_by_prefix() {
9355        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9356            "review",
9357            "---\ndescription: x\n---\nbody",
9358        );
9359        let items = slash_filter("rev", &[fc]);
9360        assert!(items.iter().any(|i| i.name == "review"));
9361    }
9362
9363    #[test]
9364    fn file_commands_shadowed_by_builtins_are_dropped() {
9365        // A file command whose name collides with a built-in must be dropped —
9366        // built-ins always win. Without this guarantee the popup could surface
9367        // two items for the same prefix and the dispatch layer would pick the
9368        // wrong one.
9369        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9370            "quit",
9371            "---\ndescription: hijack\n---\nbody",
9372        );
9373        let items = slash_filter("", &[fc]);
9374        let quit_count = items.iter().filter(|i| i.name == "quit").count();
9375        assert_eq!(quit_count, 1, "shadowed file command must not appear");
9376        // And it must be the built-in description, not the file one.
9377        assert!(
9378            items
9379                .iter()
9380                .any(|i| i.name == "quit" && !i.description.contains("hijack"))
9381        );
9382    }
9383
9384    #[test]
9385    fn file_commands_with_builtin_aliases_are_dropped() {
9386        let fc = crate::tui_vt::slash::file_commands::FileCommand::parse(
9387            "review",
9388            "---\ndescription: hijack\naliases: quit\n---\nbody",
9389        );
9390        let items = slash_filter("", &[fc]);
9391        assert!(!items.iter().any(|item| item.name == "review"));
9392    }
9393
9394    #[test]
9395    fn popup_opens_on_slash() {
9396        let mut state = RenderState::default();
9397        state.composer.set_text("/");
9398        refresh_input_popups(&mut state);
9399        assert!(state.slash_popup.open);
9400        assert!(!state.slash_popup.items.is_empty());
9401    }
9402
9403    #[test]
9404    fn popup_closes_on_space() {
9405        let mut state = RenderState::default();
9406        state.composer.set_text("/quit ");
9407        refresh_input_popups(&mut state);
9408        assert!(!state.slash_popup.open);
9409    }
9410
9411    #[test]
9412    fn popup_closes_on_non_slash() {
9413        let mut state = RenderState::default();
9414        state.composer.set_text("hello");
9415        refresh_input_popups(&mut state);
9416        assert!(!state.slash_popup.open);
9417    }
9418
9419    #[test]
9420    fn popup_filters_as_user_types() {
9421        let mut state = RenderState::default();
9422        state.composer.set_text("/m");
9423        refresh_input_popups(&mut state);
9424        assert!(state.slash_popup.open);
9425        // Every item's canonical name must start with 'm' (model is the
9426        // only command matching the "m" prefix).
9427        assert!(
9428            state
9429                .slash_popup
9430                .items
9431                .iter()
9432                .all(|i| i.name.starts_with('m'))
9433        );
9434    }
9435
9436    #[test]
9437    fn popup_selection_clamps_on_shrink() {
9438        let mut state = RenderState::default();
9439        state.composer.set_text("/");
9440        refresh_input_popups(&mut state);
9441        let full_count = state.slash_popup.items.len();
9442        state.slash_popup.selected = full_count - 1;
9443        // Narrow the filter so fewer items remain.
9444        state.composer.set_text("/qu");
9445        refresh_input_popups(&mut state);
9446        assert!(state.slash_popup.selected < state.slash_popup.items.len());
9447    }
9448}
9449
9450#[cfg(test)]
9451mod keymap_dispatch_tests {
9452    use super::*;
9453    use crate::tui_vt::keymap::Keymap;
9454    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9455
9456    fn press(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
9457        KeyEvent::new(code, mods)
9458    }
9459
9460    /// Build a keymap from (action-name, combo-string) override pairs —
9461    /// the unified replacement for the branch's keybindings.yml overlay
9462    /// (settings-backed overrides go through the same
9463    /// `Keymap::from_settings` path as the real settings editor).
9464    fn keymap_with(overrides: &[(&str, &str)]) -> Keymap {
9465        let mut o = std::collections::HashMap::new();
9466        for (action, combo) in overrides {
9467            o.insert(action.to_string(), vec![combo.to_string()]);
9468        }
9469        Keymap::from_settings(&o)
9470    }
9471
9472    fn default_keymap() -> Keymap {
9473        Keymap::from_settings(&std::collections::HashMap::new())
9474    }
9475
9476    #[test]
9477    fn rebound_submit_fires_through_generic_dispatch() {
9478        // Final-review finding 6: `submit: alt+s` must actually fire.
9479        // Previously Submit was consulted only inside the hardcoded
9480        // Enter arm, so a rebind could only disable submit at Enter,
9481        // never move it to another key.
9482        let km = keymap_with(&[("Submit", "Alt+s")]);
9483        let alt_s = press(KeyCode::Char('s'), KeyModifiers::ALT);
9484        // Multiline is irrelevant for the rebind: the carve-out only
9485        // protects plain Enter.
9486        assert_eq!(keymap_pre_match(&km, &alt_s, false), KeymapDispatch::Submit);
9487        assert_eq!(keymap_pre_match(&km, &alt_s, true), KeymapDispatch::Submit);
9488        // Enter no longer submits (replaced wholesale) — and in
9489        // multiline it still falls through for the newline insert.
9490        let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9491        assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::None);
9492        assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9493    }
9494
9495    #[test]
9496    fn default_submit_dispatch_preserves_muscle_memory() {
9497        let km = default_keymap();
9498        let enter = press(KeyCode::Enter, KeyModifiers::NONE);
9499        let shift_enter = press(KeyCode::Enter, KeyModifiers::SHIFT);
9500        // Non-multiline: plain Enter submits.
9501        assert_eq!(keymap_pre_match(&km, &enter, false), KeymapDispatch::Submit);
9502        // Multiline: plain Enter falls through (the Enter arm inserts
9503        // a newline), Shift+Enter submits.
9504        assert_eq!(keymap_pre_match(&km, &enter, true), KeymapDispatch::None);
9505        assert_eq!(
9506            keymap_pre_match(&km, &shift_enter, true),
9507            KeymapDispatch::Submit
9508        );
9509    }
9510
9511    #[test]
9512    fn scroll_and_help_dispatch_via_keymap() {
9513        let km = default_keymap();
9514        assert_eq!(
9515            keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::NONE), false),
9516            KeymapDispatch::ScrollPageUp
9517        );
9518        assert_eq!(
9519            keymap_pre_match(&km, &press(KeyCode::PageDown, KeyModifiers::NONE), false),
9520            KeymapDispatch::ScrollPageDown
9521        );
9522        let km = keymap_with(&[("ScrollUp", "Ctrl+u")]);
9523        assert_eq!(
9524            keymap_pre_match(
9525                &km,
9526                &press(KeyCode::Char('u'), KeyModifiers::CONTROL),
9527                false
9528            ),
9529            KeymapDispatch::ScrollPageUp
9530        );
9531        // Printable Help bindings stay with the Char arm (empty-
9532        // composer gate); non-printable ones dispatch here.
9533        let km = default_keymap();
9534        assert_eq!(
9535            keymap_pre_match(&km, &press(KeyCode::Char('?'), KeyModifiers::NONE), false),
9536            KeymapDispatch::None
9537        );
9538        let km = keymap_with(&[("Help", "Ctrl+PageUp")]);
9539        assert_eq!(
9540            keymap_pre_match(&km, &press(KeyCode::PageUp, KeyModifiers::CONTROL), false),
9541            KeymapDispatch::Help
9542        );
9543        // Everything else falls through.
9544        assert_eq!(
9545            keymap_pre_match(&km, &press(KeyCode::Char('x'), KeyModifiers::NONE), false),
9546            KeymapDispatch::None
9547        );
9548    }
9549}
9550
9551#[cfg(test)]
9552mod render_tests {
9553    use super::*;
9554    use oxicode_vtui::tui::core::{InlineHandle, OverlayEvent};
9555    use ratatui::{Terminal, backend::TestBackend};
9556    use tokio::sync::mpsc;
9557
9558    /// Render `render_frame` into a TestBackend and return the concatenated
9559    /// cell text. This catches regressions like a missing render_composer
9560    /// call — `#![allow(dead_code)]` in lib.rs suppresses the unused-fn lint,
9561    /// so only a render assertion can prove the composer is painted.
9562    fn render_frame_to_string(state: &RenderState) -> String {
9563        let backend = TestBackend::new(80, 24);
9564        let mut terminal = Terminal::new(backend).expect("backend");
9565        let (tx, _rx) = mpsc::unbounded_channel();
9566        let handle = InlineHandle::new_for_tests(tx);
9567        terminal
9568            .draw(|f| render_frame(f, state, &handle))
9569            .expect("draw");
9570        let buf = terminal.backend().buffer();
9571        let area = buf.area();
9572        let mut out = String::new();
9573        for y in 0..area.height {
9574            for x in 0..area.width {
9575                if let Some(cell) = buf.cell((x, y)) {
9576                    out.push_str(cell.symbol());
9577                }
9578            }
9579            out.push('\n');
9580        }
9581        out
9582    }
9583
9584    /// Render the full frame at the requested size. Mirrors
9585    /// `render_frame_to_string` but at the documented width so PTY-style
9586    /// snapshot tests can assert on a representative viewport.
9587    #[allow(dead_code)]
9588    fn render_frame_to_string_at(state: &RenderState, width: u16, height: u16) -> String {
9589        let backend = TestBackend::new(width, height);
9590        let mut terminal = Terminal::new(backend).expect("backend");
9591        let (tx, _rx) = mpsc::unbounded_channel();
9592        let handle = InlineHandle::new_for_tests(tx);
9593        terminal
9594            .draw(|f| render_frame(f, state, &handle))
9595            .expect("draw");
9596        let buf = terminal.backend().buffer();
9597        let area = buf.area();
9598        let mut out = String::new();
9599        for y in 0..area.height {
9600            for x in 0..area.width {
9601                if let Some(cell) = buf.cell((x, y)) {
9602                    out.push_str(cell.symbol());
9603                }
9604            }
9605            out.push('\n');
9606        }
9607        out
9608    }
9609
9610    /// Diagnostic helper: render the full frame and return the terminal
9611    /// caret position (where render_composer set it).
9612    fn terminal_caret(state: &RenderState) -> Option<(u16, u16)> {
9613        let backend = TestBackend::new(80, 24);
9614        let mut terminal = Terminal::new(backend).expect("backend");
9615        let (tx, _rx) = mpsc::unbounded_channel();
9616        let handle = InlineHandle::new_for_tests(tx);
9617        terminal
9618            .draw(|f| render_frame(f, state, &handle))
9619            .expect("draw");
9620        terminal
9621            .get_cursor_position()
9622            .ok()
9623            .map(|position| (position.x, position.y))
9624    }
9625
9626    #[test]
9627    fn composer_caret_aligns_after_ascii() {
9628        let mut state = RenderState::default();
9629        state.prompt_prefix = "> ".to_string();
9630        state.input_enabled = true;
9631        let mut composer = oxicode_textarea::TextArea::new();
9632        composer.set_text("hello");
9633        composer.set_cursor(5);
9634        state.composer = composer;
9635        let caret = terminal_caret(&state);
9636        // The dense chat layout leaves a 1-column side gutter and no outer
9637        // vertical padding: prompt = Rect{x:1,y:21,w:78,h:3}; inner starts at
9638        // (2, 21), and the 2-column prefix puts the body at x=4.
9639        assert_eq!(
9640            caret,
9641            Some((9, 22)),
9642            "ASCII caret must sit right after '> hello'"
9643        );
9644    }
9645
9646    #[test]
9647    fn composer_caret_aligns_after_cjk_display_columns() {
9648        let mut state = RenderState::default();
9649        state.prompt_prefix = "> ".to_string();
9650        state.input_enabled = true;
9651        let body = "안녕";
9652        let mut composer = oxicode_textarea::TextArea::new();
9653        composer.set_text(body);
9654        composer.set_cursor(body.len()); // 6 bytes (end), 4 display cols
9655        state.composer = composer;
9656        let caret = terminal_caret(&state);
9657        // textarea_area.x = 4, col = 4 -> (4 + 4, 21) = (8, 21).
9658        assert_eq!(
9659            caret,
9660            Some((8, 22)),
9661            "CJK caret must sit after 4 display columns (not 6 bytes)"
9662        );
9663    }
9664
9665    #[test]
9666    fn composer_caret_aligns_after_mixed_ascii_cjk() {
9667        let body = "hi안녕";
9668        let mut state = RenderState::default();
9669        state.prompt_prefix = "> ".to_string();
9670        state.input_enabled = true;
9671        let mut composer = oxicode_textarea::TextArea::new();
9672        composer.set_text(body);
9673        composer.set_cursor(body.len()); // 8 bytes, 6 display cols
9674        state.composer = composer;
9675        let caret = terminal_caret(&state);
9676        // textarea_area.x = 4, col = 6 -> (4 + 6, 21) = (10, 21).
9677        assert_eq!(
9678            caret,
9679            Some((10, 22)),
9680            "Mixed caret must sit after 6 display columns"
9681        );
9682    }
9683
9684    #[test]
9685    fn agent_session_event_reaches_the_transcript_bridge() {
9686        let (tx, mut rx) = mpsc::unbounded_channel();
9687        let handle = InlineHandle::new_for_tests(tx);
9688        let mut state = RenderState::default();
9689
9690        handle_session_event(
9691            &mut state,
9692            &handle,
9693            &SessionEvent::Agent(Box::new(AgentEvent::TextChunk {
9694                text: "streamed reply".to_string(),
9695            })),
9696            None,
9697        );
9698
9699        let command = rx
9700            .try_recv()
9701            .expect("an agent event must produce a render command");
9702        apply_command(&mut state, command);
9703        assert_eq!(state.transcript.len(), 1);
9704        assert_eq!(state.transcript[0].kind, InlineMessageKind::Agent);
9705        assert_eq!(state.transcript[0].segments[0].text, "streamed reply");
9706    }
9707
9708    #[test]
9709    fn missing_key_errors_are_distinguished_from_other_provider_failures() {
9710        assert!(is_missing_api_key_error(
9711            "Provider stream error: Missing API key — configure a credential"
9712        ));
9713        assert!(!is_missing_api_key_error("Provider returned HTTP 429"));
9714        assert_eq!(
9715            provider_from_model_id("deepseek/deepseek-v4-flash"),
9716            "deepseek"
9717        );
9718    }
9719
9720    #[test]
9721    fn prompt_queue_mutations_change_the_execution_queue() {
9722        let queue = PromptQueue::default();
9723        queue.enqueue("first".to_string());
9724        queue.enqueue("second".to_string());
9725        queue.enqueue("third".to_string());
9726
9727        assert!(queue.move_by(2, -1));
9728        assert_eq!(queue.remove(0).as_deref(), Some("first"));
9729        let pending: Vec<_> = queue.pending.lock().iter().cloned().collect();
9730        assert_eq!(pending, ["third", "second"]);
9731    }
9732
9733    #[test]
9734    fn welcome_screen_shown_when_transcript_empty() {
9735        let state = RenderState::default();
9736        let rendered = render_frame_to_string(&state);
9737        assert!(
9738            rendered.contains("OXICODE") && rendered.contains("WORKSPACE"),
9739            "welcome banner must appear when transcript is empty"
9740        );
9741    }
9742
9743    #[test]
9744    fn composer_is_painted() {
9745        // Regression guard: the composer prompt prefix must appear in the
9746        // rendered output. This would have caught the missing
9747        // render_composer call (advisory 2026-08-04).
9748        let mut state = RenderState::default();
9749        state.input_enabled = true;
9750        state.prompt_prefix = "> ".to_string();
9751        let rendered = render_frame_to_string(&state);
9752        assert!(
9753            rendered.contains('>'),
9754            "composer prompt prefix must be painted"
9755        );
9756    }
9757
9758    #[test]
9759    fn slash_popup_renders_command_list() {
9760        let mut state = RenderState::default();
9761        state.slash_popup.open = true;
9762        state.slash_popup.items = slash_filter("", &[]);
9763        let rendered = render_frame_to_string(&state);
9764        assert!(rendered.contains("COMMANDS"), "popup title must render");
9765        assert!(rendered.contains("/quit"), "popup must list /quit");
9766    }
9767
9768    #[test]
9769    fn composer_and_popup_render_together() {
9770        let mut state = RenderState::default();
9771        state.prompt_prefix = "> ".to_string();
9772        state.composer.set_text("/qu");
9773        state.slash_popup.open = true;
9774        state.slash_popup.items = slash_filter("qu", &[]);
9775        let rendered = render_frame_to_string(&state);
9776        assert!(rendered.contains("COMMANDS"), "popup must render");
9777        assert!(rendered.contains("/quit"), "popup must list /quit");
9778        assert!(rendered.contains('>'), "composer must still render");
9779    }
9780
9781    #[test]
9782    fn transcript_wraps_long_lines() {
9783        // Write-path width invariant (Task 2 / omp tui-core-renderer.md §4):
9784        // the transcript MUST never paint past the content width — even if
9785        // an agent response would naturally wrap to several rows, we hard-clip
9786        // to the viewport width so a malformed table can never overflow a
9787        // narrow terminal. The visible row stays at exactly the content width
9788        // and content past that column is dropped at the boundary (never
9789        // wrapped into a second visual row).
9790        let mut state = RenderState::default();
9791        state.transcript.push(TranscriptLine {
9792            kind: InlineMessageKind::Agent,
9793            segments: vec![plain_segment(
9794                "This is a very long agent response line that should wrap across multiple terminal rows when rendered at a narrow width.".to_string()
9795            )],
9796            block_id: 0,
9797        });
9798        let backend = TestBackend::new(40, 24);
9799        let mut terminal = Terminal::new(backend).expect("backend");
9800        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
9801        let handle = InlineHandle::new_for_tests(tx);
9802        terminal
9803            .draw(|f| render_frame(f, &state, &handle))
9804            .expect("draw");
9805        let buf = terminal.backend().buffer();
9806        // Walk every cell: the transcript row never paints past the content
9807        // width (no cell beyond col 40 should carry the clipped text).
9808        let mut full = String::new();
9809        for y in 0..buf.area.height {
9810            for x in 0..buf.area.width {
9811                if let Some(cell) = buf.cell((x, y)) {
9812                    full.push_str(cell.symbol());
9813                }
9814            }
9815            full.push('\n');
9816        }
9817        assert!(
9818            !full.contains("wrap"),
9819            "long line is clamped at the viewport edge — content past col 40 must be dropped, not wrapped"
9820        );
9821        // The truncated prefix is still visible: the leading word "This" lands
9822        // at the top-left of the transcript.
9823        assert!(
9824            full.contains("This"),
9825            "the truncated prefix of the clamped line is visible: {full:?}"
9826        );
9827    }
9828
9829    // ─── overlay tests ────────────────────────────────────────────────────
9830
9831    fn sample_overlay_items() -> Vec<OverlayListItem> {
9832        vec![
9833            OverlayListItem {
9834                title: "model-a".to_string(),
9835                subtitle: Some("first".to_string()),
9836                badge: Some("ready".to_string()),
9837                indent: 0,
9838                search_value: None,
9839                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(0)),
9840            },
9841            OverlayListItem {
9842                title: "model-b".to_string(),
9843                subtitle: None,
9844                badge: None,
9845                indent: 0,
9846                search_value: None,
9847                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(1)),
9848            },
9849            OverlayListItem {
9850                title: "model-c".to_string(),
9851                subtitle: None,
9852                badge: None,
9853                indent: 0,
9854                search_value: None,
9855                selection: Some(oxicode_vtui::tui::core::InlineListSelection::Model(2)),
9856            },
9857        ]
9858    }
9859
9860    #[test]
9861    fn overlay_renders_title_and_items() {
9862        let mut state = RenderState::default();
9863        state.overlay = Some(OverlayState {
9864            title: "Select model".to_string(),
9865            lines: vec!["Pick one".to_string()],
9866            items: sample_overlay_items(),
9867            selected: 0,
9868            search: None,
9869            secure_input: None,
9870            ..Default::default()
9871        });
9872        let rendered = render_frame_to_string(&state);
9873        assert!(
9874            rendered.contains("Select model"),
9875            "overlay title must render"
9876        );
9877        assert!(rendered.contains("model-a"), "first item must render");
9878        assert!(rendered.contains("model-b"), "second item must render");
9879        assert!(rendered.contains("model-c"), "third item must render");
9880        assert!(
9881            rendered.contains("Pick one"),
9882            "descriptive line must render"
9883        );
9884    }
9885
9886    #[test]
9887    fn overlay_search_filters_items() {
9888        let mut state = RenderState::default();
9889        state.overlay = Some(OverlayState {
9890            title: "Select".to_string(),
9891            lines: Vec::new(),
9892            items: sample_overlay_items(),
9893            selected: 0,
9894            search: Some(OverlaySearchState {
9895                label: "filter".to_string(),
9896                placeholder: Some("type".to_string()),
9897                value: "model-b".to_string(),
9898            }),
9899            secure_input: None,
9900            ..Default::default()
9901        });
9902        let rendered = render_frame_to_string(&state);
9903        assert!(rendered.contains("model-b"), "matching item must render");
9904        assert!(
9905            !rendered.contains("model-a"),
9906            "non-matching item must not render (got: {})",
9907            rendered
9908        );
9909        assert!(
9910            !rendered.contains("model-c"),
9911            "non-matching item must not render"
9912        );
9913    }
9914
9915    #[test]
9916    fn overlay_keyboard_nav_moves_selection() {
9917        let mut state = RenderState::default();
9918        state.overlay = Some(OverlayState {
9919            title: "Select".to_string(),
9920            lines: Vec::new(),
9921            items: sample_overlay_items(),
9922            selected: 0,
9923            search: None,
9924            secure_input: None,
9925            ..Default::default()
9926        });
9927        let state_arc = Arc::new(parking_lot::Mutex::new(state));
9928        let (tx, mut _rx) = mpsc::unbounded_channel();
9929
9930        // Initial: index 0 selected.
9931        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9932
9933        // Down: index 1 selected.
9934        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9935        assert!(consumed, "Down must be consumed while overlay is open");
9936        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 1);
9937
9938        // Down: index 2 selected.
9939        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9940        assert!(consumed);
9941        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9942
9943        // Down: wraps to index 0.
9944        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Down);
9945        assert!(consumed);
9946        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 0);
9947
9948        // Up: wraps to last (index 2).
9949        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Up);
9950        assert!(consumed);
9951        assert_eq!(state_arc.lock().overlay.as_ref().unwrap().selected, 2);
9952
9953        // Enter: closes overlay and emits a Submission event.
9954        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
9955        assert!(consumed);
9956        assert!(
9957            state_arc.lock().overlay.is_none(),
9958            "overlay must be cleared after Enter"
9959        );
9960        let evt = _rx.try_recv().expect("submit event must arrive");
9961        match evt {
9962            InlineEvent::Overlay(OverlayEvent::Submitted(_)) => {}
9963            other => panic!("expected Submitted overlay event, got {other:?}"),
9964        }
9965    }
9966
9967    #[test]
9968    fn overlay_esc_closes_and_emits_cancelled() {
9969        let mut state = RenderState::default();
9970        state.overlay = Some(OverlayState {
9971            title: "Select".to_string(),
9972            lines: Vec::new(),
9973            items: sample_overlay_items(),
9974            selected: 0,
9975            search: None,
9976            secure_input: None,
9977            ..Default::default()
9978        });
9979        let state_arc = Arc::new(parking_lot::Mutex::new(state));
9980        let (tx, mut rx) = mpsc::unbounded_channel();
9981
9982        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Esc);
9983        assert!(consumed);
9984        assert!(
9985            state_arc.lock().overlay.is_none(),
9986            "overlay must be cleared after Esc"
9987        );
9988        let evt = rx.try_recv().expect("cancel event must arrive");
9989        assert!(
9990            matches!(evt, InlineEvent::Overlay(OverlayEvent::Cancelled)),
9991            "expected Cancelled overlay event"
9992        );
9993    }
9994
9995    #[test]
9996    fn overlay_enter_on_readonly_item_is_noop() {
9997        // A read-only item (selection: None — /tools, /mcp, the /settings
9998        // Model row) must NOT submit a synthetic selection or pollute the
9999        // prompt with "/overlay:N". Enter is a no-op: overlay stays open.
10000        let mut state = RenderState::default();
10001        state.overlay = Some(OverlayState {
10002            title: "Tools".to_string(),
10003            lines: Vec::new(),
10004            items: vec![OverlayListItem {
10005                title: "read".to_string(),
10006                subtitle: Some("Read a file".to_string()),
10007                badge: None,
10008                indent: 0,
10009                search_value: None,
10010                selection: None,
10011            }],
10012            selected: 0,
10013            search: None,
10014            secure_input: None,
10015            ..Default::default()
10016        });
10017        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10018        let (tx, mut rx) = mpsc::unbounded_channel();
10019
10020        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10021        assert!(consumed, "Enter must be consumed even on read-only items");
10022        assert!(
10023            state_arc.lock().overlay.is_some(),
10024            "overlay must stay open when Enter hits a read-only item"
10025        );
10026        assert!(
10027            rx.try_recv().is_err(),
10028            "no overlay event must be emitted for a read-only Enter"
10029        );
10030    }
10031
10032    #[test]
10033    fn overlay_chars_route_to_search_field() {
10034        let mut state = RenderState::default();
10035        state.overlay = Some(OverlayState {
10036            title: "Select".to_string(),
10037            lines: Vec::new(),
10038            items: sample_overlay_items(),
10039            selected: 0,
10040            search: Some(OverlaySearchState {
10041                label: "filter".to_string(),
10042                placeholder: None,
10043                value: String::new(),
10044            }),
10045            secure_input: None,
10046            ..Default::default()
10047        });
10048        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10049        let (tx, _rx) = mpsc::unbounded_channel();
10050
10051        handle_overlay_key(&state_arc, &tx, KeyCode::Char('m'));
10052        handle_overlay_key(&state_arc, &tx, KeyCode::Char('o'));
10053        handle_overlay_key(&state_arc, &tx, KeyCode::Backspace);
10054        let value = state_arc
10055            .lock()
10056            .overlay
10057            .as_ref()
10058            .unwrap()
10059            .search
10060            .as_ref()
10061            .unwrap()
10062            .value
10063            .clone();
10064        assert_eq!(value, "m", "Backspace should drop last char");
10065    }
10066
10067    #[test]
10068    fn overlay_key_no_op_when_no_overlay_open() {
10069        let state = RenderState::default();
10070        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10071        let (tx, _rx) = mpsc::unbounded_channel();
10072        let consumed = handle_overlay_key(&state_arc, &tx, KeyCode::Enter);
10073        assert!(
10074            !consumed,
10075            "handle_overlay_key must return false when no overlay is open"
10076        );
10077    }
10078
10079    #[test]
10080    fn apply_command_show_overlay_populates_state() {
10081        use oxicode_vtui::tui::core::{InlineListItem, ListOverlayRequest};
10082        let mut state = RenderState::default();
10083        let items = vec![
10084            InlineListItem {
10085                title: "alpha".to_string(),
10086                subtitle: None,
10087                badge: None,
10088                indent: 0,
10089                selection: None,
10090                search_value: None,
10091            },
10092            InlineListItem {
10093                title: "beta".to_string(),
10094                subtitle: None,
10095                badge: None,
10096                indent: 0,
10097                selection: None,
10098                search_value: None,
10099            },
10100        ];
10101        let request = OverlayRequest::List(ListOverlayRequest {
10102            title: "Pick".to_string(),
10103            lines: vec!["desc".to_string()],
10104            footer_hint: None,
10105            items,
10106            selected: None,
10107            search: None,
10108            hotkeys: Vec::new(),
10109        });
10110        let shutdown = apply_command(
10111            &mut state,
10112            InlineCommand::ShowOverlay {
10113                request: Box::new(request),
10114            },
10115        );
10116        assert!(!shutdown, "ShowOverlay must not request shutdown");
10117        let overlay = state.overlay.as_ref().expect("overlay must be Some");
10118        assert_eq!(overlay.title, "Pick");
10119        assert_eq!(overlay.items.len(), 2);
10120        assert_eq!(overlay.items[0].title, "alpha");
10121        assert_eq!(overlay.items[1].title, "beta");
10122        assert_eq!(overlay.lines.len(), 1);
10123
10124        // CloseOverlay clears it.
10125        apply_command(&mut state, InlineCommand::CloseOverlay);
10126        assert!(state.overlay.is_none(), "CloseOverlay must clear state");
10127    }
10128
10129    #[test]
10130    fn materialize_overlay_modal_with_secure_prompt_populates_secure_input() {
10131        use oxicode_vtui::tui::core::{ModalOverlayRequest, SecurePromptConfig};
10132        let request = OverlayRequest::Modal(ModalOverlayRequest {
10133            title: "API key".into(),
10134            lines: vec!["Paste your key".into()],
10135            secure_prompt: Some(SecurePromptConfig {
10136                label: "Key".into(),
10137                placeholder: Some("sk-...".into()),
10138                mask_input: true,
10139            }),
10140        });
10141        let state = materialize_overlay(request);
10142        let secure = state
10143            .secure_input
10144            .expect("secure_input must be Some when secure_prompt is Some");
10145        assert_eq!(secure.config.label, "Key");
10146        assert!(secure.config.mask_input);
10147        assert_eq!(secure.editor.text(), "");
10148        assert_eq!(secure.editor.cursor_byte(), 0);
10149    }
10150
10151    #[test]
10152    fn materialize_overlay_modal_without_secure_prompt_has_none_secure_input() {
10153        use oxicode_vtui::tui::core::ModalOverlayRequest;
10154        let request = OverlayRequest::Modal(ModalOverlayRequest {
10155            title: "Confirm".into(),
10156            lines: vec!["y/n".into()],
10157            secure_prompt: None,
10158        });
10159        let state = materialize_overlay(request);
10160        assert!(
10161            state.secure_input.is_none(),
10162            "secure_input must be None when secure_prompt is None"
10163        );
10164    }
10165
10166    // ─── fold / grace tests ─────────────────────────────────────────────
10167
10168    fn three_block_transcript() -> Vec<TranscriptLine> {
10169        // Three distinct blocks: user(0), agent(1), user(2).
10170        vec![
10171            TranscriptLine {
10172                kind: InlineMessageKind::User,
10173                segments: vec![plain_segment("hi")],
10174                block_id: 0,
10175            },
10176            TranscriptLine {
10177                kind: InlineMessageKind::Agent,
10178                segments: vec![plain_segment("hello")],
10179                block_id: 1,
10180            },
10181            TranscriptLine {
10182                kind: InlineMessageKind::Agent,
10183                segments: vec![plain_segment("world")],
10184                block_id: 1,
10185            },
10186            TranscriptLine {
10187                kind: InlineMessageKind::User,
10188                segments: vec![plain_segment("bye")],
10189                block_id: 2,
10190            },
10191        ]
10192    }
10193
10194    #[test]
10195    fn fold_all_collapses_every_block() {
10196        let mut state = RenderState::default();
10197        state.transcript = three_block_transcript();
10198        state.fold_all();
10199        assert_eq!(state.block_display.len(), 3, "3 distinct block ids");
10200        assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10201        assert_eq!(state.block_mode(1), BlockDisplayMode::Collapsed);
10202        assert_eq!(state.block_mode(2), BlockDisplayMode::Collapsed);
10203    }
10204
10205    #[test]
10206    fn expand_all_after_fold_all_shows_expanded() {
10207        let mut state = RenderState::default();
10208        state.transcript = three_block_transcript();
10209        state.fold_all();
10210        state.expand_all();
10211        assert!(
10212            state.block_display.is_empty(),
10213            "Expanded is the default — no overrides"
10214        );
10215        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10216        assert_eq!(state.block_mode(2), BlockDisplayMode::Expanded);
10217    }
10218
10219    #[test]
10220    fn truncate_all_sets_explicit_truncated() {
10221        let mut state = RenderState::default();
10222        state.transcript = three_block_transcript();
10223        state.fold_all();
10224        state.truncate_all();
10225        assert_eq!(state.block_display.len(), 3);
10226        assert_eq!(state.block_mode(1), BlockDisplayMode::Truncated);
10227    }
10228
10229    #[test]
10230    fn default_block_mode_is_expanded() {
10231        let state = RenderState::default();
10232        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10233        assert!(state.block_display.is_empty(), "default needs no map entry");
10234    }
10235
10236    #[test]
10237    fn cycle_block_advances_through_three_states() {
10238        let mut state = RenderState::default();
10239        state.transcript = three_block_transcript();
10240        state.scroll_offset = 0; // view on block 0
10241        // Expanded (default) → Collapsed
10242        state.cycle_block_at_view();
10243        assert_eq!(state.block_mode(0), BlockDisplayMode::Collapsed);
10244        // Collapsed → Truncated
10245        state.cycle_block_at_view();
10246        assert_eq!(state.block_mode(0), BlockDisplayMode::Truncated);
10247        // Truncated → Expanded (default — removed from the map)
10248        state.cycle_block_at_view();
10249        assert_eq!(state.block_mode(0), BlockDisplayMode::Expanded);
10250        assert!(!state.block_display.contains_key(&0));
10251    }
10252
10253    #[test]
10254    fn cancel_grace_field_defaults_none() {
10255        let state = RenderState::default();
10256        assert!(
10257            state.cancel_grace_until.is_none(),
10258            "cancel_grace_until must default to None"
10259        );
10260    }
10261
10262    #[test]
10263    fn cancel_routes_to_interrupt_when_streaming() {
10264        assert_eq!(
10265            route_cancel(true),
10266            CancelRoute::Interrupt,
10267            "Esc while streaming must route through the interrupt path"
10268        );
10269    }
10270
10271    #[test]
10272    fn cancel_routes_to_exit_when_idle() {
10273        assert_eq!(
10274            route_cancel(false),
10275            CancelRoute::Exit,
10276            "Esc while idle must exit immediately (one-press quit)"
10277        );
10278    }
10279    #[test]
10280    fn no_scrollbar_even_when_content_overflows() {
10281        // The in-app scrollbar is gone — native terminal scrollback owns
10282        // history and finalized rows commit above the viewport. Even a
10283        // 40-block transcript overflowing the viewport must not paint a
10284        // rail or thumb.
10285        let mut state = RenderState::default();
10286        for i in 0..40u32 {
10287            state.transcript.push(TranscriptLine {
10288                kind: InlineMessageKind::Agent,
10289                segments: vec![plain_segment(format!("line {i}"))],
10290                block_id: i as usize,
10291            });
10292        }
10293        let rendered = render_frame_to_string(&state);
10294        assert!(
10295            !rendered.contains('\u{2588}'),
10296            "no scrollbar thumb (█): native scrollback owns history"
10297        );
10298    }
10299
10300    // ─── confirmation modal tests ───────────────────────────────────────
10301
10302    #[test]
10303    fn confirmation_modal_renders_title() {
10304        let mut state = RenderState::default();
10305        state.confirmation = Some(quit_confirmation());
10306        let rendered = render_frame_to_string(&state);
10307        assert!(
10308            rendered.contains("Quit oxicode?"),
10309            "confirmation title must render"
10310        );
10311    }
10312
10313    #[test]
10314    fn confirmation_yes_sends_exit_and_closes() {
10315        let mut state = RenderState::default();
10316        state.confirmation = Some(quit_confirmation());
10317        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10318        let (tx, mut rx) = mpsc::unbounded_channel();
10319        let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10320        handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('y'));
10321        assert!(
10322            state_arc.lock().confirmation.is_none(),
10323            "yes must close the modal"
10324        );
10325        let ev = rx.try_recv().expect("yes must send an event");
10326        assert!(matches!(ev, InlineEvent::Exit), "yes must send Exit");
10327    }
10328
10329    #[test]
10330    fn confirmation_no_closes_without_event() {
10331        let mut state = RenderState::default();
10332        state.confirmation = Some(quit_confirmation());
10333        let state_arc = Arc::new(parking_lot::Mutex::new(state));
10334        let (tx, mut rx) = mpsc::unbounded_channel();
10335        let (issue_tx, _issue_rx) = mpsc::unbounded_channel();
10336        handle_confirmation_key(&state_arc, &tx, &issue_tx, KeyCode::Char('n'));
10337        assert!(
10338            state_arc.lock().confirmation.is_none(),
10339            "no must close the modal"
10340        );
10341        assert!(rx.try_recv().is_err(), "no must not send an event");
10342    }
10343    // ─── ephemeral tip tests ───────────────────────────────────────────
10344
10345    #[test]
10346    fn tip_banner_renders_when_active() {
10347        let mut state = RenderState::default();
10348        let now_tick = FRAME_TICK.load(std::sync::atomic::Ordering::Relaxed);
10349        state.tip = Some(EphemeralTip {
10350            text: "hello-tip-marker".to_string(),
10351            born_tick: now_tick,
10352            ttl_ticks: 100,
10353            key: "test",
10354            ambient: false,
10355        });
10356        let rendered = render_frame_to_string(&state);
10357        assert!(
10358            rendered.contains("hello-tip-marker"),
10359            "active tip must render above the composer"
10360        );
10361    }
10362
10363    #[test]
10364    fn tip_visible_within_ttl_window() {
10365        let tip = EphemeralTip {
10366            text: "x".to_string(),
10367            born_tick: 10,
10368            ttl_ticks: 5,
10369            key: "test",
10370            ambient: false,
10371        };
10372        assert!(tip_is_visible(&tip, 12), "within TTL must be visible");
10373        assert!(
10374            !tip_is_visible(&tip, 15),
10375            "at TTL boundary (born + ttl) must expire"
10376        );
10377        assert!(!tip_is_visible(&tip, 99), "past TTL must expire");
10378    }
10379
10380    // ─── sticky header tests ───────────────────────────────────────────
10381
10382    #[test]
10383    fn sticky_header_pins_block_head_when_scrolled_into_body() {
10384        // One big block (40 same-block lines); scroll the viewport into the
10385        // body. The sticky header must pin the block's first line at the top.
10386        let mut state = RenderState::default();
10387        for i in 0..40u32 {
10388            state.transcript.push(TranscriptLine {
10389                kind: InlineMessageKind::Agent,
10390                segments: vec![plain_segment(format!("body-line-{i:02}"))],
10391                block_id: 0,
10392            });
10393        }
10394        state.scroll_offset = 10;
10395        let rendered = render_frame_to_string(&state);
10396        assert!(
10397            rendered.contains("body-line-00"),
10398            "sticky header must pin the block head when scrolled into the body"
10399        );
10400    }
10401
10402    #[test]
10403    fn sticky_header_absent_when_viewport_at_block_head() {
10404        // Viewport top is the block head itself — no sticky pin needed.
10405        let mut state = RenderState::default();
10406        for i in 0..40u32 {
10407            state.transcript.push(TranscriptLine {
10408                kind: InlineMessageKind::Agent,
10409                segments: vec![plain_segment(format!("head-line-{i:02}"))],
10410                block_id: 0,
10411            });
10412        }
10413        state.scroll_offset = 0;
10414        let rendered = render_frame_to_string(&state);
10415        // head-line-00 is the viewport top already; it renders exactly once
10416        // (no separate sticky row). Just assert it is present.
10417        assert!(rendered.contains("head-line-00"));
10418    }
10419
10420    // ─── prompt queue tests ─────────────────────────────────────────────
10421
10422    #[test]
10423    fn turn_end_drains_queue_head() {
10424        let mut state = RenderState::default();
10425        state.queued_inputs = vec!["queued-1".into(), "queued-2".into()];
10426        state.drain_queue_head();
10427        assert_eq!(
10428            state.queued_inputs.len(),
10429            1,
10430            "drain_queue_head must drop the head (now running)"
10431        );
10432        assert_eq!(state.queued_inputs[0], "queued-2");
10433    }
10434
10435    // ─── render_frame integration ──────────────────────────────────────
10436
10437    #[test]
10438    fn render_frame_paints_transcript_content() {
10439        // Guard against render_frame losing its render_transcript call
10440        // (which only a content assertion through render_frame can catch —
10441        // render_transcript unit tests bypass render_frame entirely).
10442        let mut state = RenderState::default();
10443        state.transcript.push(TranscriptLine {
10444            kind: InlineMessageKind::Agent,
10445            segments: vec![plain_segment("frame-content-marker-xyz")],
10446            block_id: 0,
10447        });
10448        let rendered = render_frame_to_string(&state);
10449        assert!(
10450            rendered.contains("frame-content-marker-xyz"),
10451            "render_frame must paint transcript content"
10452        );
10453    }
10454
10455    #[test]
10456    fn user_turns_get_one_blank_spacer_row() {
10457        let mut state = RenderState::default();
10458        state.transcript = vec![
10459            TranscriptLine {
10460                kind: InlineMessageKind::Agent,
10461                segments: vec![plain_segment("agent-answer")],
10462                block_id: 0,
10463            },
10464            TranscriptLine {
10465                kind: InlineMessageKind::User,
10466                segments: vec![plain_segment("next-question")],
10467                block_id: 1,
10468            },
10469        ];
10470        let rendered = render_frame_to_string(&state);
10471        let rows: Vec<&str> = rendered.split('\n').collect();
10472        let agent_row = rows
10473            .iter()
10474            .position(|r| r.contains("agent-answer"))
10475            .expect("agent row");
10476        assert!(
10477            rows[agent_row + 1].trim().is_empty(),
10478            "blank spacer between turns: {:?}",
10479            &rows[agent_row..agent_row + 3]
10480        );
10481        assert!(
10482            rows[agent_row + 2].contains("next-question"),
10483            "user line follows the spacer"
10484        );
10485    }
10486
10487    #[test]
10488    fn transcript_snapshot_at_120_cols_matches_role_layout() {
10489        let mut state = RenderState::default();
10490        state.brain = BrainChip::Ok;
10491        state.append_line(
10492            InlineMessageKind::User,
10493            vec![plain_segment("intro message\nsecond line")],
10494        );
10495        state.append_line(
10496            InlineMessageKind::Agent,
10497            vec![plain_segment("answer paragraph line one\nline two")],
10498        );
10499        state.append_line(
10500            InlineMessageKind::User,
10501            vec![plain_segment("follow-up question")],
10502        );
10503        let rendered = render_frame_to_string_at(&state, 120, 24);
10504        let rows: Vec<&str> = rendered.split('\n').collect();
10505        // User rows carry no glyph — bold primary text only.
10506        let first_user = rows
10507            .iter()
10508            .position(|row| row.contains("intro message"))
10509            .expect("intro user row");
10510        let continuation = rows
10511            .iter()
10512            .position(|row| row.contains("second line"))
10513            .expect("user continuation visible");
10514        assert_eq!(
10515            continuation,
10516            first_user + 1,
10517            "user continuation on next row"
10518        );
10519        assert!(
10520            !rows[first_user].contains("> "),
10521            "plain style has no prompt glyph: {rows:?}"
10522        );
10523
10524        // Turn rhythm: a blank row breathes between the request and the
10525        // response, and again before the next user turn.
10526        let agent_row = rows
10527            .iter()
10528            .position(|row| row.contains("answer paragraph"))
10529            .expect("agent row");
10530        assert_eq!(
10531            agent_row,
10532            continuation + 2,
10533            "one blank row separates request from response: {:?}",
10534            &rows[continuation..=agent_row]
10535        );
10536        assert!(
10537            !rows[agent_row].trim_start().starts_with('>'),
10538            "agent rows carry no prompt glyph: {rows:?}"
10539        );
10540
10541        let next_user = rows
10542            .iter()
10543            .position(|row| row.contains("follow-up question"))
10544            .expect("second user row");
10545        assert_eq!(
10546            next_user,
10547            agent_row + 3,
10548            "answer (2 rows) + one blank + next user turn: {:?}",
10549            &rows[agent_row..=next_user]
10550        );
10551
10552        // Brain chip lives on the shortcuts bar, not the composer border.
10553        let shortcuts_row = rows
10554            .iter()
10555            .position(|row| row.contains("brain·ok"))
10556            .expect("brain chip on shortcuts row");
10557        assert!(shortcuts_row > next_user, "chip below the chat surface");
10558    }
10559
10560    #[test]
10561    fn response_breathes_after_the_user_request() {
10562        let mut state = RenderState::default();
10563        state.append_line(InlineMessageKind::User, vec![plain_segment("the request")]);
10564        state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10565        let rendered = render_frame_to_string(&state);
10566        let rows: Vec<&str> = rendered.split('\n').collect();
10567        let request_row = rows
10568            .iter()
10569            .position(|r| r.contains("the request"))
10570            .expect("request row");
10571        let answer_row = rows
10572            .iter()
10573            .position(|r| r.contains("the answer"))
10574            .expect("answer row");
10575        assert_eq!(
10576            answer_row,
10577            request_row + 2,
10578            "one blank row must separate request from response: {:?}",
10579            &rows[request_row..=answer_row]
10580        );
10581    }
10582
10583    #[test]
10584    fn assistant_tool_flow_stays_contiguous() {
10585        let mut state = RenderState::default();
10586        state.append_line(InlineMessageKind::Tool, vec![plain_segment("[tool] read")]);
10587        state.append_line(InlineMessageKind::Tool, vec![plain_segment("[done] ok")]);
10588        state.append_line(InlineMessageKind::Agent, vec![plain_segment("the answer")]);
10589        let rendered = render_frame_to_string(&state);
10590        let rows: Vec<&str> = rendered.split('\n').collect();
10591        let tool_row = rows
10592            .iter()
10593            .position(|r| r.contains("[tool] read"))
10594            .expect("tool row");
10595        let answer_row = rows
10596            .iter()
10597            .position(|r| r.contains("the answer"))
10598            .expect("answer row");
10599        assert_eq!(
10600            answer_row,
10601            tool_row + 2,
10602            "tool → answer is one assistant turn — no blank inside it: {:?}",
10603            &rows[tool_row..=answer_row]
10604        );
10605    }
10606
10607    #[test]
10608    fn no_spacer_above_the_first_transcript_line() {
10609        let mut state = RenderState::default();
10610        state.transcript = vec![TranscriptLine {
10611            kind: InlineMessageKind::User,
10612            segments: vec![plain_segment("opening-question")],
10613            block_id: 0,
10614        }];
10615        let rendered = render_frame_to_string(&state);
10616        let rows: Vec<&str> = rendered.split('\n').collect();
10617        let user_row = rows
10618            .iter()
10619            .position(|r| r.contains("opening-question"))
10620            .expect("user row");
10621        assert!(
10622            rows[..user_row].iter().all(|r| r.trim().is_empty()),
10623            "transcript starts at the top with no spacer"
10624        );
10625    }
10626
10627    #[test]
10628    fn long_response_renders_every_line_by_default() {
10629        let mut state = RenderState::default();
10630        let mut segments = Vec::new();
10631        for i in 0..8 {
10632            if i > 0 {
10633                segments.push(plain_segment("\n"));
10634            }
10635            segments.push(plain_segment(format!("line-{i}")));
10636        }
10637        state.append_line(InlineMessageKind::Agent, segments);
10638        let rendered = render_frame_to_string(&state);
10639        assert!(
10640            !rendered.contains("lines"),
10641            "no elision gap by default — full text scrolls instead: {rendered}"
10642        );
10643        for i in 0..8 {
10644            assert!(
10645                rendered.contains(&format!("line-{i}")),
10646                "line-{i} must be reachable by scrolling: {rendered}"
10647            );
10648        }
10649    }
10650    #[test]
10651    fn multiline_user_input_renders_every_explicit_line() {
10652        let mut state = RenderState::default();
10653        state.append_line(
10654            InlineMessageKind::User,
10655            vec![plain_segment("first line\nsecond line")],
10656        );
10657        let rendered = render_frame_to_string(&state);
10658        let rows: Vec<&str> = rendered.split('\n').collect();
10659        let first_row = rows
10660            .iter()
10661            .position(|row| row.contains("first line"))
10662            .expect("first user row");
10663        let second_row = rows
10664            .iter()
10665            .position(|row| row.contains("second line"))
10666            .expect("explicit continuation line is visible");
10667        assert_eq!(
10668            second_row,
10669            first_row + 1,
10670            "explicit newline must occupy the following visual row"
10671        );
10672
10673        assert!(
10674            !rows[second_row].contains("> second line"),
10675            "continuation row must not look like a second user turn"
10676        );
10677    }
10678
10679    #[test]
10680    fn streaming_agent_delta_renders_every_explicit_line() {
10681        let mut state = RenderState::default();
10682        state.inline_segment(
10683            InlineMessageKind::Agent,
10684            plain_segment("first answer\nsecond answer"),
10685        );
10686        let rendered = render_frame_to_string(&state);
10687        let rows: Vec<&str> = rendered.split('\n').collect();
10688        let first_row = rows
10689            .iter()
10690            .position(|row| row.contains("first answer"))
10691            .expect("first agent row");
10692        let second_row = rows
10693            .iter()
10694            .position(|row| row.contains("second answer"))
10695            .expect("second agent row");
10696        assert_eq!(
10697            second_row,
10698            first_row + 1,
10699            "streamed newline must occupy the following visual row"
10700        );
10701    }
10702
10703    #[test]
10704    fn file_search_dropdown_renders_results() {
10705        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10706        let mut state = RenderState::default();
10707        state.input_enabled = true;
10708        state.file_search = Some(FileSearchState {
10709            query: "main".into(),
10710            at_offset: 0,
10711            hidden_mode: false,
10712            results: vec![
10713                FileSearchResult {
10714                    path: "src/main.rs".into(),
10715                    score: 100,
10716                },
10717                FileSearchResult {
10718                    path: "tests/main.rs".into(),
10719                    score: 50,
10720                },
10721            ],
10722            selected: 0,
10723            index: vec![],
10724            line_mode: false,
10725        });
10726        let rendered = render_frame_to_string(&state);
10727        assert!(rendered.contains("FILES"), "dropdown title must render");
10728        assert!(
10729            rendered.contains("src/main.rs"),
10730            "dropdown must show file paths"
10731        );
10732    }
10733
10734    #[test]
10735    fn file_search_dropdown_hidden_mode_title() {
10736        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10737        let mut state = RenderState::default();
10738        state.input_enabled = true;
10739        state.file_search = Some(FileSearchState {
10740            query: "".into(),
10741            at_offset: 0,
10742            hidden_mode: true,
10743            results: vec![FileSearchResult {
10744                path: ".env".into(),
10745                score: 0,
10746            }],
10747            selected: 0,
10748            index: vec![],
10749            line_mode: false,
10750        });
10751        let rendered = render_frame_to_string(&state);
10752        assert!(
10753            rendered.contains("HIDDEN"),
10754            "hidden mode must be indicated in title"
10755        );
10756    }
10757
10758    #[test]
10759    fn file_search_and_composer_render_together() {
10760        use crate::tui_vt::file_search::{FileSearchResult, FileSearchState};
10761        let mut state = RenderState::default();
10762        state.input_enabled = true;
10763        state.prompt_prefix = "> ".into();
10764        state.composer.set_text("@main");
10765        state.file_search = Some(FileSearchState {
10766            query: "main".into(),
10767            at_offset: 0,
10768            hidden_mode: false,
10769            results: vec![FileSearchResult {
10770                path: "src/main.rs".into(),
10771                score: 100,
10772            }],
10773            selected: 0,
10774            index: vec![],
10775            line_mode: false,
10776        });
10777        let rendered = render_frame_to_string(&state);
10778        // Both the composer text and the dropdown must appear.
10779        assert!(rendered.contains('>'), "composer must still render");
10780        assert!(
10781            rendered.contains("src/main.rs"),
10782            "dropdown must render alongside composer"
10783        );
10784    }
10785
10786    #[test]
10787    fn format_todo_line_shows_block_reason_and_notes_marker() {
10788        let styles = active_styles();
10789        let todo = TodoItem {
10790            content: "Wire OAuth".into(),
10791            status: TodoStatus::Blocked,
10792            notes: Some(vec!["waiting on vendor".into()]),
10793            block_reason: Some("vendor sandbox pending".into()),
10794        };
10795        let line = format_todo_line(&todo, false, &styles);
10796        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10797        assert!(text.contains("Wire OAuth"));
10798        assert!(text.contains("blocked: vendor sandbox pending"));
10799        assert!(text.contains("·1"));
10800    }
10801
10802    #[test]
10803    fn format_todo_line_abandoned_is_strikethrough() {
10804        let styles = active_styles();
10805        let todo = TodoItem {
10806            content: "Drop this".into(),
10807            status: TodoStatus::Abandoned,
10808            notes: None,
10809            block_reason: None,
10810        };
10811        let line = format_todo_line(&todo, false, &styles);
10812        assert!(
10813            line.spans
10814                .iter()
10815                .any(|s| s.style.add_modifier.contains(Modifier::CROSSED_OUT))
10816        );
10817    }
10818
10819    #[test]
10820    fn render_todo_pane_multi_phase_shows_roman_header_and_progress() {
10821        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10822        let mut state = RenderState::default();
10823        state.todo_phases = vec![
10824            TodoPhase {
10825                name: "Foundation".into(),
10826                tasks: vec![
10827                    TodoItem {
10828                        content: "a".into(),
10829                        status: TodoStatus::Completed,
10830                        notes: None,
10831                        block_reason: None,
10832                    },
10833                    TodoItem {
10834                        content: "b".into(),
10835                        status: TodoStatus::Completed,
10836                        notes: None,
10837                        block_reason: None,
10838                    },
10839                ],
10840            },
10841            TodoPhase {
10842                name: "Auth".into(),
10843                tasks: vec![
10844                    TodoItem {
10845                        content: "c".into(),
10846                        status: TodoStatus::Completed,
10847                        notes: None,
10848                        block_reason: None,
10849                    },
10850                    TodoItem {
10851                        content: "d".into(),
10852                        status: TodoStatus::InProgress,
10853                        notes: None,
10854                        block_reason: None,
10855                    },
10856                    TodoItem {
10857                        content: "e".into(),
10858                        status: TodoStatus::Pending,
10859                        notes: None,
10860                        block_reason: None,
10861                    },
10862                ],
10863            },
10864        ];
10865        let rendered = render_frame_to_string(&state);
10866        assert!(
10867            rendered.contains("II. Auth"),
10868            "multi-phase HUD must show the roman-numeral phase header"
10869        );
10870        assert!(
10871            rendered.contains("1/3"),
10872            "active phase must show its done/total progress"
10873        );
10874    }
10875
10876    #[test]
10877    fn todo_auto_clear_fires_after_delay_when_all_closed() {
10878        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10879        let mut state = RenderState::default();
10880        state.todo_phases = vec![TodoPhase {
10881            name: "Auth".into(),
10882            tasks: vec![TodoItem {
10883                content: "a".into(),
10884                status: TodoStatus::Completed,
10885                notes: None,
10886                block_reason: None,
10887            }],
10888        }];
10889        sync_todo_clear_timer(&mut state, 0); // 0-second delay = instant
10890        assert!(state.todo_phases.is_empty());
10891    }
10892
10893    #[test]
10894    fn todo_auto_clear_does_not_fire_while_open_tasks_remain() {
10895        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10896        let mut state = RenderState::default();
10897        let phases = vec![TodoPhase {
10898            name: "Auth".into(),
10899            tasks: vec![TodoItem {
10900                content: "a".into(),
10901                status: TodoStatus::Pending,
10902                notes: None,
10903                block_reason: None,
10904            }],
10905        }];
10906        state.todo_phases = phases.clone();
10907        sync_todo_clear_timer(&mut state, 0);
10908        assert_eq!(state.todo_phases.len(), phases.len());
10909        assert_eq!(state.todo_phases[0].name, "Auth");
10910    }
10911
10912    #[test]
10913    fn todo_auto_clear_negative_delay_disables_clearing() {
10914        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10915        let mut state = RenderState::default();
10916        let phases = vec![TodoPhase {
10917            name: "Auth".into(),
10918            tasks: vec![TodoItem {
10919                content: "a".into(),
10920                status: TodoStatus::Completed,
10921                notes: None,
10922                block_reason: None,
10923            }],
10924        }];
10925        state.todo_phases = phases.clone();
10926        sync_todo_clear_timer(&mut state, -1);
10927        assert_eq!(state.todo_phases.len(), phases.len());
10928    }
10929
10930    #[test]
10931    fn render_todo_pane_single_phase_has_no_roman_header() {
10932        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10933        let mut state = RenderState::default();
10934        state.todo_phases = vec![TodoPhase {
10935            name: "Todos".into(),
10936            tasks: vec![TodoItem {
10937                content: "a".into(),
10938                status: TodoStatus::Pending,
10939                notes: None,
10940                block_reason: None,
10941            }],
10942        }];
10943        let rendered = render_frame_to_string(&state);
10944        assert!(
10945            !rendered.contains("I. Todos"),
10946            "single phase must skip roman header"
10947        );
10948        assert!(rendered.contains("Todos"), "single-phase name must render");
10949    }
10950
10951    #[test]
10952    fn render_todo_compact_line_shows_counts_and_active_task() {
10953        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10954        let phases = vec![TodoPhase {
10955            name: "Auth".into(),
10956            tasks: vec![
10957                TodoItem {
10958                    content: "a".into(),
10959                    status: TodoStatus::Completed,
10960                    notes: None,
10961                    block_reason: None,
10962                },
10963                TodoItem {
10964                    content: "b".into(),
10965                    status: TodoStatus::InProgress,
10966                    notes: None,
10967                    block_reason: None,
10968                },
10969            ],
10970        }];
10971        let line = render_todo_compact_line(&phases);
10972        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10973        assert!(text.contains("TODO 1/2"));
10974        assert!(text.contains("b"));
10975    }
10976
10977    #[test]
10978    fn render_todo_compact_line_all_done_shows_done_marker() {
10979        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10980        let phases = vec![TodoPhase {
10981            name: "Auth".into(),
10982            tasks: vec![TodoItem {
10983                content: "a".into(),
10984                status: TodoStatus::Completed,
10985                notes: None,
10986                block_reason: None,
10987            }],
10988        }];
10989        let line = render_todo_compact_line(&phases);
10990        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
10991        assert!(text.contains("done"));
10992    }
10993
10994    fn test_todo_state() -> std::sync::Arc<crate::store::todo_state::TodoState> {
10995        use oxicode_agent::tools::todo::{TodoItem, TodoPhase};
10996        std::sync::Arc::new(crate::store::todo_state::TodoState::with_phases(vec![
10997            TodoPhase {
10998                name: "Auth".into(),
10999                tasks: vec![TodoItem {
11000                    content: "implement authentication module".into(),
11001                    status: TodoStatus::Pending,
11002                    notes: None,
11003                    block_reason: None,
11004                }],
11005            },
11006        ]))
11007    }
11008
11009    fn hub_with_subagent(
11010        status: oxicode_sdk::HubStatus,
11011        current_task: Option<&str>,
11012    ) -> std::sync::Arc<crate::app::agent_hub_registry::HubRegistry> {
11013        use crate::app::agent_hub_registry::{HubEntry, HubRegistry};
11014        let hub = HubRegistry::new();
11015        hub.register(
11016            "sub".into(),
11017            HubEntry {
11018                kind: oxicode_sdk::HubKind::Subagent,
11019                status,
11020                display_name: "sub".into(),
11021                current_task: current_task.map(str::to_string),
11022                last_activity_ms: 0,
11023                session_file: None,
11024            },
11025        );
11026        std::sync::Arc::new(hub)
11027    }
11028
11029    #[test]
11030    fn frame_refresh_reconciles_todo_with_completed_subagent() {
11031        let state = test_todo_state();
11032        let provider: std::sync::Arc<dyn TodoStateProvider> =
11033            crate::store::todo_state::provider_from_state(state.clone());
11034        let hub = hub_with_subagent(oxicode_sdk::HubStatus::Idle, Some("authentication module"));
11035        let phases = refresh_todo_phases(&provider, Some(&hub));
11036        assert_eq!(phases[0].tasks[0].status, TodoStatus::Completed);
11037        // The reconcile write-back persisted through the provider.
11038        assert_eq!(state.get_phases()[0].tasks[0].status, TodoStatus::Completed);
11039    }
11040
11041    #[test]
11042    fn matched_closure_lights_pending_todo_for_running_subagent() {
11043        let hub = hub_with_subagent(
11044            oxicode_sdk::HubStatus::Running,
11045            Some("authentication module"),
11046        );
11047        let matched = build_matched_closure(Some(&hub));
11048        let t = oxicode_agent::tools::todo::TodoItem {
11049            content: "implement authentication module".into(),
11050            status: TodoStatus::Pending,
11051            notes: None,
11052            block_reason: None,
11053        };
11054        assert!(matched(&t));
11055    }
11056
11057    #[test]
11058    fn todo_pane_renders_when_items_present() {
11059        // The sticky pane is populated from the live provider in the event
11060        // loop; here we seed it directly to assert the pane paints task text.
11061        let mut state = RenderState::default();
11062        state.todo_phases = vec![oxicode_agent::tools::todo::TodoPhase {
11063            name: "Work".into(),
11064            tasks: vec![
11065                oxicode_agent::tools::todo::TodoItem {
11066                    content: "active task".into(),
11067                    status: TodoStatus::InProgress,
11068                    notes: None,
11069                    block_reason: None,
11070                },
11071                oxicode_agent::tools::todo::TodoItem {
11072                    content: "open task".into(),
11073                    status: TodoStatus::Pending,
11074                    notes: None,
11075                    block_reason: None,
11076                },
11077            ],
11078        }];
11079        let rendered = render_frame_to_string(&state);
11080        assert!(
11081            rendered.contains("active task"),
11082            "in-progress task must render"
11083        );
11084        assert!(rendered.contains("open task"), "pending task must render");
11085        // The active task is marked with the in-progress glyph.
11086        assert!(rendered.contains("▸"), "in-progress status must render");
11087    }
11088
11089    #[test]
11090    fn todo_pane_hidden_when_empty() {
11091        let state = RenderState::default();
11092        let rendered = render_frame_to_string(&state);
11093        // No todo content should leak when the list is empty.
11094        assert!(!rendered.contains("done"), "no completed state when empty");
11095    }
11096
11097    #[test]
11098    fn render_overlay_secure_input_shows_label_mask_value_and_placeholder() {
11099        use ratatui::{Terminal, backend::TestBackend};
11100        let backend = TestBackend::new(80, 24);
11101        let mut terminal = Terminal::new(backend).unwrap();
11102        let overlay = OverlayState {
11103            title: "OpenAI key".into(),
11104            lines: vec!["Paste your API key".into()],
11105            items: Vec::new(),
11106            selected: 0,
11107            search: None,
11108            secure_input: Some(OverlaySecureInput {
11109                config: SecurePromptConfig {
11110                    label: "Key".into(),
11111                    placeholder: Some("sk-...".into()),
11112                    mask_input: true,
11113                },
11114                editor: oxicode_textarea::EditBuffer::from_parts("sk-abc", 6),
11115            }),
11116            ..Default::default()
11117        };
11118        terminal
11119            .draw(|f| render_overlay(f, f.area(), &overlay))
11120            .unwrap();
11121        let buf = terminal.backend().buffer().clone();
11122        // Mask must show 6 asterisks, never the value.
11123        let text: String = buf
11124            .content()
11125            .iter()
11126            .map(|c| c.symbol())
11127            .collect::<Vec<_>>()
11128            .join("");
11129        assert!(text.contains("Key:"));
11130        assert!(text.contains("******"));
11131        assert!(!text.contains("sk-abc"));
11132    }
11133
11134    #[test]
11135    fn render_overlay_secure_input_placeholder_when_empty() {
11136        use ratatui::{Terminal, backend::TestBackend};
11137        let backend = TestBackend::new(80, 24);
11138        let mut terminal = Terminal::new(backend).unwrap();
11139        let overlay = OverlayState {
11140            title: "OpenAI key".into(),
11141            lines: vec!["Paste your API key".into()],
11142            items: Vec::new(),
11143            selected: 0,
11144            search: None,
11145            secure_input: Some(OverlaySecureInput {
11146                config: SecurePromptConfig {
11147                    label: "Key".into(),
11148                    placeholder: Some("sk-...".into()),
11149                    mask_input: true,
11150                },
11151                editor: oxicode_textarea::EditBuffer::new(),
11152            }),
11153            ..Default::default()
11154        };
11155        terminal
11156            .draw(|f| render_overlay(f, f.area(), &overlay))
11157            .unwrap();
11158        let buf = terminal.backend().buffer().clone();
11159        let text: String = buf
11160            .content()
11161            .iter()
11162            .map(|c| c.symbol())
11163            .collect::<Vec<_>>()
11164            .join("");
11165        assert!(text.contains("sk-..."));
11166    }
11167    /// Pressure-driven allocation ladder: with many blocks competing
11168    /// for a short live region, older blocks must collapse to glyph
11169    /// rows and the emergency branch must paint a `… N earlier
11170    /// blocks hidden` banner.
11171    #[test]
11172    fn ladder_collapses_oldest_blocks_to_glyph_row_and_banner() {
11173        // 6 tool blocks; live region is 6 rows. Allocate 3 source
11174        // lines per block so the natural height (3) exceeds the
11175        // budget per block in the pressure branch. The ladder
11176        // hides the oldest blocks and paints glyph rows for the
11177        // newest ones.
11178        let mut state = RenderState::default();
11179        for i in 0..6 {
11180            let bid = i;
11181            state.transcript.push(TranscriptLine {
11182                kind: InlineMessageKind::Tool,
11183                segments: vec![plain_segment(format!("tool-{i}-headline"))],
11184                block_id: bid,
11185            });
11186            state.transcript.push(TranscriptLine {
11187                kind: InlineMessageKind::Tool,
11188                segments: vec![plain_segment(format!("tool-{i}-middle"))],
11189                block_id: bid,
11190            });
11191            state.transcript.push(TranscriptLine {
11192                kind: InlineMessageKind::Tool,
11193                segments: vec![plain_segment(format!("tool-{i}-trailer"))],
11194                block_id: bid,
11195            });
11196        }
11197        // 80x10 viewport → content_area.height ≈ 10 - composer 3 -
11198        // breath row 1 = 6 rows for the live region.
11199        let rendered = render_frame_to_string_at(&state, 80, 10);
11200        // Emergency banner ("… N earlier blocks hidden") must be
11201        // present when more blocks than rows exist. With 6 blocks
11202        // and a 6-row region, the ladder may or may not hide — but
11203        // it should never panic. We assert the renderer did not
11204        // lose the live region entirely and that the most-recent
11205        // block (tool-5) is at least partially visible.
11206        assert!(
11207            rendered.contains("tool-5")
11208                || rendered.contains("tool-5-headline")
11209                || rendered.contains("\u{25B8}")
11210                || rendered.contains("earlier blocks hidden"),
11211            "live region must surface a recent block or its folded form"
11212        );
11213    }
11214
11215    /// Pressure-driven ladder: the latest block stays full when
11216    /// older blocks are folded to glyph rows.
11217    #[test]
11218    fn ladder_keeps_newest_block_full_under_pressure() {
11219        // 3 blocks: one big (5 lines) + two small (2 lines each) =
11220        // 9 natural items; budget ≈ 6 → pressure. Newest (big)
11221        // gets the largest slice.
11222        let mut state = RenderState::default();
11223        // Block 0 (older, 2 lines)
11224        for j in 0..2 {
11225            state.transcript.push(TranscriptLine {
11226                kind: InlineMessageKind::Agent,
11227                segments: vec![plain_segment(format!("old-block-line-{j}"))],
11228                block_id: 0,
11229            });
11230        }
11231        // Block 1 (middle, 2 lines)
11232        for j in 0..2 {
11233            state.transcript.push(TranscriptLine {
11234                kind: InlineMessageKind::Agent,
11235                segments: vec![plain_segment(format!("mid-block-line-{j}"))],
11236                block_id: 1,
11237            });
11238        }
11239        // Block 2 (newest, 5 lines)
11240        for j in 0..5 {
11241            state.transcript.push(TranscriptLine {
11242                kind: InlineMessageKind::Agent,
11243                segments: vec![plain_segment(format!("new-block-line-{j}"))],
11244                block_id: 2,
11245            });
11246        }
11247        let rendered = render_frame_to_string_at(&state, 80, 12);
11248        // Newest block's first line must be visible.
11249        assert!(
11250            rendered.contains("new-block-line-0"),
11251            "newest block's leading line must be visible"
11252        );
11253        // Oldest block's lines may be folded or hidden — assert
11254        // they don't occupy the FULL natural height (the ladder
11255        // folded them).
11256        let old_visible = (0..2).all(|j| rendered.contains(&format!("old-block-line-{j}")));
11257        assert!(
11258            !old_visible,
11259            "oldest block must be folded or hidden when under pressure"
11260        );
11261    }
11262    /// Long activity strings must be clamped to the live content
11263    /// width — never wrap onto a second visual row that would
11264    /// break the `▸ ` or `╭─ / ╰─ …` affordances.
11265    #[test]
11266    fn long_activity_folded_card_stays_within_width() {
11267        let mut state = RenderState::default();
11268        // Many blocks of 3 lines each in a short live region.
11269        // 6 blocks × 3 = 18 visible items, budget ≈ 6 → pressure.
11270        // Every block gets 1 glyph row; activity descriptors are
11271        // 120+ chars long and would wrap without clamping.
11272        for i in 0..8 {
11273            let bid = i;
11274            let long = format!("tool-{i}-{}", "x".repeat(120));
11275            state.transcript.push(TranscriptLine {
11276                kind: InlineMessageKind::Tool,
11277                segments: vec![plain_segment(format!("tool-{i}-head"))],
11278                block_id: bid,
11279            });
11280            state.transcript.push(TranscriptLine {
11281                kind: InlineMessageKind::Tool,
11282                segments: vec![plain_segment(format!("tool-{i}-body"))],
11283                block_id: bid,
11284            });
11285            state.transcript.push(TranscriptLine {
11286                kind: InlineMessageKind::Tool,
11287                segments: vec![plain_segment(long)],
11288                block_id: bid,
11289            });
11290        }
11291        let rendered = render_frame_to_string_at(&state, 80, 10);
11292        // Every row in the rendered output must stay within the
11293        // 80-cell viewport. (Without clamping, the glyph row
11294        // `▸ tool-N-xxxxxxxxxxxxx...` would wrap onto a second
11295        // visual row whose first cell holds `▸`.)
11296        for line in rendered.split('\n') {
11297            assert!(
11298                line.width() <= 80,
11299                "rendered row exceeded the viewport width: '{}' ({} cells)",
11300                line,
11301                line.width()
11302            );
11303        }
11304        // The glyph row (`▸ `) signature must appear — long
11305        // activity must still surface (clamped, not truncated to
11306        // empty).
11307        assert!(
11308            rendered.contains('\u{25B8}'),
11309            "glyph rows must be painted even with long activities"
11310        );
11311    }
11312
11313    /// Manual `Collapsed` mode must keep the historic `[+] ` prefix
11314    /// produced by `transcript_line_marked(folded=true)`. The
11315    /// ladder applies only to non-manual blocks.
11316    #[test]
11317    fn manual_collapsed_block_keeps_the_plus_marker() {
11318        let mut state = RenderState::default();
11319        // One block with 3 lines + one newest block.
11320        for j in 0..3 {
11321            state.transcript.push(TranscriptLine {
11322                kind: InlineMessageKind::Error,
11323                segments: vec![plain_segment(format!("boom-line-{j}"))],
11324                block_id: 0,
11325            });
11326        }
11327        // Mark block 0 as manually collapsed.
11328        state.block_display.insert(0, BlockDisplayMode::Collapsed);
11329        // Newest block (1) untouched, default mode.
11330        state.transcript.push(TranscriptLine {
11331            kind: InlineMessageKind::Agent,
11332            segments: vec![plain_segment("after-collapsed")],
11333            block_id: 1,
11334        });
11335        let rendered = render_frame_to_string_at(&state, 80, 12);
11336        // The historic `[+] error:` prefix from
11337        // `transcript_line_marked(folded=true)` must still appear.
11338        assert!(
11339            rendered.contains("[+] error: boom-line-0"),
11340            "manual Collapsed must keep the [+] marker (got: {rendered:?})"
11341        );
11342        // The ladder's glyph affordance (`▸ `) must NOT replace it.
11343        assert!(
11344            !rendered.contains('\u{25B8}'),
11345            "manual Collapsed must NOT be replaced by the ladder glyph"
11346        );
11347    }
11348
11349    /// `clamp_fold_text` (the helper that truncates activity
11350    /// strings) honors unicode display width and replaces overflow
11351    /// with an ellipsis.
11352    #[test]
11353    fn clamp_fold_text_truncates_at_unicode_width() {
11354        // ASCII overflow: 80-cell budget, prefix 3, activity 100.
11355        let out = clamp_fold_text(&"x".repeat(100), 3, 80, "\u{2026}");
11356        assert!(out.ends_with('\u{2026}'), "ellipsis appended on overflow");
11357        assert!(out.width() <= 80, "clamped to budget: got {}", out.width());
11358        // CJK: each glyph is 2 cells.
11359        let cjk = "\u{4ECA}\u{65E5}\u{306F}\u{667A}\u{6167}".repeat(20);
11360        let out_cjk = clamp_fold_text(&cjk, 2, 20, "\u{2026}");
11361        assert!(out_cjk.width() <= 20, "CJK clamp: got {}", out_cjk.width());
11362        assert!(out_cjk.ends_with('\u{2026}'));
11363        // Identity when text already fits.
11364        assert_eq!(clamp_fold_text("short", 0, 80, "\u{2026}"), "short");
11365        // Zero-width returns empty.
11366        assert_eq!(clamp_fold_text("text", 0, 0, "\u{2026}"), "");
11367    }
11368
11369    // Cursor math for the composer is now owned by `oxicode_textarea::
11370    // TextArea::cursor_pos_with_state`, which is exercised by the
11371    // 351 tests in `oxicode-textarea`. The byte-cursor column math
11372    // these tests used to pin (composer_cursor_position) is gone.
11373}
11374
11375#[cfg(test)]
11376mod secure_input_tests {
11377    use super::*;
11378    use oxicode_vtui::tui::core::OverlaySubmission;
11379
11380    #[test]
11381    fn overlay_submission_secure_input_is_routed_to_host() {
11382        // Smoke: serialization round-trip — the variant must be reachable
11383        // through the protocol so the input thread can dispatch it.
11384        let _ = OverlaySubmission::SecureInput("sk-test".into());
11385        let serialized = format!("{:?}", OverlaySubmission::SecureInput("x".into()));
11386        assert!(serialized.contains("SecureInput"));
11387    }
11388
11389    #[test]
11390    fn providers_action_matrix_branches_correctly() {
11391        // Pin the (has_key, oauth_capable) → Vec<AuthAction> matrix
11392        // exactly. Refactors MUST keep this contract: the order of
11393        // returned actions drives the visible action menu order.
11394        assert_eq!(
11395            next_provider_actions(true, true),
11396            vec![
11397                AuthAction::SetApiKey,
11398                AuthAction::StartOAuth,
11399                AuthAction::RemoveKey,
11400            ],
11401            "has key + oauth-capable: replace, oauth, remove"
11402        );
11403        assert_eq!(
11404            next_provider_actions(true, false),
11405            vec![AuthAction::SetApiKey, AuthAction::RemoveKey],
11406            "has key, key-only provider: replace, remove"
11407        );
11408        assert_eq!(
11409            next_provider_actions(false, true),
11410            vec![AuthAction::SetApiKey, AuthAction::StartOAuth],
11411            "no key + oauth-capable: set key, oauth"
11412        );
11413        assert_eq!(
11414            next_provider_actions(false, false),
11415            vec![AuthAction::SetApiKey],
11416            "no key + key-only provider: set key only"
11417        );
11418    }
11419
11420    // ── EditBuffer-flow tests for the post-port secure input ──────
11421    //
11422    // These exercise the new flow end-to-end so we never regress on the
11423    // core invariants: the real value lives only in the editor, the
11424    // renderer paints asterisks (not the value), and a backspace at the
11425    // end of the masked element clears the buffer atomically. None of the
11426    // assertions reference the secret string directly — only its length
11427    // and the renderer's symbol output.
11428
11429    /// Replicate the secure-input render path against an [`OverlaySecureInput`]
11430    /// so each test can build it without going through `materialize_overlay`.
11431    fn render_secure_to_text(secure: &OverlaySecureInput) -> String {
11432        use ratatui::{Terminal, backend::TestBackend};
11433        let backend = TestBackend::new(80, 24);
11434        let mut terminal = Terminal::new(backend).unwrap();
11435        let overlay = OverlayState {
11436            title: "OpenAI key".into(),
11437            lines: vec!["Paste your API key".into()],
11438            items: Vec::new(),
11439            selected: 0,
11440            search: None,
11441            secure_input: Some(secure.clone()),
11442            ..Default::default()
11443        };
11444        terminal
11445            .draw(|f| render_overlay(f, f.area(), &overlay))
11446            .unwrap();
11447        terminal
11448            .backend()
11449            .buffer()
11450            .content()
11451            .iter()
11452            .map(|c| c.symbol())
11453            .collect::<Vec<_>>()
11454            .join("")
11455    }
11456
11457    #[test]
11458    fn masked_render_shows_asterisks_not_value() {
11459        // The render path must NEVER carry the real value through a
11460        // `Line` span when `mask_input` is on. We assert on the rendered
11461        // buffer symbols only — the secret lives only in `editor.text()`.
11462        let mut editor = oxicode_textarea::EditBuffer::new();
11463        let _ = editor.insert_str("ABCDE");
11464        let rendered = render_secure_to_text(&OverlaySecureInput {
11465            config: SecurePromptConfig {
11466                label: "Key".into(),
11467                placeholder: Some("sk-...".into()),
11468                mask_input: true,
11469            },
11470            editor,
11471        });
11472        assert!(rendered.contains("*****"), "mask must render asterisks");
11473        assert!(
11474            !rendered.contains("ABCDE"),
11475            "masked render must NEVER carry the real value"
11476        );
11477        assert!(rendered.contains("Key:"), "label prefix must still render");
11478    }
11479
11480    #[test]
11481    fn masked_render_caret_lands_after_mask() {
11482        // After a value is set the caret must sit at the end of the
11483        // masked element (atomic boundary). The exact column is the
11484        // label-prefix width plus the masked width — both are stable.
11485        let mut editor = oxicode_textarea::EditBuffer::new();
11486        let _ = editor.insert_str("ABCD");
11487        let secure = OverlaySecureInput {
11488            config: SecurePromptConfig {
11489                label: "Key".into(),
11490                placeholder: Some("sk-...".into()),
11491                mask_input: true,
11492            },
11493            editor,
11494        };
11495        // Drive the same render path used by the production renderer to
11496        // pull the caret column out via `cursor_pos_with_state`.
11497        use ratatui::{Terminal, backend::TestBackend};
11498        let backend = TestBackend::new(80, 24);
11499        let mut terminal = Terminal::new(backend).unwrap();
11500        let overlay = OverlayState {
11501            title: "OpenAI key".into(),
11502            lines: vec!["Paste your API key".into()],
11503            items: Vec::new(),
11504            selected: 0,
11505            search: None,
11506            secure_input: Some(secure.clone()),
11507            ..Default::default()
11508        };
11509        terminal
11510            .draw(|f| render_overlay(f, f.area(), &overlay))
11511            .unwrap();
11512        // Build the masked textarea identically and ask for its cursor
11513        // column relative to the same area the renderer uses.
11514        let value = secure.editor.text();
11515        let mut ta = oxicode_textarea::TextArea::new();
11516        ta.set_text(value);
11517        ta.replace_range_with_element(
11518            0..value.len(),
11519            value,
11520            MASKED_ELEMENT_KIND,
11521            Some(Line::from("*".repeat(value.chars().count()))),
11522        );
11523        ta.set_cursor(secure.editor.cursor_byte());
11524        let caret = ta
11525            .cursor_pos_with_state(
11526                Rect {
11527                    x: 0,
11528                    y: 0,
11529                    width: 80,
11530                    height: 24,
11531                },
11532                oxicode_textarea::TextAreaState::default(),
11533            )
11534            .expect("caret must be visible");
11535        // The masked element covers 0..4, so the textarea's cursor snaps
11536        // to its end boundary and reports column 4 relative to the area.
11537        assert_eq!(caret.0, 4);
11538    }
11539
11540    #[test]
11541    fn backspace_removes_previous_grapheme() {
11542        // The masked element renders the whole buffer as asterisks, but
11543        // `EditBuffer` operates grapheme-by-grapheme — the textarea's
11544        // element bookkeeping only affects cursor snapping at render
11545        // time, not the editor's edit primitives. Pin both halves of the
11546        // contract so a future port that changes either side is caught.
11547        let mut editor = oxicode_textarea::EditBuffer::new();
11548        let _ = editor.insert_str("XYZ");
11549        assert_eq!(editor.text(), "XYZ");
11550        assert_eq!(editor.cursor_byte(), 3);
11551        let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11552        assert_eq!(editor.text(), "XY");
11553        assert_eq!(editor.cursor_byte(), 2);
11554        let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11555        assert_eq!(editor.text(), "X");
11556        let _ = editor.apply(oxicode_textarea::EditCommand::DeleteGraphemeBackward);
11557        assert_eq!(editor.text(), "");
11558        assert_eq!(editor.cursor_byte(), 0);
11559    }
11560
11561    #[test]
11562    fn empty_editor_renders_placeholder_not_asterisks() {
11563        // Pin the empty-buffer render path: placeholder text, zero
11564        let rendered = render_secure_to_text(&OverlaySecureInput {
11565            config: SecurePromptConfig {
11566                label: "Key".into(),
11567                placeholder: Some("sk-...".into()),
11568                mask_input: true,
11569            },
11570            editor: oxicode_textarea::EditBuffer::new(),
11571        });
11572        assert!(rendered.contains("sk-..."));
11573        assert!(!rendered.contains("*"));
11574    }
11575
11576    #[test]
11577    fn paste_filter_drops_newline_and_non_ascii_via_edit_command() {
11578        // The paste path now feeds `EditCommand::Insert` per character
11579        // after the same ASCII + newline filter the helper used to apply.
11580        // Re-pinning the contract here means a regression in the filter
11581        // shows up directly as a test failure.
11582        let mut editor = oxicode_textarea::EditBuffer::new();
11583        let pasted = "sk-xyz\nABC\u{1F600}";
11584        let trimmed = pasted.trim_end_matches('\n');
11585        for ch in trimmed.chars() {
11586            if ch.is_ascii_graphic() || ch == ' ' {
11587                let _ = editor.apply(oxicode_textarea::EditCommand::Insert(ch));
11588            }
11589        }
11590        assert_eq!(editor.text(), "sk-xyzABC");
11591        assert_eq!(editor.cursor_byte(), 9);
11592    }
11593}
11594// ═════════════════════════════════════════════════════════════════════════
11595// `/providers` overlay chaining — regression for the bug where the
11596// `OverlayEvent::Submitted` arm closed the current overlay
11597// unconditionally, even when the handler opened a fresh overlay (action
11598// menu, secure prompt). The cmd channel processes `ShowOverlay` and
11599// `CloseOverlay` in submit order, so a `CloseOverlay` enqueued right
11600// after the `ShowOverlay` from the action menu won — leaving the user
11601// with nothing visible on Enter.
11602// ═════════════════════════════════════════════════════════════════════════
11603
11604#[cfg(test)]
11605mod provider_overlay_tests {
11606    use super::*;
11607    use crate::app::agent_session::{AgentSession, AgentSessionHandle};
11608    use crate::store::session::SessionManager;
11609    use crate::store::settings::Settings;
11610    use oxicode_agent::{Agent, AgentConfig};
11611    use oxicode_sdk::{Provider, ProviderError, ProviderEvent};
11612    use oxicode_vtui::tui::core::OverlayEvent;
11613    use std::pin::Pin;
11614    use std::sync::Arc;
11615    use std::task::{Context as TaskContext, Poll};
11616
11617    /// Minimal mock provider — produces an empty stream so `AgentSession`
11618    /// can construct (the `ProviderRow` dispatch never streams).
11619    struct EmptyStream;
11620    impl futures::Stream for EmptyStream {
11621        type Item = ProviderEvent;
11622        fn poll_next(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
11623            Poll::Ready(None)
11624        }
11625    }
11626
11627    struct StubProvider;
11628    impl Provider for StubProvider {
11629        fn stream<'a>(
11630            &'a self,
11631            _model: &'a oxicode_sdk::Model,
11632            _context: &'a oxicode_sdk::Context,
11633            _options: Option<oxicode_sdk::StreamOptions>,
11634        ) -> Pin<
11635            Box<
11636                dyn Future<
11637                        Output = Result<
11638                            Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>,
11639                            ProviderError,
11640                        >,
11641                    > + Send
11642                    + 'a,
11643            >,
11644        > {
11645            Box::pin(async move {
11646                Ok::<_, ProviderError>(Box::pin(EmptyStream)
11647                    as Pin<Box<dyn futures::Stream<Item = ProviderEvent> + Send>>)
11648            })
11649        }
11650    }
11651
11652    /// Minimal `AgentTool` fixture: name + essential flag, execute is a
11653    /// stub. Used by `make_session_with_tools_for_tests`.
11654    struct StubEssentialTool {
11655        name: &'static str,
11656        essential: bool,
11657    }
11658    #[async_trait::async_trait]
11659    impl oxicode_agent::AgentTool for StubEssentialTool {
11660        fn name(&self) -> &str {
11661            self.name
11662        }
11663        fn label(&self) -> &str {
11664            self.name
11665        }
11666        fn description(&self) -> &str {
11667            "stub tool for multiselect editor tests"
11668        }
11669        fn parameters_schema(&self) -> serde_json::Value {
11670            serde_json::json!({ "type": "object", "properties": {} })
11671        }
11672        fn essential(&self) -> bool {
11673            self.essential
11674        }
11675        async fn execute(
11676            &self,
11677            _id: &str,
11678            _params: serde_json::Value,
11679            _signal: Option<tokio::sync::oneshot::Receiver<()>>,
11680            _ctx: &oxicode_agent::ToolContext,
11681        ) -> Result<oxicode_agent::AgentToolResult, String> {
11682            Ok(oxicode_agent::AgentToolResult::success("ok"))
11683        }
11684    }
11685
11686    fn make_session() -> AgentSessionHandle {
11687        let provider = Arc::new(StubProvider);
11688        let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11689        let agent = Arc::new(Agent::new(
11690            provider,
11691            config,
11692            Arc::new(oxicode_agent::ToolRegistry::new()),
11693        ));
11694        let settings = Settings::default();
11695        let session_manager = SessionManager::in_memory("/tmp/test_providers");
11696        let session = AgentSession::new(
11697            agent,
11698            settings,
11699            session_manager,
11700            "/tmp/test_providers".to_string(),
11701            crate::SessionState::default(),
11702        );
11703        session.clone_handle()
11704    }
11705
11706    /// Session fixture with a registry holding one essential (`bash`)
11707    /// and one optional (`commit`) tool — used by the settings-panel
11708    /// multiselect editor tests (they source their row list from the
11709    /// live registry). `pub(super)` so sibling test mods can reuse the
11710    /// provider stub without duplicating it.
11711    pub(super) fn make_session_with_tools_for_tests() -> AgentSessionHandle {
11712        let provider = Arc::new(StubProvider);
11713        let config = AgentConfig::new("anthropic/claude-sonnet-4-20250514");
11714        let registry = oxicode_agent::ToolRegistry::new();
11715        registry.register_arc(Arc::new(StubEssentialTool {
11716            name: "bash",
11717            essential: true,
11718        }));
11719        registry.register_arc(Arc::new(StubEssentialTool {
11720            name: "commit",
11721            essential: false,
11722        }));
11723        let agent = Arc::new(Agent::new(provider, config, Arc::new(registry)));
11724        let settings = Settings::default();
11725        let session_manager = SessionManager::in_memory("/tmp/test_providers");
11726        let session = AgentSession::new(
11727            agent,
11728            settings,
11729            session_manager,
11730            "/tmp/test_providers".to_string(),
11731            crate::SessionState::default(),
11732        );
11733        session.clone_handle()
11734    }
11735
11736    /// `InlineCommand` does not implement `Debug`, so summarise the channel
11737    /// contents by command variant for assertion failure messages.
11738    fn summarise(cmds: &[InlineCommand]) -> String {
11739        let mut show = 0;
11740        let mut close = 0;
11741        let mut other = 0;
11742        for c in cmds {
11743            match c {
11744                InlineCommand::ShowOverlay { .. } => show += 1,
11745                InlineCommand::CloseOverlay => close += 1,
11746                _ => other += 1,
11747            }
11748        }
11749        format!("[ShowOverlay={show}, CloseOverlay={close}, other={other}]")
11750    }
11751
11752    /// Regression: `/providers` row selection for an OAuth-capable
11753    /// provider with no stored key triggers the multi-action chain
11754    /// `[SetApiKey, StartOAuth]` → `handle.show_list_modal` opens the
11755    /// action menu. The bug closed that menu instantly. The fix tracks
11756    /// whether the handler opened a new overlay and only emits the
11757    /// trailing `close_overlay()` when nothing was opened.
11758    #[test]
11759    fn provider_row_opens_action_menu_without_close() {
11760        // openai is OAuth-capable (per `product-meta.toml`), no key in
11761        // the env / storage, so the action matrix returns the
11762        // multi-action list.
11763        let session = make_session();
11764        let mut state = RenderState::default();
11765        state.overlay_providers = vec!["openai".to_string()];
11766        state.overlay = Some(OverlayState {
11767            title: "Providers".to_string(),
11768            lines: Vec::new(),
11769            items: Vec::new(),
11770            selected: 0,
11771            search: None,
11772            secure_input: None,
11773            ..Default::default()
11774        });
11775
11776        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11777        let handle = InlineHandle::new_for_tests(cmd_tx);
11778        let prompt_queue = Arc::new(PromptQueue::default());
11779
11780        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11781            InlineListSelection::ProviderRow(0),
11782        )));
11783        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11784
11785        let cmds: Vec<InlineCommand> = {
11786            let mut out = Vec::new();
11787            while let Ok(cmd) = cmd_rx.try_recv() {
11788                out.push(cmd);
11789            }
11790            out
11791        };
11792        let show_count = cmds
11793            .iter()
11794            .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11795            .count();
11796        assert_eq!(
11797            show_count,
11798            1,
11799            "submitting a provider row must ShowOverlay exactly once (commands: {})",
11800            summarise(&cmds)
11801        );
11802
11803        // The bug: a `CloseOverlay` followed the `ShowOverlay` on the
11804        // cmd channel and won the order-of-application race. After the
11805        // fix, no `CloseOverlay` may follow the `ShowOverlay`.
11806        let show_idx = cmds
11807            .iter()
11808            .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11809            .expect("ShowOverlay must be present");
11810        let trailing = &cmds[show_idx + 1..];
11811        assert!(
11812            !trailing
11813                .iter()
11814                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11815            "no CloseOverlay may follow the action-menu ShowOverlay (commands: {})",
11816            summarise(&cmds)
11817        );
11818
11819        // Stale-state cleanup must still run so future `/providers`
11820        // does not see stale indices.
11821        assert!(
11822            state.overlay_providers.is_empty(),
11823            "overlay_providers must be cleared after dispatch (got {:?})",
11824            state.overlay_providers
11825        );
11826    }
11827
11828    /// Regression: `/providers` row selection for a key-only provider
11829    /// (no OAuth spec) with no stored key triggers the single-action
11830    /// chain `[SetApiKey]` → `handle_auth_action` opens the secure
11831    /// prompt modal. The bug closed that modal instantly. The fix
11832    /// propagates the `opened_new_overlay` flag through `|=` so the
11833    /// secure prompt survives.
11834    #[test]
11835    fn provider_row_set_api_key_opens_secure_prompt_without_close() {
11836        // cerebras is key-only (no OAuth spec in `product-meta.toml`).
11837        let session = make_session();
11838        let mut state = RenderState::default();
11839        state.overlay_providers = vec!["cerebras".to_string()];
11840        state.overlay = Some(OverlayState {
11841            title: "Providers".to_string(),
11842            lines: Vec::new(),
11843            items: Vec::new(),
11844            selected: 0,
11845            search: None,
11846            secure_input: None,
11847            ..Default::default()
11848        });
11849
11850        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11851        let handle = InlineHandle::new_for_tests(cmd_tx);
11852        let prompt_queue = Arc::new(PromptQueue::default());
11853
11854        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11855            InlineListSelection::ProviderRow(0),
11856        )));
11857        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11858
11859        let cmds: Vec<InlineCommand> = {
11860            let mut out = Vec::new();
11861            while let Ok(cmd) = cmd_rx.try_recv() {
11862                out.push(cmd);
11863            }
11864            out
11865        };
11866        let show_count = cmds
11867            .iter()
11868            .filter(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11869            .count();
11870        assert_eq!(
11871            show_count,
11872            1,
11873            "submitting a provider row must ShowOverlay exactly once (commands: {})",
11874            summarise(&cmds)
11875        );
11876
11877        let show_idx = cmds
11878            .iter()
11879            .position(|c| matches!(c, InlineCommand::ShowOverlay { .. }))
11880            .expect("ShowOverlay must be present");
11881        let trailing = &cmds[show_idx + 1..];
11882        assert!(
11883            !trailing
11884                .iter()
11885                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11886            "no CloseOverlay may follow the secure-prompt ShowOverlay (commands: {})",
11887            summarise(&cmds)
11888        );
11889
11890        // The secure prompt origin must be stashed so a subsequent
11891        // `SecureInput` submission routes the key to the right provider
11892        // and emits a contextual follow-up message.
11893        assert_eq!(
11894            state.secure_input_origin,
11895            Some(SecureInputOrigin::SetKey {
11896                provider: "cerebras".to_string(),
11897            }),
11898            "secure_input_origin must be stashed by SetApiKey"
11899        );
11900    }
11901
11902    /// Catalog model selection (the working baseline) must remain
11903    /// closing — pinning the behavior so the conditional close does
11904    /// not regress the other `Submitted` branches.
11905    #[test]
11906    fn catalog_model_selection_still_closes_overlay() {
11907        let session = make_session();
11908        let mut state = RenderState::default();
11909        state.overlay_catalog_models = vec![(
11910            "anthropic".to_string(),
11911            "claude-sonnet-4-20250514".to_string(),
11912        )];
11913        state.overlay = Some(OverlayState {
11914            title: "Models".to_string(),
11915            lines: Vec::new(),
11916            items: Vec::new(),
11917            selected: 0,
11918            search: None,
11919            secure_input: None,
11920            ..Default::default()
11921        });
11922
11923        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
11924        let handle = InlineHandle::new_for_tests(cmd_tx);
11925        let prompt_queue = Arc::new(PromptQueue::default());
11926
11927        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
11928            InlineListSelection::CatalogModel(0),
11929        )));
11930        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
11931
11932        let cmds: Vec<InlineCommand> = {
11933            let mut out = Vec::new();
11934            while let Ok(cmd) = cmd_rx.try_recv() {
11935                out.push(cmd);
11936            }
11937            out
11938        };
11939        assert!(
11940            cmds.iter()
11941                .any(|c| matches!(c, InlineCommand::CloseOverlay)),
11942            "catalog model selection must close the overlay (commands: {})",
11943            summarise(&cmds)
11944        );
11945    }
11946
11947    /// `add_custom_provider` chains into the secure prompt via
11948    /// `open_secure_prompt` with `SecureInputOrigin::NewlyAdded`. The
11949    /// provider must be reachable from either variant so the
11950    /// `OverlaySubmission::SecureInput` consumer routes the key to the
11951    /// right slot without a per-variant branch.
11952    fn secure_input_origin_carries_provider_independently_of_variant() {
11953        let set = SecureInputOrigin::SetKey {
11954            provider: "openai".to_string(),
11955        };
11956        let added = SecureInputOrigin::NewlyAdded {
11957            provider: "minimax".to_string(),
11958        };
11959        // `provider` must be reachable regardless of variant so the
11960        // `OverlaySubmission::SecureInput` consumer can route the key
11961        // without a per-variant branch. (The model-role origins carry
11962        // no provider — they route through their own arm.)
11963        let provider_of = |o: &SecureInputOrigin| match o {
11964            SecureInputOrigin::SetKey { provider } | SecureInputOrigin::NewlyAdded { provider } => {
11965                provider.clone()
11966            }
11967            SecureInputOrigin::ModelRoleKey | SecureInputOrigin::ModelRoleValue { .. } => {
11968                unreachable!("model-role origins have no provider")
11969            }
11970            SecureInputOrigin::TextEdit(_) => {
11971                unreachable!("text-edit origin has no provider")
11972            }
11973        };
11974        assert_eq!(provider_of(&set), "openai");
11975        assert_eq!(provider_of(&added), "minimax");
11976        // Variants are distinct (so the follow-up message can branch).
11977        assert_ne!(set, added);
11978    }
11979
11980    /// Regression: the `/sessions` picker arm previously set
11981    /// `state.pending_resume` without the `is_streaming()` gate that the
11982    /// direct `/sessions <id>` path and `/handoff` both use. A mid-stream
11983    /// pick + Enter fired the drain, which calls `resume_from_file` →
11984    /// `AgentSession::new` → `agent.update_state` on the shared
11985    /// `Arc<Agent>`, clobbering the in-flight conversation's message
11986    /// history. The picker now refuses with the same error wording as
11987    /// the direct path and never sets `pending_resume` while streaming.
11988    #[test]
11989    fn session_picker_resume_refused_while_streaming() {
11990        let session = make_session();
11991        // Flip the streaming flag BEFORE dispatch so the gate fires.
11992        // `streaming_flag()` returns an `Arc<AtomicBool>` shared with the
11993        // worker thread, so the production code observes the new value.
11994        session
11995            .streaming_flag()
11996            .store(true, std::sync::atomic::Ordering::SeqCst);
11997
11998        let mut state = RenderState::default();
11999        // Sanity: no resume queued yet.
12000        assert!(
12001            state.pending_resume.is_none(),
12002            "precondition: pending_resume must start None"
12003        );
12004
12005        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12006        let handle = InlineHandle::new_for_tests(cmd_tx);
12007        let prompt_queue = Arc::new(PromptQueue::default());
12008
12009        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12010            InlineListSelection::Session("some-id".to_string()),
12011        )));
12012        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12013
12014        // The gate must have refused: pending_resume stays None.
12015        assert!(
12016            state.pending_resume.is_none(),
12017            "streaming session must not enqueue pending_resume (got {:?})",
12018            state.pending_resume
12019        );
12020
12021        // Drain the handle's cmd channel and inspect appended lines.
12022        let mut cmds: Vec<InlineCommand> = Vec::new();
12023        while let Ok(cmd) = cmd_rx.try_recv() {
12024            cmds.push(cmd);
12025        }
12026        let mut found_error = false;
12027        let mut error_text = String::new();
12028        for cmd in &cmds {
12029            if let InlineCommand::AppendLine { kind, segments } = cmd
12030                && matches!(kind, InlineMessageKind::Error)
12031            {
12032                error_text = segments
12033                    .iter()
12034                    .map(|s| s.text.as_str())
12035                    .collect::<Vec<_>>()
12036                    .join("");
12037                found_error = true;
12038            }
12039        }
12040        assert!(
12041            found_error,
12042            "expected an error AppendLine (commands: {})",
12043            summarise(&cmds)
12044        );
12045        assert!(
12046            error_text.contains("Cannot resume while agent is running"),
12047            "error text must match the direct-path wording (got {error_text:?})"
12048        );
12049
12050        // Cleanup: reset streaming so the flag doesn't leak across tests
12051        // in the same process.
12052        session
12053            .streaming_flag()
12054            .store(false, std::sync::atomic::Ordering::SeqCst);
12055    }
12056
12057    /// `/settings` tab switch: submitting `SettingsTab(1)` must reopen the
12058    /// panel rebuilt for tab 1 (Model) — tab bar, sidebar sections, and
12059    /// the def-table rows for that tab — without emitting a CloseOverlay.
12060    #[test]
12061    fn settings_tab_selection_rebuilds_item_list() {
12062        let session = make_session();
12063        let mut state = RenderState::default();
12064        // Enter already closed the overlay before the submission arrives.
12065        state.overlay = None;
12066
12067        let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<InlineCommand>();
12068        let handle = InlineHandle::new_for_tests(cmd_tx);
12069        let prompt_queue = Arc::new(PromptQueue::default());
12070
12071        let evt = InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Selection(
12072            InlineListSelection::SettingsTab(1),
12073        )));
12074        let _ = handle_inline_event(&mut state, &handle, &session, &prompt_queue, evt);
12075
12076        let overlay = state.overlay.as_ref().expect("panel reopened on tab 1");
12077        assert_eq!(overlay.active_tab, 1);
12078        assert_eq!(overlay.tabs.get(1).map(String::as_str), Some("Model"));
12079        assert_eq!(
12080            overlay.sections,
12081            vec!["Defaults".to_string(), "Pointers".to_string()]
12082        );
12083        // Rows come from the def table for the Model tab.
12084        let settings = Settings::load().unwrap_or_default();
12085        let expected = settings_overlay_items(SettingsTab::Model, &settings).0;
12086        assert_eq!(overlay.items.len(), expected.len());
12087        assert_eq!(
12088            overlay
12089                .items
12090                .iter()
12091                .map(|i| i.title.clone())
12092                .collect::<Vec<_>>(),
12093            expected.iter().map(|i| i.title.clone()).collect::<Vec<_>>(),
12094        );
12095        assert_eq!(state.settings_active_tab, SettingsTab::Model);
12096        // The switch reopens in place — no close may leak through the
12097        // cmd channel.
12098        while let Ok(cmd) = cmd_rx.try_recv() {
12099            assert!(
12100                !matches!(cmd, InlineCommand::CloseOverlay),
12101                "tab switch must not close the reopened panel"
12102            );
12103        }
12104    }
12105}
12106#[cfg(test)]
12107mod thinking_stream_tests {
12108    //! Regression: a `StreamDelta::Thinking` delta must (a) never append
12109    //! to the transcript, and (b) only set a fixed `thinking…` label on
12110    //! the reasoning stage — never the streamed fragment. Raw reasoning
12111    //! fragments would otherwise leak through two render surfaces
12112    //! (the composer `RUN ` field in `composer_context_line`, and the
12113    //! reasoning indicator above the composer).
12114    use super::*;
12115    use oxicode_ai::{Api, AssistantMessage, ContentBlock, Message, TextContent};
12116    use oxicode_vtui::tui::core::InlineHandle;
12117    use tokio::sync::mpsc;
12118
12119    fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
12120        let (tx, rx) = mpsc::unbounded_channel();
12121        (InlineHandle::new_for_tests(tx), rx)
12122    }
12123
12124    fn assistant() -> Message {
12125        Message::Assistant(AssistantMessage::new(
12126            Api::OpenAiCompletions,
12127            "test",
12128            "test",
12129        ))
12130    }
12131
12132    fn assistant_with_text(text: &str) -> Message {
12133        let mut a = AssistantMessage::new(Api::OpenAiCompletions, "test", "test");
12134        a.content.push(ContentBlock::Text(TextContent::new(text)));
12135        Message::Assistant(a)
12136    }
12137    #[test]
12138    fn thinking_delta_sets_fixed_stage_label_not_raw_text() {
12139        let mut state = RenderState::default();
12140        let (handle, mut cmd_rx) = fresh_handle();
12141
12142        // The exact text the model streamed for reasoning MUST NOT appear
12143        // in the stage indicator — it renders in the transcript's dimmed
12144        // reasoning block instead.
12145        let event = AgentEvent::MessageUpdate {
12146            message: assistant(),
12147            delta: oxicode_sdk::StreamDelta::Thinking("considering options".into()),
12148        };
12149        map_agent_event(&handle, event, &mut state);
12150
12151        assert_eq!(
12152            state.reasoning_stage.as_deref(),
12153            Some("thinking\u{2026}"),
12154            "reasoning stage must show a fixed label, never the streamed fragment"
12155        );
12156        while let Ok(cmd) = cmd_rx.try_recv() {
12157            assert!(
12158                !matches!(cmd, InlineCommand::Inline { .. }),
12159                "thinking must not emit a transcript Inline command"
12160            );
12161        }
12162    }
12163
12164    #[test]
12165    fn thinking_streams_as_dimmed_block_above_the_answer() {
12166        let mut state = RenderState::default();
12167        let (handle, mut rx) = fresh_handle();
12168
12169        map_agent_event(
12170            &handle,
12171            AgentEvent::MessageStart {
12172                message: assistant(),
12173            },
12174            &mut state,
12175        );
12176        map_agent_event(
12177            &handle,
12178            AgentEvent::MessageUpdate {
12179                message: assistant(),
12180                delta: oxicode_sdk::StreamDelta::Thinking("weighing alternatives".into()),
12181            },
12182            &mut state,
12183        );
12184        apply_all(&mut state, &mut rx);
12185        let text: String = state
12186            .transcript
12187            .iter()
12188            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12189            .collect::<Vec<_>>()
12190            .join(" ");
12191        assert!(
12192            text.contains("weighing alternatives"),
12193            "thinking must render in the transcript: {text}"
12194        );
12195        let dim_italic = state.transcript.iter().any(|l| {
12196            l.segments.iter().any(|s| {
12197                let st = s.style.as_ref();
12198                st.effects.contains(anstyle::Effects::DIMMED)
12199                    && st.effects.contains(anstyle::Effects::ITALIC)
12200            })
12201        });
12202        assert!(
12203            dim_italic,
12204            "thinking lines render in the dimmed italic reasoning style"
12205        );
12206
12207        // The answer streams below the thinking block, and thinking survives.
12208        map_agent_event(
12209            &handle,
12210            AgentEvent::MessageUpdate {
12211                message: assistant(),
12212                delta: oxicode_sdk::StreamDelta::Text("the answer".into()),
12213            },
12214            &mut state,
12215        );
12216        apply_all(&mut state, &mut rx);
12217        type_out_stream(&mut state);
12218
12219        // One blank row breathes between the thinking block and the answer.
12220        let texts: Vec<String> = state
12221            .transcript
12222            .iter()
12223            .map(|l| {
12224                l.segments
12225                    .iter()
12226                    .map(|s| s.text.as_str())
12227                    .collect::<String>()
12228            })
12229            .collect();
12230        let think_idx = texts
12231            .iter()
12232            .position(|t| t.contains("weighing alternatives"))
12233            .expect("thinking line");
12234        let answer_idx = texts
12235            .iter()
12236            .position(|t| t.contains("the answer"))
12237            .expect("answer line");
12238        assert!(
12239            answer_idx > think_idx,
12240            "answer renders below thinking: {texts:?}"
12241        );
12242        assert_eq!(
12243            state.reasoning_stage.as_deref(),
12244            Some("generating response"),
12245            "the stage label moves on once the answer streams"
12246        );
12247    }
12248
12249    #[test]
12250    fn tool_lines_survive_the_full_turn_event_sequence() {
12251        let mut state = RenderState::default();
12252        let (handle, mut rx) = fresh_handle();
12253
12254        // Real order (agent_loop): assistant text → MessageEnd → ToolStart
12255        // → ToolComplete → ToolResult(MessageStart+MessageEnd) → next text.
12256        map_agent_event(
12257            &handle,
12258            AgentEvent::MessageStart {
12259                message: assistant(),
12260            },
12261            &mut state,
12262        );
12263        map_agent_event(
12264            &handle,
12265            AgentEvent::MessageUpdate {
12266                message: assistant(),
12267                delta: oxicode_sdk::StreamDelta::Text("I will check.".into()),
12268            },
12269            &mut state,
12270        );
12271        map_agent_event(
12272            &handle,
12273            AgentEvent::MessageEnd {
12274                message: assistant(),
12275            },
12276            &mut state,
12277        );
12278        map_agent_event(
12279            &handle,
12280            AgentEvent::ToolExecutionStart {
12281                tool_call_id: "tc1".into(),
12282                tool_name: "bash".into(),
12283                args: serde_json::json!({"command": "echo hi"}),
12284                intent: None,
12285                context: None,
12286            },
12287            &mut state,
12288        );
12289        map_agent_event(
12290            &handle,
12291            AgentEvent::ToolExecutionEnd {
12292                tool_call_id: "tc1".into(),
12293                tool_name: "bash".into(),
12294                intent: None,
12295                result: oxicode_ai::ToolResult {
12296                    tool_call_id: "tc1".into(),
12297                    content: "ls output".into(),
12298                    status: "success".into(),
12299                },
12300                is_error: false,
12301            },
12302            &mut state,
12303        );
12304        map_agent_event(
12305            &handle,
12306            AgentEvent::MessageStart {
12307                message: assistant(),
12308            },
12309            &mut state,
12310        );
12311        map_agent_event(
12312            &handle,
12313            AgentEvent::MessageUpdate {
12314                message: assistant(),
12315                delta: oxicode_sdk::StreamDelta::Text("All done.".into()),
12316            },
12317            &mut state,
12318        );
12319        map_agent_event(
12320            &handle,
12321            AgentEvent::MessageEnd {
12322                message: assistant(),
12323            },
12324            &mut state,
12325        );
12326        apply_all(&mut state, &mut rx);
12327
12328        let text: String = state
12329            .transcript
12330            .iter()
12331            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12332            .collect::<Vec<_>>()
12333            .join("|");
12334        assert!(
12335            text.contains("$ echo hi"),
12336            "box header shows the shell command: {text}"
12337        );
12338        assert!(
12339            text.contains("Output") && text.contains("ls output"),
12340            "labeled divider separates the call from its output: {text}"
12341        );
12342        assert!(
12343            text.contains("\u{256D}") && text.contains("\u{2570}"),
12344            "rounded top and bottom borders close the box: {text}"
12345        );
12346        // The whole box is ONE block: folding and scrollback commits stay
12347        // atomic per call.
12348        let block_ids: std::collections::HashSet<usize> = state
12349            .transcript
12350            .iter()
12351            .filter(|l| l.kind == InlineMessageKind::Tool)
12352            .map(|l| l.block_id)
12353            .collect();
12354        assert_eq!(block_ids.len(), 1, "one tool call = one block");
12355    }
12356    #[test]
12357    fn first_text_delta_overrides_thinking_stage_with_generating_response() {
12358        let mut state = RenderState::default();
12359        let (handle, _cmd_rx) = fresh_handle();
12360
12361        // The real streaming path emits Thinking and Text deltas as
12362        // `AgentEvent::MessageUpdate { delta: StreamDelta::* }`
12363        // (oxicode-agent/src/agent_loop/streaming.rs:277-280). TextChunk
12364        // is legacy and no producer emits it. The Text arm is the
12365        // lifecycle owner that moves the stage off `thinking…`.
12366        map_agent_event(
12367            &handle,
12368            AgentEvent::MessageUpdate {
12369                message: assistant(),
12370                delta: oxicode_sdk::StreamDelta::Thinking("considering".into()),
12371            },
12372            &mut state,
12373        );
12374        map_agent_event(
12375            &handle,
12376            AgentEvent::MessageUpdate {
12377                message: assistant(),
12378                delta: oxicode_sdk::StreamDelta::Text("hi".into()),
12379            },
12380            &mut state,
12381        );
12382
12383        assert_eq!(
12384            state.reasoning_stage.as_deref(),
12385            Some("generating response"),
12386            "first Text delta must move the stage off `thinking\u{2026}`"
12387        );
12388    }
12389
12390    fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
12391        while let Ok(cmd) = rx.try_recv() {
12392            apply_command(state, cmd);
12393        }
12394    }
12395
12396    #[test]
12397    fn message_end_replaces_the_streamed_block_without_duplicates() {
12398        let mut state = RenderState::default();
12399        let (handle, mut rx) = fresh_handle();
12400
12401        map_agent_event(
12402            &handle,
12403            AgentEvent::MessageStart {
12404                message: assistant(),
12405            },
12406            &mut state,
12407        );
12408        map_agent_event(
12409            &handle,
12410            AgentEvent::MessageUpdate {
12411                message: assistant(),
12412                delta: oxicode_sdk::StreamDelta::Text("para one".into()),
12413            },
12414            &mut state,
12415        );
12416        map_agent_event(
12417            &handle,
12418            AgentEvent::MessageUpdate {
12419                message: assistant(),
12420                delta: oxicode_sdk::StreamDelta::Text("\n\npara two".into()),
12421            },
12422            &mut state,
12423        );
12424        map_agent_event(
12425            &handle,
12426            AgentEvent::MessageEnd {
12427                message: assistant_with_text("para one\n\npara two"),
12428            },
12429            &mut state,
12430        );
12431        apply_all(&mut state, &mut rx);
12432
12433        let text: String = state
12434            .transcript
12435            .iter()
12436            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12437            .collect::<Vec<_>>()
12438            .join(" ");
12439        assert_eq!(
12440            text.matches("para one").count(),
12441            1,
12442            "the markdown re-render must fully replace the streamed raw lines: {text}"
12443        );
12444    }
12445
12446    #[test]
12447    fn consecutive_messages_stream_into_separate_blocks() {
12448        let mut state = RenderState::default();
12449        let (handle, mut rx) = fresh_handle();
12450
12451        for body in ["first answer", "second answer"] {
12452            map_agent_event(
12453                &handle,
12454                AgentEvent::MessageStart {
12455                    message: assistant(),
12456                },
12457                &mut state,
12458            );
12459            map_agent_event(
12460                &handle,
12461                AgentEvent::MessageUpdate {
12462                    message: assistant(),
12463                    delta: oxicode_sdk::StreamDelta::Text(body.into()),
12464                },
12465                &mut state,
12466            );
12467            map_agent_event(
12468                &handle,
12469                AgentEvent::MessageEnd {
12470                    message: assistant_with_text(body),
12471                },
12472                &mut state,
12473            );
12474        }
12475        apply_all(&mut state, &mut rx);
12476
12477        let joined = state
12478            .transcript
12479            .iter()
12480            .map(|l| {
12481                l.segments
12482                    .iter()
12483                    .map(|s| s.text.as_str())
12484                    .collect::<String>()
12485            })
12486            .collect::<Vec<_>>()
12487            .join("|");
12488        assert!(
12489            joined.contains("first answer") && joined.contains("second answer"),
12490            "both messages survive: {joined}"
12491        );
12492        assert!(
12493            !joined.contains("first answersecond answer"),
12494            "a new message must not append into the previous message's line: {joined}"
12495        );
12496    }
12497
12498    #[test]
12499    fn text_deltas_render_markdown_live_not_raw() {
12500        let mut state = RenderState::default();
12501        let (handle, mut rx) = fresh_handle();
12502        map_agent_event(
12503            &handle,
12504            AgentEvent::MessageStart {
12505                message: assistant(),
12506            },
12507            &mut state,
12508        );
12509        map_agent_event(
12510            &handle,
12511            AgentEvent::MessageUpdate {
12512                message: assistant(),
12513                delta: oxicode_sdk::StreamDelta::Text("a **bold** claim".into()),
12514            },
12515            &mut state,
12516        );
12517        apply_all(&mut state, &mut rx);
12518        type_out_stream(&mut state);
12519
12520        let text = state
12521            .transcript
12522            .iter()
12523            .flat_map(|l| l.segments.iter().map(|s| s.text.as_str()))
12524            .collect::<Vec<_>>()
12525            .join(" ");
12526        assert!(
12527            !text.contains("**"),
12528            "the live stream must render markdown, not raw syntax: {text}"
12529        );
12530        assert!(text.contains("bold"), "content survives: {text}");
12531    }
12532
12533    #[test]
12534    fn message_end_does_not_reflow_the_streamed_block() {
12535        let mut state = RenderState::default();
12536        let (handle, mut rx) = fresh_handle();
12537        map_agent_event(
12538            &handle,
12539            AgentEvent::MessageStart {
12540                message: assistant(),
12541            },
12542            &mut state,
12543        );
12544        map_agent_event(
12545            &handle,
12546            AgentEvent::MessageUpdate {
12547                message: assistant(),
12548                delta: oxicode_sdk::StreamDelta::Text("hello **world**".into()),
12549            },
12550            &mut state,
12551        );
12552        apply_all(&mut state, &mut rx);
12553        type_out_stream(&mut state);
12554        let streamed: Vec<String> = state
12555            .transcript
12556            .iter()
12557            .map(|l| {
12558                l.segments
12559                    .iter()
12560                    .map(|s| s.text.as_str())
12561                    .collect::<String>()
12562            })
12563            .collect();
12564
12565        map_agent_event(
12566            &handle,
12567            AgentEvent::MessageEnd {
12568                message: assistant_with_text("hello **world**"),
12569            },
12570            &mut state,
12571        );
12572        apply_all(&mut state, &mut rx);
12573        let final_: Vec<String> = state
12574            .transcript
12575            .iter()
12576            .map(|l| {
12577                l.segments
12578                    .iter()
12579                    .map(|s| s.text.as_str())
12580                    .collect::<String>()
12581            })
12582            .collect();
12583        assert_eq!(
12584            streamed, final_,
12585            "MessageEnd must not re-render what the live stream already shows"
12586        );
12587    }
12588
12589    #[test]
12590    fn final_message_renders_the_authoritative_tail() {
12591        // Regression: providers can coalesce the stream tail into the
12592        // final Done message without a matching delta (the Done message
12593        // replaces the accumulated partial in agent_loop/streaming.rs).
12594        // Rendering the final block from the delta buffers lost that
12595        // tail until the next prompt rebuilt history from the session.
12596        let mut state = RenderState::default();
12597        let (handle, mut rx) = fresh_handle();
12598        map_agent_event(
12599            &handle,
12600            AgentEvent::MessageStart {
12601                message: assistant(),
12602            },
12603            &mut state,
12604        );
12605        map_agent_event(
12606            &handle,
12607            AgentEvent::MessageUpdate {
12608                message: assistant(),
12609                delta: oxicode_sdk::StreamDelta::Text("visible prefix ".into()),
12610            },
12611            &mut state,
12612        );
12613        apply_all(&mut state, &mut rx);
12614        type_out_stream(&mut state);
12615
12616        map_agent_event(
12617            &handle,
12618            AgentEvent::MessageEnd {
12619                message: assistant_with_text("visible prefix HIDDEN-TAIL"),
12620            },
12621            &mut state,
12622        );
12623        apply_all(&mut state, &mut rx);
12624        let text: String = state
12625            .transcript
12626            .iter()
12627            .map(|l| {
12628                l.segments
12629                    .iter()
12630                    .map(|s| s.text.as_str())
12631                    .collect::<String>()
12632            })
12633            .collect();
12634        assert!(
12635            text.contains("HIDDEN-TAIL"),
12636            "the final message is authoritative — its tail must render: {text}"
12637        );
12638        assert_eq!(
12639            text.matches("visible prefix").count(),
12640            1,
12641            "no duplicated block: {text}"
12642        );
12643    }
12644
12645    #[test]
12646    fn streamed_body_renders_only_the_revealed_prefix() {
12647        let mut state = RenderState::default();
12648        state.message_buffer = "hello world".to_string();
12649        state.stream_reveal = 5; // bytes — "hello"
12650        let lines = render_streamed_message(&mut state);
12651        let text: String = lines
12652            .iter()
12653            .map(|l| l.iter().map(|s| s.text.as_str()).collect::<String>())
12654            .collect();
12655        assert!(text.contains("hello"), "revealed prefix renders: {text}");
12656        assert!(!text.contains("world"), "unrevealed text waits: {text}");
12657    }
12658
12659    #[test]
12660    fn advance_stream_reveal_types_out_in_bounded_steps() {
12661        let mut state = RenderState::default();
12662        state.stream_anchor = Some(0);
12663        state.message_buffer = "x".repeat(6000);
12664        state.stream_reveal = 0;
12665
12666        assert!(advance_stream_reveal(&mut state), "first tick paints");
12667        assert!(
12668            state.stream_reveal > 0 && state.stream_reveal < 6000,
12669            "bounded step, not a lump: {}",
12670            state.stream_reveal
12671        );
12672        let transcript_after_step: usize = state.transcript.len();
12673        assert!(
12674            transcript_after_step > 0,
12675            "the revealed prefix lands in the transcript"
12676        );
12677
12678        while advance_stream_reveal(&mut state) {}
12679        assert_eq!(
12680            state.stream_reveal, 6000,
12681            "repeated ticks drain the backlog completely"
12682        );
12683    }
12684
12685    /// Drive the typewriter to completion (test-side stand-in for the
12686    /// render tick).
12687    fn type_out_stream(state: &mut RenderState) {
12688        while advance_stream_reveal(state) {}
12689    }
12690
12691    #[test]
12692    fn message_end_clears_reasoning_stage() {
12693        let mut state = RenderState::default();
12694        let (handle, _cmd_rx) = fresh_handle();
12695        state.reasoning_stage = Some("thinking\u{2026}".into());
12696
12697        map_agent_event(
12698            &handle,
12699            AgentEvent::MessageEnd {
12700                message: assistant(),
12701            },
12702            &mut state,
12703        );
12704
12705        assert!(
12706            state.reasoning_stage.is_none(),
12707            "MessageEnd must clear the reasoning stage so follow-ups / tips can render"
12708        );
12709    }
12710
12711    #[test]
12712    fn run_tracker_spans_the_whole_tool_loop() {
12713        let mut state = RenderState::default();
12714        let (handle, _cmd_rx) = fresh_handle();
12715
12716        map_agent_event(
12717            &handle,
12718            AgentEvent::AgentStart {
12719                prompts: vec![],
12720                session_id: None,
12721            },
12722            &mut state,
12723        );
12724        assert!(
12725            state.active_run.is_some(),
12726            "AgentStart opens the run tracker"
12727        );
12728
12729        map_agent_event(
12730            &handle,
12731            AgentEvent::MessageStart {
12732                message: assistant(),
12733            },
12734            &mut state,
12735        );
12736        map_agent_event(
12737            &handle,
12738            AgentEvent::ToolExecutionStart {
12739                tool_call_id: "tc-1".into(),
12740                tool_name: "read".into(),
12741                args: serde_json::json!({}),
12742                intent: None,
12743                context: None,
12744            },
12745            &mut state,
12746        );
12747        map_agent_event(
12748            &handle,
12749            AgentEvent::MessageEnd {
12750                message: assistant(),
12751            },
12752            &mut state,
12753        );
12754
12755        // Turn boundary: the stage may be cleared, but the run tracker —
12756        // with its progress facts — stays live until AgentEnd.
12757        let run = state.active_run.as_ref().expect("run stays live");
12758        assert_eq!(run.turn, 1, "MessageStart counts a turn");
12759        assert_eq!(run.tool_calls, 1, "ToolExecutionStart counts a call");
12760
12761        map_agent_event(
12762            &handle,
12763            AgentEvent::AgentEnd {
12764                messages: vec![],
12765                stop_reason: None,
12766                session_id: None,
12767            },
12768            &mut state,
12769        );
12770        assert!(state.active_run.is_none(), "AgentEnd closes the tracker");
12771        assert!(
12772            state.reasoning_stage.is_none(),
12773            "AgentEnd releases the indicator row"
12774        );
12775    }
12776
12777    #[test]
12778    fn message_end_releases_the_stream_anchor() {
12779        let mut state = RenderState::default();
12780        let (handle, mut rx) = fresh_handle();
12781        map_agent_event(
12782            &handle,
12783            AgentEvent::MessageStart {
12784                message: assistant(),
12785            },
12786            &mut state,
12787        );
12788        map_agent_event(
12789            &handle,
12790            AgentEvent::MessageUpdate {
12791                message: assistant(),
12792                delta: oxicode_sdk::StreamDelta::Text("done".into()),
12793            },
12794            &mut state,
12795        );
12796        map_agent_event(
12797            &handle,
12798            AgentEvent::MessageEnd {
12799                message: assistant(),
12800            },
12801            &mut state,
12802        );
12803        while let Ok(cmd) = rx.try_recv() {
12804            apply_command(&mut state, cmd);
12805        }
12806        assert!(
12807            state.stream_anchor.is_none(),
12808            "MessageEnd finalizes the message — the anchor must release so the finished block can commit to scrollback"
12809        );
12810    }
12811}
12812
12813#[cfg(test)]
12814mod composer_border_tests {
12815    //! The composer's top border is the single chrome surface after the
12816    //! status bar's removal: session facts + brain health, no app badge.
12817    use super::*;
12818
12819    fn spans_to_string(line: &Line<'_>) -> String {
12820        line.spans.iter().map(|s| s.content.as_ref()).collect()
12821    }
12822
12823    #[test]
12824    fn composer_border_has_no_app_badge() {
12825        let mut state = RenderState::default();
12826        state.header_context.provider = "prov".to_string();
12827        state.header_context.model = "prov/m-1".to_string();
12828
12829        let text = spans_to_string(&composer_context_line(&state, 200));
12830
12831        assert!(
12832            text.starts_with("MODEL "),
12833            "model leads with no leading separator: {text}"
12834        );
12835        assert!(
12836            text.contains("MODEL m-1"),
12837            "provider prefix is stripped from the model: {text}"
12838        );
12839    }
12840
12841    #[test]
12842    fn composer_border_fields_drop_by_width() {
12843        let mut state = RenderState::default();
12844        state.header_context.provider = "prov".to_string();
12845        state.header_context.model = "prov/m-1".to_string();
12846
12847        // Narrow: only the model survives; wide: context usage joins.
12848        let narrow = spans_to_string(&composer_context_line(&state, 60));
12849        assert!(
12850            narrow.contains("MODEL ") && !narrow.contains("CTX "),
12851            "narrow keeps the model only: {narrow}"
12852        );
12853        let wide = spans_to_string(&composer_context_line(&state, 140));
12854        assert!(wide.contains("CTX "), "wide carries context usage: {wide}");
12855    }
12856
12857    #[test]
12858    fn model_chips_follow_model_switch() {
12859        let mut state = RenderState::default();
12860        assert_eq!(state.context_window, 128_000, "default before sync");
12861
12862        // A 1M-context model must replace both the MODEL field and the
12863        // CTX denominator (regression: the denominator was written once
12864        // at startup and never updated).
12865        apply_model_to_chips(&mut state, "google/gemini-2.5-pro", 1_048_576);
12866        assert_eq!(state.header_context.provider, "google");
12867        assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12868        assert_eq!(
12869            state.header_context.editor_context.as_deref(),
12870            Some("google/gemini-2.5-pro")
12871        );
12872        assert_eq!(state.context_window, 1_048_576);
12873
12874        let wide = spans_to_string(&composer_context_line(&state, 140));
12875        assert!(
12876            wide.contains("CTX 0/1048.5K"),
12877            "CTX chip renders the synced denominator: {wide}"
12878        );
12879
12880        // Empty id is a no-op.
12881        apply_model_to_chips(&mut state, "", 999);
12882        assert_eq!(state.header_context.model, "google/gemini-2.5-pro");
12883
12884        // Zero window (unknown model): the MODEL chip follows the switch,
12885        // the CTX denominator keeps the last known value instead of 0.
12886        apply_model_to_chips(&mut state, "zai/glm-5.1", 0);
12887        assert_eq!(state.header_context.model, "zai/glm-5.1");
12888        assert_eq!(state.context_window, 1_048_576);
12889    }
12890
12891    #[test]
12892    fn plain_segments_render_in_their_kind_color_not_response() {
12893        let styles = active_styles();
12894        let user_color = color_from_anstyle(styles.user.get_fg_color());
12895        let response = color_from_anstyle(styles.response.get_fg_color());
12896        let line = |kind| TranscriptLine {
12897            kind,
12898            segments: vec![plain_segment("body")],
12899            block_id: 0,
12900        };
12901
12902        let user_line = line(InlineMessageKind::User);
12903        let user = transcript_line_marked(&user_line, &styles, false, false, false, true, 80);
12904        assert_eq!(
12905            user.spans[0].style.fg,
12906            Some(user_color),
12907            "user text must read in the user color — response-ink makes turns indistinguishable"
12908        );
12909
12910        let agent_line = line(InlineMessageKind::Agent);
12911        let agent = transcript_line_marked(&agent_line, &styles, false, false, false, true, 80);
12912        assert_eq!(agent.spans[0].style.fg, Some(response));
12913    }
12914}
12915#[cfg(test)]
12916mod transcript_turn_tests {
12917    //! Speaker identity is structural (accent rail + weight), never prose
12918    //! labels. See docs/superpowers/specs/2026-08-20-transcript-turn-rendering-design.md.
12919    use super::*;
12920    fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
12921        TranscriptLine {
12922            kind,
12923            segments: vec![plain_segment(text)],
12924            block_id,
12925        }
12926    }
12927
12928    fn spans_to_string(line: &Line<'_>) -> String {
12929        line.spans.iter().map(|s| s.content.as_ref()).collect()
12930    }
12931
12932    #[test]
12933    fn user_lines_are_bold_primary_without_prefix() {
12934        let styles = active_styles();
12935        let line = tl(InlineMessageKind::User, "refactor the parser", 0);
12936        let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12937        assert_eq!(
12938            rendered.spans.len(),
12939            1,
12940            "plain style renders no prefix span"
12941        );
12942        assert_eq!(
12943            spans_to_string(&rendered),
12944            "refactor the parser",
12945            "user text renders as typed, no glyph"
12946        );
12947        assert!(
12948            rendered.spans[0]
12949                .style
12950                .add_modifier
12951                .contains(Modifier::BOLD),
12952            "user body is the only bold transcript text"
12953        );
12954    }
12955
12956    #[test]
12957    fn agent_tool_and_shell_lines_have_no_prefix() {
12958        let styles = active_styles();
12959        for (kind, label) in [
12960            (InlineMessageKind::Agent, "agent"),
12961            (InlineMessageKind::Tool, "tool"),
12962            (InlineMessageKind::Pty, "shell"),
12963        ] {
12964            let line = tl(kind, &format!("{label}-content"), 0);
12965            let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12966            assert_eq!(
12967                rendered.spans.len(),
12968                1,
12969                "{label} lines carry no marker spans"
12970            );
12971            assert_eq!(spans_to_string(&rendered), format!("{label}-content"));
12972        }
12973    }
12974
12975    #[test]
12976    fn system_labels_render_on_block_start_only() {
12977        let styles = active_styles();
12978        let line = tl(InlineMessageKind::Error, "boom", 0);
12979
12980        let head = transcript_line_marked(&line, &styles, false, false, false, true, 80);
12981        assert_eq!(spans_to_string(&head), "error: boom");
12982
12983        let body = transcript_line_marked(&line, &styles, false, false, false, false, 80);
12984        assert_eq!(
12985            spans_to_string(&body),
12986            "boom",
12987            "continuation lines drop the label"
12988        );
12989    }
12990
12991    #[test]
12992    fn folded_head_keeps_the_block_label() {
12993        let styles = active_styles();
12994        let line = tl(InlineMessageKind::Error, "boom", 0);
12995        let rendered = transcript_line_marked(&line, &styles, true, false, false, false, 80);
12996        assert_eq!(
12997            spans_to_string(&rendered),
12998            "[+] error: boom",
12999            "a collapsed block stays identifiable"
13000        );
13001    }
13002
13003    #[test]
13004    fn transcript_line_marked_clamps_to_width() {
13005        // Write-path width invariant: even if a 300-char segment lands on
13006        // a 40-col viewport, the rendered Line never overflows.
13007        let styles = active_styles();
13008        let big: String = "x".repeat(300);
13009        let line = tl(InlineMessageKind::Agent, &big, 0);
13010        let rendered = transcript_line_marked(&line, &styles, false, false, false, true, 40);
13011        assert!(
13012            rendered.width() <= 40,
13013            "transcript row overflowed the terminal width: rendered.width()={}",
13014            rendered.width()
13015        );
13016    }
13017}
13018
13019#[cfg(test)]
13020mod scrollback_commit_tests {
13021    //! Host-scrollback committing (inline-viewport pattern — peer parity
13022    //! with Claude Code / pi): finalized transcript blocks are printed
13023    //! into the terminal's real scrollback so native scroll-up shows the
13024    //! conversation. Commits are block-atomic, never touch the anchored
13025    //! streaming block, and pause while the user browses.
13026    use super::*;
13027
13028    fn tl(kind: InlineMessageKind, text: &str, block_id: usize) -> TranscriptLine {
13029        TranscriptLine {
13030            kind,
13031            segments: vec![plain_segment(text)],
13032            block_id,
13033        }
13034    }
13035
13036    /// 6 agent blocks × 2 lines = 12 entries; one display row each at
13037    /// width 80 (no spacers — agent flow stays contiguous).
13038    fn long_transcript() -> Vec<TranscriptLine> {
13039        (0..6)
13040            .flat_map(|b| {
13041                [
13042                    tl(InlineMessageKind::Agent, &format!("b{b}-line-one"), b),
13043                    tl(InlineMessageKind::Agent, &format!("b{b}-line-two"), b),
13044                ]
13045            })
13046            .collect()
13047    }
13048
13049    #[test]
13050    fn commit_plan_sheds_oldest_blocks_and_keeps_the_tail() {
13051        let state = RenderState {
13052            transcript: long_transcript(),
13053            ..Default::default()
13054        };
13055        let styles = active_styles();
13056        let display = build_transcript_display(&state, &styles, 0, 80);
13057        // 12 rows, keep 4 → 8 rows commit; entry 8 starts block b4, so
13058        // the boundary is already block-atomic (b3 ends at entry 7).
13059        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, None).expect("plan");
13060        assert_eq!(plan.rows, 8, "12 rows total, keep 4 → commit 8");
13061        assert_eq!(plan.new_committed_entries, 8);
13062    }
13063
13064    #[test]
13065    fn commit_plan_never_splits_a_block() {
13066        // Blocks of 3; the keep-window boundary lands mid-block and must
13067        // snap back to the block start.
13068        let transcript: Vec<TranscriptLine> = (0..3)
13069            .flat_map(|b| {
13070                (0..3).map(move |i| tl(InlineMessageKind::Agent, &format!("b{b}-{i}"), b))
13071            })
13072            .collect();
13073        let state = RenderState {
13074            transcript,
13075            ..Default::default()
13076        };
13077        let styles = active_styles();
13078        let display = build_transcript_display(&state, &styles, 0, 80);
13079        // 9 rows; keep 5 → limit 4 → the boundary would split b1
13080        // (items 3,4,5).
13081        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 5, None).expect("plan");
13082        assert_eq!(
13083            plan.new_committed_entries, 3,
13084            "boundary snaps to block start"
13085        );
13086        assert_eq!(plan.rows, 3);
13087    }
13088
13089    #[test]
13090    fn commit_plan_excludes_the_streaming_anchor() {
13091        let state = RenderState {
13092            transcript: long_transcript(),
13093            stream_anchor: Some(4),
13094            ..Default::default()
13095        };
13096        let styles = active_styles();
13097        let display = build_transcript_display(&state, &styles, 0, 80);
13098        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 4, state.stream_anchor)
13099            .expect("plan");
13100        assert!(
13101            plan.new_committed_entries <= 4,
13102            "nothing at/after the anchored (streaming) block commits"
13103        );
13104    }
13105    #[test]
13106    fn committed_entries_floor_the_live_render() {
13107        let mut state = RenderState::default();
13108        state.transcript = long_transcript();
13109        state.committed_entries = 8;
13110        let backend = ratatui::backend::TestBackend::new(80, 24);
13111        let mut terminal = Terminal::new(backend).expect("backend");
13112        terminal
13113            .draw(|f| render_frame(f, &state, &unused_handle()))
13114            .expect("draw");
13115        let buf = terminal.backend().buffer();
13116        let area = buf.area();
13117        let mut rendered = String::new();
13118        for y in 0..area.height {
13119            for x in 0..area.width {
13120                if let Some(cell) = buf.cell((x, y)) {
13121                    rendered.push_str(cell.symbol());
13122                }
13123            }
13124            rendered.push('\n');
13125        }
13126        assert!(
13127            !rendered.contains("b0-line-one"),
13128            "committed blocks leave the viewport"
13129        );
13130        assert!(rendered.contains("b5-line"), "the live tail stays");
13131    }
13132
13133    #[test]
13134    fn search_skips_committed_entries() {
13135        let mut state = RenderState::default();
13136        state.transcript = long_transcript();
13137        state.committed_entries = 8;
13138        state.start_search("line-one");
13139        let s = state.search.as_ref().expect("search open");
13140        assert!(
13141            s.matches.iter().all(|&i| i >= 8),
13142            "matches confined to the live region: {:?}",
13143            s.matches
13144        );
13145        assert!(!s.matches.is_empty());
13146    }
13147
13148    fn unused_handle() -> InlineHandle {
13149        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13150        InlineHandle::new_for_tests(tx)
13151    }
13152
13153    #[test]
13154    fn commit_plan_noop_when_tail_fits() {
13155        let state = RenderState {
13156            transcript: long_transcript(),
13157            ..Default::default()
13158        };
13159        let styles = active_styles();
13160        let display = build_transcript_display(&state, &styles, 0, 80);
13161        assert!(scrollback_commit_plan(&display, &state.transcript, 80, 12, None).is_none());
13162    }
13163
13164    #[test]
13165    fn oversized_block_commits_its_head_at_line_granularity() {
13166        // One 30-row block + a trailing 2-row block, viewport keeps 10:
13167        // the big block cannot fit the live region, so its head commits.
13168        let mut transcript: Vec<TranscriptLine> = (0..30)
13169            .map(|i| tl(InlineMessageKind::Agent, &format!("big-{i:02}"), 0))
13170            .collect();
13171        transcript.push(tl(InlineMessageKind::Agent, "tail-a", 1));
13172        transcript.push(tl(InlineMessageKind::Agent, "tail-b", 1));
13173        let state = RenderState {
13174            transcript,
13175            ..Default::default()
13176        };
13177        let styles = active_styles();
13178        let display = build_transcript_display(&state, &styles, 0, 80);
13179        let plan = scrollback_commit_plan(&display, &state.transcript, 80, 10, None).expect("plan");
13180        assert_eq!(
13181            plan.new_committed_entries, 22,
13182            "32 rows, keep 10 → head 22 rows commit"
13183        );
13184        assert_eq!(plan.rows, 22);
13185    }
13186
13187    #[test]
13188    fn rebuild_only_on_width_change() {
13189        // Height-only resize: nothing to rebuild — committed transcript
13190        // lives in the host terminal's scrollback at the width it was
13191        // printed at; the live viewport just changes rows.
13192        assert!(!should_rebuild_scrollback(80, 80, 24, 30));
13193        // Width grew: re-commit so freshly finalized rows wrap to the
13194        // new width and the old frozen scrollback must go (CSI 3J).
13195        assert!(should_rebuild_scrollback(80, 100, 24, 24));
13196        // Width shrank: same — the old layout no longer fits.
13197        assert!(should_rebuild_scrollback(100, 80, 30, 24));
13198        // Sentinel (final-review finding 1): prev_w == 0 means "never
13199        // measured" — no frame was drawn at a known width, so there is
13200        // no stale-width scrollback and the wipe must NOT fire, no
13201        // matter what the new width is.
13202        assert!(!should_rebuild_scrollback(0, 100, 24, 24));
13203        assert!(!should_rebuild_scrollback(0, 80, 24, 24));
13204    }
13205
13206    #[test]
13207    fn force_flush_boundary_is_everything() {
13208        // Force-flush on exit ignores viewport fit and commits the
13209        // whole finalized prefix — every display row, regardless of
13210        // what fits in the live region.
13211        assert_eq!(plan_full_flush(0), 0);
13212        assert_eq!(plan_full_flush(8), 8);
13213        assert_eq!(plan_full_flush(40), 40);
13214    }
13215}
13216
13217#[cfg(test)]
13218mod tool_box_tests {
13219    //! omp-style tool boxes: borders, divider labels, and — critically —
13220    //! display-width math. Korean text is width-2 per glyph; a char-count
13221    //! wrap or pad misaligns the right border instantly.
13222    use super::*;
13223
13224    fn row_text(row: &[InlineSegment]) -> String {
13225        row.iter().map(|s| s.text.as_str()).collect()
13226    }
13227
13228    #[test]
13229    fn korean_rows_keep_the_right_border_aligned() {
13230        // w=20 → inner=16 cells. "한글" = 4 cells per word.
13231        let rows = tool_box_rows(
13232            "한글테스트 명령어",
13233            20,
13234            InlineTextStyle::default(),
13235            anstyle::Color::Ansi(anstyle::AnsiColor::White),
13236        );
13237        for row in &rows {
13238            let text = row_text(row);
13239            assert_eq!(text.width(), 20, "row must fill exactly 20 cells: {text:?}");
13240            assert!(text.starts_with('\u{2502}'), "left border: {text:?}");
13241            assert!(text.ends_with('\u{2502}'), "right border: {text:?}");
13242        }
13243        assert!(!rows.is_empty());
13244        // Wrapping counts cells, not chars: 9 Korean chars = 18 cells >
13245        // 16 inner → two rows.
13246        assert_eq!(rows.len(), 2, "wraps by display width");
13247    }
13248
13249    #[test]
13250    fn divider_carries_the_label() {
13251        let seg = tool_box_divider(
13252            "Output",
13253            30,
13254            anstyle::Color::Ansi(anstyle::AnsiColor::White),
13255        );
13256        let text = row_text(&seg);
13257        assert!(
13258            text.starts_with("\u{251C}\u{2500} Output"),
13259            "label after ├─: {text:?}"
13260        );
13261
13262        assert!(text.ends_with('\u{2524}'), "closes with ┤: {text:?}");
13263        assert_eq!(text.width(), 30, "divider fills the box width");
13264    }
13265}
13266
13267#[cfg(test)]
13268mod tool_box_width_tests {
13269    //! Box width must equal the LIVE transcript content width (layout
13270    //! gutters + scrollbar column). At the raw terminal width every
13271    //! row's right border wraps onto the next visual line.
13272    use super::*;
13273
13274    #[test]
13275    fn tool_box_width_matches_live_content_width() {
13276        let state = RenderState {
13277            viewport_width: 100,
13278            ..Default::default()
13279        };
13280        // CHAT_LAYOUT insets 1 column per side; the in-app scrollbar is
13281        // gone (native scrollback owns history): 100 - 2 = 98.
13282        assert_eq!(tool_box_width(&state), 98);
13283    }
13284}
13285
13286#[test]
13287fn tool_box_rows_expand_tabs_so_borders_align() {
13288    // The read tool numbers lines as `{:>6}\t{content}`. unicode-width 0.2
13289    // counts the tab as 1 (`UnicodeWidthStr::width`), but ratatui drops it
13290    // when filling cells — a row built with tab width in its pad math
13291    // renders one column short and the right border lands inside the box.
13292    let chunk = format!("{:>6}\t{}", 1, "[package]");
13293    let rows = tool_box_rows(
13294        &chunk,
13295        176,
13296        InlineTextStyle::default(),
13297        anstyle::Color::Ansi(anstyle::AnsiColor::White),
13298    );
13299    assert_eq!(rows.len(), 1);
13300    for seg in &rows[0] {
13301        assert!(
13302            !seg.text.contains('\t'),
13303            "tabs must be expanded: {:?}",
13304            seg.text
13305        );
13306    }
13307    let built: usize = rows[0]
13308        .iter()
13309        .map(|s| UnicodeWidthStr::width(s.text.as_str()))
13310        .sum();
13311    assert_eq!(built, 176, "built width must equal the box width exactly");
13312}
13313
13314#[cfg(test)]
13315mod contextual_hint_tests {
13316    //! The static shortcuts bar is gone; discoverability is contextual:
13317    //! the brain chip lives on the composer border, abort/quit hints
13318    //! appear only while a run is live or a quit is armed.
13319    use super::*;
13320
13321    #[test]
13322    fn brain_chip_lives_on_the_composer_border() {
13323        let mut state = RenderState::default();
13324        state.header_context.provider = "prov".to_string();
13325        state.header_context.model = "prov/m-1".to_string();
13326
13327        // Off (memory disabled) — no chip.
13328        let off = spans_to_string_border(&state);
13329        assert!(!off.contains("brain"), "chip hidden when off: {off}");
13330
13331        // Ok — right side of the border.
13332        state.brain = BrainChip::Ok;
13333        let ok = spans_to_string_border(&state);
13334        assert!(ok.contains("brain·ok"), "healthy chip on border: {ok}");
13335        assert!(
13336            ok.trim_end().ends_with("brain·ok"),
13337            "chip is right-aligned: {ok}"
13338        );
13339
13340        // Down — still renders.
13341        state.brain = BrainChip::Down;
13342        let down = spans_to_string_border(&state);
13343        assert!(down.contains("brain·down"), "degraded chip: {down}");
13344    }
13345
13346    #[test]
13347    fn brain_chip_does_not_erase_the_border_rule() {
13348        // Regression: the chip used to be space-padded into the fields
13349        // title. A title overwrites the border row for its full width,
13350        // so the padding erased the `─` rule right of the facts.
13351        let backend = ratatui::backend::TestBackend::new(80, 24);
13352        let mut terminal = Terminal::new(backend).expect("backend");
13353        let mut state = RenderState::default();
13354        state.header_context.provider = "prov".to_string();
13355        state.header_context.model = "prov/m-1".to_string();
13356        state.brain = BrainChip::Ok;
13357        terminal
13358            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13359            .expect("draw");
13360        let buf = terminal.backend().buffer();
13361        // The welcome card also prints "MODEL" when the transcript is
13362        // empty; the composer border row is the one with ` | ` field
13363        // separators.
13364        let border_row = (0..buf.area().height)
13365            .map(|y| {
13366                (0..buf.area().width)
13367                    .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
13368                    .collect::<String>()
13369            })
13370            .find(|row| row.contains("MODEL") && row.contains(" | "))
13371            .expect("composer border row rendered");
13372        let rule_count = border_row.chars().filter(|c| *c == '\u{2500}').count();
13373        assert!(
13374            rule_count >= 10,
13375            "the ─ rule must survive right of the facts: {border_row}"
13376        );
13377        assert!(
13378            border_row.contains("brain\u{b7}ok"),
13379            "chip still on the border: {border_row}"
13380        );
13381    }
13382
13383    #[test]
13384    fn run_indicator_stays_up_between_turns() {
13385        // Mid-run the stage is cleared at each turn boundary
13386        // (MessageEnd/TurnEnd); the run tracker must keep the indicator
13387        // row owned so it never flickers to the idle row — and it should
13388        // carry progress facts (spinner, turn/tool counts, elapsed).
13389        let backend = ratatui::backend::TestBackend::new(80, 24);
13390        let mut terminal = Terminal::new(backend).expect("backend");
13391        let state = RenderState {
13392            active_run: Some(RunState {
13393                started_at: std::time::Instant::now(),
13394                turn: 2,
13395                tool_calls: 3,
13396            }),
13397            reasoning_stage: None,
13398            ..Default::default()
13399        };
13400        terminal
13401            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13402            .expect("draw");
13403        let buf = terminal.backend().buffer();
13404        let row: String = (0..buf.area().width)
13405            .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13406            .collect();
13407        assert!(row.contains("RUNNING"), "row stays up between turns: {row}");
13408        assert!(row.contains("working"), "stage fallback label: {row}");
13409        assert!(row.contains("turn 2"), "turn count: {row}");
13410        assert!(row.contains("3 tool calls"), "tool count: {row}");
13411        assert!(row.contains("Esc abort"), "abort hint stays: {row}");
13412        assert!(
13413            RUN_SPINNER.iter().any(|f| row.contains(f)),
13414            "animated spinner frame: {row}"
13415        );
13416    }
13417
13418    #[test]
13419    fn spinner_frame_is_wall_clock_not_draw_count() {
13420        // Regression: the spinner advanced on FRAME_TICK (draw count).
13421        // Event bursts during streaming drive many draws per interval,
13422        // so the spinner raced. Animation frames must key on wall-clock
13423        // time — rapid back-to-back draws show the SAME frame.
13424        let state = || RenderState {
13425            active_run: Some(RunState::default()),
13426            reasoning_stage: None,
13427            ..Default::default()
13428        };
13429        let spinner_of = |s: &RenderState| -> Option<char> {
13430            let backend = ratatui::backend::TestBackend::new(80, 24);
13431            let mut terminal = Terminal::new(backend).expect("backend");
13432            terminal
13433                .draw(|f| render_frame(f, s, &unused_test_handle()))
13434                .expect("draw");
13435            let buf = terminal.backend().buffer();
13436            let row: String = (0..buf.area().width)
13437                .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13438                .collect();
13439            row.chars()
13440                .find(|c| RUN_SPINNER.iter().any(|f| f.starts_with(*c)))
13441        };
13442        // Two draws in the same animation period (sub-80ms apart, which
13443        // consecutive draws in one test always are).
13444        let first = spinner_of(&state());
13445        let second = spinner_of(&state());
13446        assert!(first.is_some(), "spinner renders");
13447        assert_eq!(
13448            first, second,
13449            "back-to-back draws must not advance the spinner"
13450        );
13451    }
13452
13453    #[test]
13454    fn elapsed_formats_minutes_beyond_60s() {
13455        assert_eq!(format_elapsed_secs(59), "59s");
13456        assert_eq!(format_elapsed_secs(60), "1m 00s");
13457        assert_eq!(format_elapsed_secs(125), "2m 05s");
13458    }
13459
13460    #[test]
13461    fn reasoning_row_carries_the_abort_hint() {
13462        let backend = ratatui::backend::TestBackend::new(80, 24);
13463        let mut terminal = Terminal::new(backend).expect("backend");
13464        let state = RenderState {
13465            reasoning_stage: Some("thinking\u{2026}".into()),
13466            ..Default::default()
13467        };
13468        terminal
13469            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13470            .expect("draw");
13471        let buf = terminal.backend().buffer();
13472        let row: String = (0..buf.area().width)
13473            .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13474            .collect();
13475        assert!(
13476            row.contains("Esc abort"),
13477            "streaming shows the contextual abort hint: {row}"
13478        );
13479    }
13480
13481    #[test]
13482    fn pending_quit_owns_the_hint_row() {
13483        let backend = ratatui::backend::TestBackend::new(80, 24);
13484        let mut terminal = Terminal::new(backend).expect("backend");
13485        let state = RenderState {
13486            pending_quit: true,
13487            ..Default::default()
13488        };
13489        terminal
13490            .draw(|f| render_frame(f, &state, &unused_test_handle()))
13491            .expect("draw");
13492        let buf = terminal.backend().buffer();
13493
13494        let row: String = (0..buf.area().width)
13495            .filter_map(|x| buf.cell((x, 20)).map(|c| c.symbol().to_string()))
13496            .collect();
13497        assert!(
13498            row.contains("press Ctrl+C again to quit"),
13499            "armed quit shows its hint: {row}"
13500        );
13501    }
13502
13503    fn spans_to_string_border(state: &RenderState) -> String {
13504        let line = composer_context_line(state, 200);
13505        let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13506        let used = line.spans.iter().map(|s| s.width()).sum();
13507        if let Some(chip) = composer_brain_chip(state, 200, used) {
13508            assert_eq!(
13509                chip.alignment,
13510                Some(Alignment::Right),
13511                "the chip is its own right-aligned title"
13512            );
13513            text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13514        }
13515        text
13516    }
13517
13518    fn unused_test_handle() -> InlineHandle {
13519        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
13520        InlineHandle::new_for_tests(tx)
13521    }
13522}
13523
13524#[cfg(test)]
13525mod trailing_breath_tests {
13526    //! The gap between the transcript and the composer is LAYOUT: the
13527    //! scrollback area reserves one blank row above the prompt, so the
13528    //! newest response never glues to the composer at any height — and
13529    //! the gap can't be windowed or committed away.
13530    use super::*;
13531
13532    #[test]
13533    fn display_ends_at_the_last_line_no_trailing_blank_item() {
13534        let state = RenderState {
13535            transcript: vec![TranscriptLine {
13536                kind: InlineMessageKind::Agent,
13537                segments: vec![plain_segment("answer")],
13538                block_id: 0,
13539            }],
13540            ..Default::default()
13541        };
13542        let styles = active_styles();
13543        let display = build_transcript_display(&state, &styles, 0, 80);
13544        assert_eq!(display.len(), 1, "the gap is layout, not a display item");
13545        assert!(display[0].line.is_some());
13546    }
13547
13548    #[test]
13549    fn scrollback_area_reserves_one_breath_row_above_the_composer() {
13550        let area = Rect {
13551            x: 0,
13552            y: 0,
13553            width: 100,
13554            height: 30,
13555        };
13556        let layout = super::super::frame_layout::compute_chrome(area);
13557        assert_eq!(
13558            layout.scrollback.bottom() + 1,
13559            layout.prompt.y,
13560            "exactly one row separates the transcript from the composer"
13561        );
13562        assert_eq!(
13563            super::super::frame_layout::scrollback_height(area),
13564            layout.scrollback.height,
13565            "the commit keep-rows must match the rendered area"
13566        );
13567    }
13568}
13569
13570#[cfg(test)]
13571mod nerd_icon_tests {
13572    //! `glyph_set = "nerd"` swaps the composer's text labels for Nerd
13573    //! Font private-use glyphs — never emoji. Default (unicode) keeps
13574    //! the text labels.
13575    use super::*;
13576
13577    fn border_text(state: &RenderState) -> String {
13578        let line = composer_context_line(state, 200);
13579        let mut text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
13580        let used = line.spans.iter().map(|s| s.width()).sum();
13581        if let Some(chip) = composer_brain_chip(state, 200, used) {
13582            text.extend(chip.spans.iter().map(|s| s.content.as_ref()));
13583        }
13584        text
13585    }
13586
13587    fn base_state() -> RenderState {
13588        let mut state = RenderState::default();
13589        state.header_context.provider = "prov".to_string();
13590        state.header_context.model = "prov/m-1".to_string();
13591        state.brain = BrainChip::Ok;
13592        state
13593    }
13594
13595    #[test]
13596    fn nerd_mode_replaces_labels_with_private_use_glyphs() {
13597        let mut state = base_state();
13598        state.glyph_set = crate::symbols::GlyphSet::Nerd;
13599        let text = border_text(&state);
13600        assert!(!text.contains("MODEL "), "text label gone: {text}");
13601        assert!(
13602            text.contains(crate::symbols::nerd::MODEL),
13603            "robot glyph for the model: {text}"
13604        );
13605        assert!(
13606            text.contains(crate::symbols::nerd::GIT),
13607            "git glyph present: {text}"
13608        );
13609        assert!(
13610            text.contains(crate::symbols::nerd::BRAIN),
13611            "brain glyph chip: {text}"
13612        );
13613        // No emoji ever: all swaps live in the private-use area
13614        // (U+E000–U+F8FF and the supplementary PUA planes).
13615        for ch in text.chars() {
13616            let cp = ch as u32;
13617            let private_use = (0xE000..=0xF8FF).contains(&cp)
13618                || (0xF0000..=0xFFFFD).contains(&cp)
13619                || (0x100000..=0x10FFFD).contains(&cp);
13620            assert!(
13621                !('\u{1F300}'..='\u{1FAFF}').contains(&ch) || !private_use,
13622                "sanity"
13623            );
13624        }
13625    }
13626
13627    #[test]
13628    fn unicode_default_keeps_text_labels() {
13629        let state = base_state();
13630        let text = border_text(&state);
13631        assert!(text.contains("MODEL "), "default keeps text: {text}");
13632        assert!(text.contains("brain\u{b7}ok"), "default chip text: {text}");
13633    }
13634}
13635#[cfg(test)]
13636mod glyph_cycle_tests {
13637    use crate::symbols::GlyphSet;
13638
13639    #[test]
13640    fn glyph_set_cycles_unicode_ascii_nerd() {
13641        assert_eq!(GlyphSet::Unicode.next(), GlyphSet::Ascii);
13642        assert_eq!(GlyphSet::Ascii.next(), GlyphSet::Nerd);
13643        assert_eq!(GlyphSet::Nerd.next(), GlyphSet::Unicode);
13644    }
13645}
13646
13647#[cfg(test)]
13648mod coalesce_draw_tests {
13649    //! Render coalescing: the event loop used to redraw on every iteration,
13650    //! causing a frame storm during token-stream bursts (one full
13651    //! `terminal.draw` per agent event). The fix is `coalesce_draw`: most
13652    //! arms gate the post-select draw behind a 50ms cadence; user-facing
13653    //! arms (keyboard, SIGINT, brain chip) raise `priority = true` for
13654    //! an immediate repaint.
13655    use super::*;
13656    use std::time::{Duration, Instant};
13657
13658    #[test]
13659    fn defer_within_interval() {
13660        let now = Instant::now();
13661        let last = now - Duration::from_millis(10);
13662        assert_eq!(
13663            coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13664            DrawDecision::Defer
13665        );
13666    }
13667
13668    #[test]
13669    fn draw_now_on_priority_even_within_interval() {
13670        let now = Instant::now();
13671        let last = now - Duration::from_millis(10);
13672        assert_eq!(
13673            coalesce_draw(last, true, DRAW_MIN_INTERVAL),
13674            DrawDecision::DrawNow
13675        );
13676    }
13677
13678    #[test]
13679    fn draw_now_when_interval_elapsed() {
13680        let now = Instant::now();
13681        let last = now - Duration::from_millis(60);
13682        assert_eq!(
13683            coalesce_draw(last, false, DRAW_MIN_INTERVAL),
13684            DrawDecision::DrawNow
13685        );
13686    }
13687}
13688#[cfg(test)]
13689mod settings_panel_tests {
13690    use super::*;
13691    use crate::app::agent_session::AgentSessionHandle;
13692    use crate::store::settings::Settings;
13693    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13694
13695    fn make_session_with_tools() -> AgentSessionHandle {
13696        super::provider_overlay_tests::make_session_with_tools_for_tests()
13697    }
13698    use oxicode_vtui::tui::core::InlineListSelection;
13699    use ratatui::{Terminal, backend::TestBackend};
13700
13701    fn heading(title: &str) -> OverlayListItem {
13702        OverlayListItem {
13703            title: title.into(),
13704            subtitle: None,
13705            badge: None,
13706            indent: 0,
13707            search_value: None,
13708            selection: None,
13709        }
13710    }
13711
13712    fn row(title: &str, badge: &str, selection: Option<InlineListSelection>) -> OverlayListItem {
13713        OverlayListItem {
13714            title: title.into(),
13715            subtitle: None,
13716            badge: Some(badge.into()),
13717            indent: 0,
13718            search_value: None,
13719            selection,
13720        }
13721    }
13722
13723    /// Collect each terminal row as (y, concatenated text, per-char x
13724    /// positions) so tests can assert on WHERE content landed, not just
13725    /// that it exists.
13726    fn rows_with_positions(terminal: &Terminal<TestBackend>) -> Vec<(u16, String, Vec<u16>)> {
13727        let buf = terminal.backend().buffer();
13728        let area = buf.area();
13729        let mut out = Vec::new();
13730        for y in 0..area.height {
13731            let mut text = String::new();
13732            let mut xs = Vec::new();
13733            for x in 0..area.width {
13734                if let Some(cell) = buf.cell((x, y)) {
13735                    text.push_str(cell.symbol());
13736                    xs.push(x);
13737                }
13738            }
13739            out.push((y, text, xs));
13740        }
13741        out
13742    }
13743
13744    /// All (y, x) offsets where `needle` starts in the rendered buffer.
13745    fn occurrences(rows: &[(u16, String, Vec<u16>)], needle: &str) -> Vec<(u16, usize)> {
13746        let mut hits = Vec::new();
13747        for (y, text, xs) in rows {
13748            let mut from = 0;
13749            while let Some(rel) = text[from..].find(needle) {
13750                let byte_idx = from + rel;
13751                let char_idx = text[..byte_idx].chars().count();
13752                if let Some(&x) = xs.get(char_idx) {
13753                    hits.push((*y, x as usize));
13754                }
13755                from = byte_idx + needle.len();
13756            }
13757        }
13758        hits
13759    }
13760
13761    /// A tabbed overlay (>= 2 sections, width >= 60) renders the tab bar
13762    /// and the sidebar column: section names appear BOTH in the sidebar
13763    /// (left of the item column) and as in-list heading rows, and rows
13764    /// outside the active section are dimmed.
13765    #[test]
13766    fn render_overlay_tabbed_settings_shows_tab_bar_and_sidebar() {
13767        let backend = TestBackend::new(80, 24);
13768        let mut terminal = Terminal::new(backend).unwrap();
13769        let overlay = OverlayState {
13770            title: "Settings".into(),
13771            lines: Vec::new(),
13772            items: vec![
13773                heading("Defaults"),
13774                row(
13775                    "Thinking level",
13776                    "medium",
13777                    Some(InlineListSelection::ConfigAction("ThinkingLevel".into())),
13778                ),
13779                row("Model roles", "0", None),
13780                heading("Pointers"),
13781                row("Theme", "dark", None),
13782            ],
13783            selected: 1,
13784            search: None,
13785            secure_input: None,
13786            tabs: vec!["General".into(), "Model".into(), "Interaction".into()],
13787            active_tab: 1,
13788            sections: vec!["Defaults".into(), "Pointers".into()],
13789            active_section: 0,
13790            key_capture: None,
13791        };
13792        terminal
13793            .draw(|f| render_overlay(f, f.area(), &overlay))
13794            .unwrap();
13795        let rows = rows_with_positions(&terminal);
13796
13797        // Tab bar: one row names the inactive tabs flanking the active
13798        // one.
13799        let general = occurrences(&rows, "General");
13800        let interaction = occurrences(&rows, "Interaction");
13801        assert!(
13802            general
13803                .iter()
13804                .any(|(gy, _)| interaction.iter().any(|(iy, _)| gy == iy)),
13805            "tab bar must list tabs on one row"
13806        );
13807
13808        // Sidebar geometry: sidebar width = min(22, longest)+4 = 12, so
13809        // the sidebar column occupies x < 13 and the item list starts at
13810        // x >= 13.
13811        for name in ["Defaults", "Pointers"] {
13812            let hits = occurrences(&rows, name);
13813            assert!(hits.len() >= 2, "{name} must render in sidebar AND list");
13814            assert!(
13815                hits.iter().any(|(_, x)| *x < 13),
13816                "{name} must render in the sidebar column"
13817            );
13818            assert!(
13819                hits.iter().any(|(_, x)| *x >= 13),
13820                "{name} must render in the item column"
13821            );
13822        }
13823
13824        // Out-of-section rows recede: the items-column "Pointers"
13825        // heading is DIM while the active section's is not.
13826        let buf = terminal.backend().buffer();
13827        let pointers_item_col = occurrences(&rows, "Pointers")
13828            .into_iter()
13829            .find(|(_, x)| *x >= 13)
13830            .expect("items-column Pointers heading");
13831        let cell = buf
13832            .cell((pointers_item_col.1 as u16, pointers_item_col.0))
13833            .expect("cell");
13834        assert!(
13835            cell.modifier.contains(Modifier::DIM),
13836            "out-of-section rows must be dimmed"
13837        );
13838        let defaults_item_col = occurrences(&rows, "Defaults")
13839            .into_iter()
13840            .find(|(_, x)| *x >= 13)
13841            .expect("items-column Defaults heading");
13842        let cell = buf
13843            .cell((defaults_item_col.1 as u16, defaults_item_col.0))
13844            .expect("cell");
13845        assert!(
13846            !cell.modifier.contains(Modifier::DIM),
13847            "active-section rows must not be dimmed"
13848        );
13849    }
13850
13851    /// The input loop resolves shortcuts through the live keymap: with
13852    /// `SendNow` rebound to `Alt+s`, that combo fires the send-now path
13853    /// (interrupt + immediate submit of the composed buffer) while the
13854    /// default `Ctrl+Enter` still resolves.
13855    #[test]
13856    fn rebound_send_now_combo_submits_immediately() {
13857        let state = Arc::new(parking_lot::Mutex::new(RenderState::default()));
13858        let mut overrides = std::collections::HashMap::new();
13859        overrides.insert("SendNow".to_string(), vec!["Alt+s".to_string()]);
13860        *state.lock().keymap.write() = Keymap::from_settings(&overrides);
13861
13862        let alt_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::ALT);
13863        let action = state
13864            .lock()
13865            .keymap
13866            .read()
13867            .resolve(alt_s)
13868            .expect("Alt+s must resolve to SendNow after the rebind");
13869        assert!(matches!(action, GlobalAction::SendNow));
13870        // Overrides replace only the named action's combo list: the old
13871        // default combo no longer fires SendNow, while every other
13872        // action keeps its default.
13873        let ctrl_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL);
13874        assert_eq!(state.lock().keymap.read().resolve(ctrl_enter), None);
13875        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13876        assert!(matches!(
13877            state.lock().keymap.read().resolve(ctrl_p),
13878            Some(GlobalAction::OpenCommandPalette)
13879        ));
13880
13881        state.lock().composer.set_text("send me now");
13882        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
13883        apply_global_action(action, &state, &tx);
13884
13885        assert_eq!(state.lock().composer.text(), "");
13886        match rx.try_recv().expect("interrupt fires first") {
13887            InlineEvent::Interrupt => {}
13888            other => panic!("expected Interrupt first, got {other:?}"),
13889        }
13890        match rx.try_recv().expect("submit fires second") {
13891            InlineEvent::Submit(text) => assert_eq!(&*text, "send me now"),
13892            other => panic!("expected Submit, got {other:?}"),
13893        }
13894        assert!(rx.try_recv().is_err(), "no further events");
13895    }
13896
13897    /// `OverlaySubmission` variants the settings panel emits must stay
13898    /// constructible through the compat layer (compile-level contract).
13899    #[test]
13900    fn settings_selection_variants_round_trip_names() {
13901        assert_eq!(
13902            InlineListSelection::SettingsTab(1),
13903            InlineListSelection::SettingsTab(1)
13904        );
13905        assert_eq!(
13906            InlineListSelection::SettingsSection(0),
13907            InlineListSelection::SettingsSection(0)
13908        );
13909        assert_eq!(
13910            InlineListSelection::SettingKeyCapture("OpenCommandPalette".into()),
13911            InlineListSelection::SettingKeyCapture("OpenCommandPalette".into())
13912        );
13913        assert_eq!(
13914            InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into()),
13915            InlineListSelection::SettingTextEdit("ToolTimeoutSecs".into())
13916        );
13917        assert_eq!(
13918            InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into()),
13919            InlineListSelection::SettingSubmenuOpen("AdvisorSyncBacklog".into())
13920        );
13921        assert_eq!(
13922            InlineListSelection::SettingMultiselect("DisabledTools".into()),
13923            InlineListSelection::SettingMultiselect("DisabledTools".into())
13924        );
13925    }
13926
13927    /// Capturing a new combo for `OpenCommandPalette` is additive: the
13928    /// next `Keymap::resolve` call resolves BOTH the new combo and the
13929    /// original default `Ctrl+P`. The capture path drives the round
13930    /// trip end-to-end — `SettingKeyCapture` selection opens the
13931    /// capture prompt, the simulated `KeyEvent` is fed straight to
13932    /// `handle_key_capture`, and the live `RenderState::keymap` is the
13933    /// single source of truth the test inspects.
13934    ///
13935    /// SANDBOXED: writes go to a tempdir `settings.json` via the
13936    /// `settings_override_path` hook so the real `~/.oxicode/settings.*`
13937    /// is never touched (the previous version of this test polluted the
13938    /// developer's live config — see final-review finding 1).
13939    #[test]
13940    fn key_capture_appends_combo_and_keeps_default_resolving() {
13941        // Snapshot the real ~/.oxicode settings.json mtime so the
13942        // post-condition assertion catches accidental leakage.
13943        let real_settings = dirs::home_dir()
13944            .map(|h| h.join(".oxicode").join("settings.json"))
13945            .filter(|p| p.exists());
13946        let real_settings_mtime_before = real_settings
13947            .as_ref()
13948            .and_then(|p| std::fs::metadata(p).ok())
13949            .and_then(|m| m.modified().ok());
13950        let real_settings_sha_before = real_settings
13951            .as_ref()
13952            .and_then(|p| std::fs::read(p).ok())
13953            .map(|b| {
13954                use std::collections::hash_map::DefaultHasher;
13955                use std::hash::{Hash, Hasher};
13956                let mut h = DefaultHasher::new();
13957                b.hash(&mut h);
13958                h.finish()
13959            });
13960
13961        let tmp = tempfile::tempdir().expect("tempdir");
13962        let sandbox = tmp.path().join("settings.json");
13963
13964        let mut state = RenderState::default();
13965        state.settings_override_path = Some(sandbox.clone());
13966        // Open the capture overlay for OpenCommandPalette — same
13967        // selection variant `handle_inline_event` would dispatch from
13968        // the settings panel.
13969        state.overlay = Some(build_key_capture_overlay(
13970            GlobalAction::OpenCommandPalette.name(),
13971        ));
13972        state.settings_map_rows.clear();
13973
13974        // Pre-condition: the default resolves, the new combo doesn't.
13975        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
13976        let alt_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT);
13977        assert_eq!(
13978            state.keymap.read().resolve(ctrl_p),
13979            Some(GlobalAction::OpenCommandPalette)
13980        );
13981        assert_eq!(state.keymap.read().resolve(alt_p), None);
13982
13983        // Drive the capture flow with an Alt+P press.
13984        handle_key_capture(&mut state, alt_p);
13985
13986        // Post-condition: both combos resolve (additive merge into the
13987        // live keymap) — and the panel has been rebuilt back on the
13988        // Keybindings tab with a success status, not the capture
13989        // prompt.
13990        let keymap = state.keymap.read();
13991        assert_eq!(
13992            keymap.resolve(ctrl_p),
13993            Some(GlobalAction::OpenCommandPalette)
13994        );
13995        assert_eq!(
13996            keymap.resolve(alt_p),
13997            Some(GlobalAction::OpenCommandPalette)
13998        );
13999        drop(keymap);
14000        let overlay = state
14001            .overlay
14002            .as_ref()
14003            .expect("capture commits reopen the panel on Keybindings");
14004        assert!(
14005            overlay.key_capture.is_none(),
14006            "capture prompt must be closed"
14007        );
14008        assert_eq!(
14009            overlay.lines.first().map(String::as_str),
14010            Some("Captured Alt+p for OpenCommandPalette")
14011        );
14012        assert_eq!(state.settings_active_tab, SettingsTab::Keybindings);
14013
14014        // Sandbox assertion: the tempdir received the write, the real
14015        // `~/.oxicode/settings.json` is untouched (no mtime or
14016        // content change).
14017        let sandbox_contents = std::fs::read_to_string(&sandbox)
14018            .expect("sandbox settings.json must exist after capture");
14019        assert!(
14020            sandbox_contents.contains("OpenCommandPalette"),
14021            "sandbox file must contain the captured keybinding override; got {sandbox_contents}"
14022        );
14023        assert!(
14024            sandbox_contents.contains("Alt+p"),
14025            "sandbox file must contain the captured Alt+p combo; got {sandbox_contents}"
14026        );
14027        if let Some(before) = real_settings_mtime_before {
14028            let after = real_settings
14029                .as_ref()
14030                .and_then(|p| std::fs::metadata(p).ok())
14031                .and_then(|m| m.modified().ok())
14032                .expect("real settings.json must still exist after capture");
14033            assert_eq!(
14034                before, after,
14035                "real ~/.oxicode/settings.json mtime must not change (sandbox leak)"
14036            );
14037        }
14038        if let (Some(before), Some(after)) = (
14039            real_settings_sha_before,
14040            real_settings
14041                .as_ref()
14042                .and_then(|p| std::fs::read(p).ok())
14043                .map(|b| {
14044                    use std::collections::hash_map::DefaultHasher;
14045                    use std::hash::{Hash, Hasher};
14046                    let mut h = DefaultHasher::new();
14047                    b.hash(&mut h);
14048                    h.finish()
14049                }),
14050        ) {
14051            assert_eq!(
14052                before, after,
14053                "real ~/.oxicode/settings.json content must not change (sandbox leak)"
14054            );
14055        }
14056    }
14057
14058    /// The remove-last-binding guard refuses to drop the final combo of
14059    /// an action — an action with zero keys would be a silent trap
14060    /// (the user could neither trigger it nor reach this panel to fix
14061    /// it). We pre-bind `OpenCommandPalette` to a single combo (the
14062    /// default) and confirm `remove_keybinding_combo` no-ops the
14063    /// removal while surfacing the reason in the panel status.
14064    #[test]
14065    fn remove_keybinding_combo_refuses_to_drop_the_last_combo() {
14066        let mut state = RenderState::default();
14067        // Force a single-combo state: replace OpenCommandPalette's
14068        // list with just `Ctrl+p` (the default minus all other
14069        // combos the action doesn't have — the point is that the
14070        // list ends up at length 1).
14071        let mut settings = Settings::default();
14072        crate::tui_vt::settings_defs::set_action_combos(
14073            &mut settings,
14074            GlobalAction::OpenCommandPalette,
14075            vec!["Ctrl+p".to_string()],
14076        );
14077        *state.keymap.write() = Keymap::from_settings(&settings.keybindings);
14078        assert_eq!(
14079            state
14080                .keymap
14081                .read()
14082                .action_combos(GlobalAction::OpenCommandPalette)
14083                .len(),
14084            1,
14085            "test setup: action must start with exactly one combo"
14086        );
14087
14088        // Place the panel somewhere (the guard reopens it, but
14089        // starting state should be observable).
14090        state.settings_active_tab = SettingsTab::Keybindings;
14091
14092        remove_keybinding_combo(&mut state, GlobalAction::OpenCommandPalette, "Ctrl+p");
14093
14094        // The combo is still live — the guard refused the removal.
14095        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
14096        assert_eq!(
14097            state.keymap.read().resolve(ctrl_p),
14098            Some(GlobalAction::OpenCommandPalette),
14099            "the guard must not let OpenCommandPalette go combo-less"
14100        );
14101        assert_eq!(
14102            state
14103                .keymap
14104                .read()
14105                .action_combos(GlobalAction::OpenCommandPalette)
14106                .len(),
14107            1,
14108            "no combo was removed"
14109        );
14110        // The reason is surfaced as the panel status line so the user
14111        // knows why nothing happened.
14112        let overlay = state.overlay.as_ref().expect("reopen leaves the panel up");
14113        assert!(
14114            overlay
14115                .lines
14116                .first()
14117                .map(|l| l.contains("Refusing to remove the last combo"))
14118                .unwrap_or(false),
14119            "panel must explain why the removal was refused; got {:?}",
14120            overlay.lines
14121        );
14122    }
14123
14124    // ── Final-fix wave: Text / SubmenuSelect / Multiselect editors ────
14125
14126    /// `commit_text_edit` with valid numeric input: the value is
14127    /// parsed, persisted to the SANDBOX path, and the panel reopens
14128    /// with a status line showing the new value.
14129    #[test]
14130    fn text_edit_commit_valid_input() {
14131        let tmp = tempfile::tempdir().expect("tempdir");
14132        let sandbox = tmp.path().join("settings.json");
14133        let mut state = RenderState::default();
14134        state.settings_override_path = Some(sandbox.clone());
14135        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14136        let handle = InlineHandle::new_for_tests(tx);
14137
14138        let (outcome, _msg) = commit_text_edit(
14139            &mut state,
14140            &handle,
14141            None,
14142            SettingKey::SessionHistorySize,
14143            "300".to_string(),
14144        );
14145        assert!(outcome.is_ok(), "valid numeric input must commit");
14146
14147        // The sandbox file received the write with the new value.
14148        let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14149        let saved: Settings = serde_json::from_str(&contents).expect("sandbox parses");
14150        assert_eq!(
14151            saved.session_history_size, 300,
14152            "sandbox must hold session_history_size=300"
14153        );
14154
14155        // The panel reopened with a status line naming the new value.
14156        let overlay = state.overlay.as_ref().expect("panel reopened");
14157        assert!(
14158            overlay
14159                .lines
14160                .first()
14161                .map(|l| l.contains("300"))
14162                .unwrap_or(false),
14163            "status line must show the new value; got {:?}",
14164            overlay.lines
14165        );
14166        // And the transcript Info line was emitted.
14167        let mut saw_info = false;
14168        while let Ok(cmd) = rx.try_recv() {
14169            if let InlineCommand::AppendLine { kind, .. } = cmd
14170                && matches!(kind, InlineMessageKind::Info)
14171            {
14172                saw_info = true;
14173            }
14174        }
14175        assert!(saw_info, "commit must surface an Info line");
14176    }
14177
14178    /// `commit_text_edit` with INVALID input: the parse fails, nothing
14179    /// is persisted (no sandbox file), and the failure surfaces as an
14180    /// Error line — never a silent no-op.
14181    #[test]
14182    fn text_edit_commit_invalid_input_is_rejected() {
14183        let tmp = tempfile::tempdir().expect("tempdir");
14184        let sandbox = tmp.path().join("settings.json");
14185        let mut state = RenderState::default();
14186        state.settings_override_path = Some(sandbox.clone());
14187        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14188        let handle = InlineHandle::new_for_tests(tx);
14189
14190        let (outcome, msg) = commit_text_edit(
14191            &mut state,
14192            &handle,
14193            None,
14194            SettingKey::SessionHistorySize,
14195            "not-a-number".to_string(),
14196        );
14197        assert!(outcome.is_err(), "non-numeric input must be rejected");
14198        assert!(
14199            msg.contains("invalid") || msg.contains("ParseError") || !msg.is_empty(),
14200            "rejection must carry a reason; got {msg}"
14201        );
14202        // No write happened.
14203        assert!(
14204            !sandbox.exists(),
14205            "rejected input must not persist anything"
14206        );
14207        // The failure surfaced as an Error line.
14208        let mut saw_error = false;
14209        while let Ok(cmd) = rx.try_recv() {
14210            if let InlineCommand::AppendLine { kind, .. } = cmd
14211                && matches!(kind, InlineMessageKind::Error)
14212            {
14213                saw_error = true;
14214            }
14215        }
14216        assert!(saw_error, "rejection must surface an Error line");
14217    }
14218
14219    /// `open_submenu_select_prompt` builds the option list from the
14220    /// def's `SubmenuSelect` options with the current value marked, and
14221    /// `commit_submenu_choice` persists the choice and reopens the
14222    /// panel.
14223    #[test]
14224    fn submenu_select_commit_for_sync_backlog() {
14225        let tmp = tempfile::tempdir().expect("tempdir");
14226        let sandbox = tmp.path().join("settings.json");
14227        let mut state = RenderState::default();
14228        state.settings_override_path = Some(sandbox.clone());
14229
14230        // Open the submenu: rows for off/sync/async, current marked.
14231        open_submenu_select_prompt(&mut state, SettingKey::AdvisorSyncBacklog);
14232        let overlay = state.overlay.as_ref().expect("submenu overlay opens");
14233        assert_eq!(overlay.items.len(), 3, "off/sync/async rows");
14234        let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14235        assert_eq!(titles, vec!["off", "sync", "async"]);
14236        // Every row carries a SubmenuCommit selection payload.
14237        for item in &overlay.items {
14238            let sel = item.selection.as_ref().expect("row is selectable");
14239            match sel {
14240                InlineListSelection::ConfigAction(p) => {
14241                    assert!(
14242                        p.starts_with("SubmenuCommit:AdvisorSyncBacklog:"),
14243                        "payload must address the key; got {p}"
14244                    );
14245                }
14246                other => panic!("expected ConfigAction, got {other:?}"),
14247            }
14248        }
14249
14250        // Commit "async" through the commit helper.
14251        let status = commit_submenu_choice(
14252            &mut state,
14253            SettingKey::AdvisorSyncBacklog,
14254            "async".to_string(),
14255        )
14256        .expect("valid option commits");
14257        assert!(status.contains("async"), "status names the new value");
14258
14259        // The sandbox file holds the new value.
14260        let contents = std::fs::read_to_string(&sandbox).expect("sandbox written");
14261        assert!(
14262            contents.contains("async"),
14263            "sandbox must hold the async choice: {contents}"
14264        );
14265        // The panel reopened with the status line.
14266        let overlay = state.overlay.as_ref().expect("panel reopened");
14267        assert!(
14268            overlay
14269                .lines
14270                .first()
14271                .map(|l| l.contains("async"))
14272                .unwrap_or(false),
14273            "status line must show the new value"
14274        );
14275    }
14276
14277    /// The multiselect editor toggles a non-essential tool into (and
14278    /// out of) `disabled_tools`, persisting through the sandbox, and
14279    /// REFUSES an essential tool with an Error line and no write.
14280    #[test]
14281    fn multiselect_toggles_tool_and_refuses_essential() {
14282        let session = make_session_with_tools();
14283        let tmp = tempfile::tempdir().expect("tempdir");
14284        let sandbox = tmp.path().join("settings.json");
14285        let mut state = RenderState::default();
14286        state.settings_override_path = Some(sandbox.clone());
14287        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
14288        let handle = InlineHandle::new_for_tests(tx);
14289
14290        // The overlay lists the registry's tools (bash + commit from
14291        // the fixture), sorted, with essential badges.
14292        open_disabled_tools_multiselect(&mut state, &session);
14293        let overlay = state.overlay.as_ref().expect("multiselect opens");
14294        let titles: Vec<&str> = overlay.items.iter().map(|i| i.title.as_str()).collect();
14295        assert_eq!(titles, vec!["bash", "commit"], "registry tools, sorted");
14296        let bash = &overlay.items[0];
14297        assert_eq!(bash.badge.as_deref(), Some("essential"));
14298        let commit = &overlay.items[1];
14299        assert_eq!(commit.badge.as_deref(), Some("enabled"));
14300
14301        // Toggle the optional tool OFF (disable): commit ∈ disabled_tools.
14302        commit_disabled_tool_toggle(
14303            &mut state,
14304            &handle,
14305            &session,
14306            "commit".to_string(),
14307            false, // not essential
14308            false, // currently enabled
14309        );
14310        let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14311            .expect("sandbox parses");
14312        assert!(
14313            saved.disabled_tools.iter().any(|t| t == "commit"),
14314            "toggle must add 'commit' to disabled_tools; got {:?}",
14315            saved.disabled_tools
14316        );
14317
14318        // Toggle it back ON (enable): commit ∉ disabled_tools.
14319        commit_disabled_tool_toggle(
14320            &mut state,
14321            &handle,
14322            &session,
14323            "commit".to_string(),
14324            false, // not essential
14325            true,  // currently disabled
14326        );
14327        let saved: Settings = serde_json::from_str(&std::fs::read_to_string(&sandbox).unwrap())
14328            .expect("sandbox parses");
14329        assert!(
14330            !saved.disabled_tools.iter().any(|t| t == "commit"),
14331            "toggle must remove 'commit' from disabled_tools; got {:?}",
14332            saved.disabled_tools
14333        );
14334
14335        // Essential refusal: an Error line is emitted, no write happens.
14336        let before = std::fs::read_to_string(&sandbox).unwrap();
14337        commit_disabled_tool_toggle(
14338            &mut state,
14339            &handle,
14340            &session,
14341            "bash".to_string(),
14342            true,  // essential
14343            false, // currently enabled
14344        );
14345        let after = std::fs::read_to_string(&sandbox).unwrap();
14346        assert_eq!(before, after, "essential refusal must not write");
14347        let mut saw_refusal = false;
14348        while let Ok(cmd) = rx.try_recv() {
14349            if let InlineCommand::AppendLine { kind, segments } = cmd
14350                && matches!(kind, InlineMessageKind::Error)
14351                && segments.iter().any(|s| s.text.contains("essential"))
14352            {
14353                saw_refusal = true;
14354            }
14355        }
14356        assert!(
14357            saw_refusal,
14358            "essential refusal must surface an Error line mentioning 'essential'"
14359        );
14360    }
14361}
14362
14363// Inline image previews — generate_image result hook (kitty/iTerm2).
14364// ═════════════════════════════════════════════════════════════════════════
14365
14366#[cfg(test)]
14367mod image_preview_hook_tests {
14368    use super::*;
14369    use base64::{Engine, engine::general_purpose};
14370    use tokio::sync::mpsc;
14371
14372    /// Craft a generate_image tool-result body in the exact shape
14373    /// `GenerateImageTool::execute` produces.
14374    fn image_result_content(payload: &[u8]) -> String {
14375        let b64 = general_purpose::STANDARD.encode(payload);
14376        format!(
14377            "Generated 1 image(s).\n\nImage 1 ({} bytes, base64):\n{}\n",
14378            payload.len(),
14379            b64
14380        )
14381    }
14382
14383    fn fresh_handle() -> (InlineHandle, mpsc::UnboundedReceiver<InlineCommand>) {
14384        let (tx, rx) = mpsc::unbounded_channel();
14385        (InlineHandle::new_for_tests(tx), rx)
14386    }
14387
14388    fn apply_all(state: &mut RenderState, rx: &mut mpsc::UnboundedReceiver<InlineCommand>) {
14389        while let Ok(cmd) = rx.try_recv() {
14390            apply_command(state, cmd);
14391        }
14392    }
14393
14394    fn transcript_text(state: &RenderState) -> Vec<String> {
14395        state
14396            .transcript
14397            .iter()
14398            .map(|l| {
14399                l.segments
14400                    .iter()
14401                    .map(|s| s.text.as_str())
14402                    .collect::<String>()
14403            })
14404            .collect()
14405    }
14406
14407    /// A successful generate_image result renders the text-fallback row
14408    /// (never the raw base64 wall) and enqueues the decoded PNG keyed by
14409    /// its content hash, pointing at the fallback row.
14410    #[test]
14411    fn generate_image_result_renders_fallback_row_and_enqueues_live_preview() {
14412        let mut state = RenderState::default();
14413        let (handle, mut rx) = fresh_handle();
14414        let payload = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14415
14416        map_agent_event(
14417            &handle,
14418            AgentEvent::ToolExecutionStart {
14419                tool_call_id: "img-1".into(),
14420                tool_name: "generate_image".into(),
14421                args: serde_json::json!({"prompt": "a cat"}),
14422                intent: None,
14423                context: None,
14424            },
14425            &mut state,
14426        );
14427        map_agent_event(
14428            &handle,
14429            AgentEvent::ToolExecutionEnd {
14430                tool_call_id: "img-1".into(),
14431                tool_name: "generate_image".into(),
14432                intent: None,
14433                result: oxicode_ai::ToolResult {
14434                    tool_call_id: "img-1".into(),
14435                    content: image_result_content(&payload),
14436                    status: "success".into(),
14437                },
14438                is_error: false,
14439            },
14440            &mut state,
14441        );
14442        apply_all(&mut state, &mut rx);
14443
14444        let texts = transcript_text(&state);
14445        let fallback_idx = texts
14446            .iter()
14447            .position(|t| t.contains("[image: generate_image:"))
14448            .expect("fallback row rendered in the tool box");
14449        assert!(
14450            texts
14451                .iter()
14452                .all(|t| !t.contains(&general_purpose::STANDARD.encode(payload))),
14453            "raw base64 must never render as text"
14454        );
14455
14456        assert_eq!(
14457            state.image_previews.pending_len(),
14458            1,
14459            "decoded PNG enqueued for live placement"
14460        );
14461        let pending = &state.image_previews.pending()[0];
14462        assert_eq!(&*pending.png, &payload, "decoded bytes round-trip");
14463        // The pending preview's label resolves to the fallback row — this
14464        // is the lookup the render pass uses to anchor the placement.
14465        assert!(
14466            texts[fallback_idx].contains(&pending.label),
14467            "label {label:?} matches the fallback row {row:?}",
14468            label = pending.label,
14469            row = texts[fallback_idx],
14470        );
14471    }
14472
14473    /// Results without an embedded base64 image (API errors, empty
14474    /// responses) keep the generic preview path and enqueue nothing.
14475    #[test]
14476    fn generate_image_without_payload_keeps_generic_preview() {
14477        let mut state = RenderState::default();
14478        let (handle, mut rx) = fresh_handle();
14479        map_agent_event(
14480            &handle,
14481            AgentEvent::ToolExecutionStart {
14482                tool_call_id: "img-2".into(),
14483                tool_name: "generate_image".into(),
14484                args: serde_json::json!({"prompt": "a cat"}),
14485                intent: None,
14486                context: None,
14487            },
14488            &mut state,
14489        );
14490        map_agent_event(
14491            &handle,
14492            AgentEvent::ToolExecutionEnd {
14493                tool_call_id: "img-2".into(),
14494                tool_name: "generate_image".into(),
14495                intent: None,
14496                result: oxicode_ai::ToolResult {
14497                    tool_call_id: "img-2".into(),
14498                    content: "Image generation completed but returned no images.".into(),
14499                    status: "success".into(),
14500                },
14501                is_error: false,
14502            },
14503            &mut state,
14504        );
14505        apply_all(&mut state, &mut rx);
14506        let texts = transcript_text(&state);
14507        assert!(
14508            texts.iter().any(|t| t.contains("returned no images")),
14509            "generic preview path still renders the summary"
14510        );
14511        assert_eq!(state.image_previews.pending_len(), 0);
14512    }
14513
14514    /// End-to-end: a live frame records the anchor for the pending
14515    /// image's tool box, and the post-draw emit produces the full kitty
14516    /// sequence (CUP + transmit + place) for it.
14517    #[test]
14518    fn live_frame_anchors_and_emits_kitty_sequence() {
14519        use crate::tui_vt::image_preview::{ImagePreviews, ImageSupport};
14520        use ratatui::{Terminal, backend::TestBackend};
14521
14522        let mut state = RenderState::default();
14523        state.image_previews = ImagePreviews::new(ImageSupport::Kitty);
14524        let (handle, mut rx) = fresh_handle();
14525        let payload = [0x89u8, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14526        map_agent_event(
14527            &handle,
14528            AgentEvent::ToolExecutionStart {
14529                tool_call_id: "img-3".into(),
14530                tool_name: "generate_image".into(),
14531                args: serde_json::json!({"prompt": "a dog"}),
14532                intent: None,
14533                context: None,
14534            },
14535            &mut state,
14536        );
14537        map_agent_event(
14538            &handle,
14539            AgentEvent::ToolExecutionEnd {
14540                tool_call_id: "img-3".into(),
14541                tool_name: "generate_image".into(),
14542                intent: None,
14543                result: oxicode_ai::ToolResult {
14544                    tool_call_id: "img-3".into(),
14545                    content: image_result_content(&payload),
14546                    status: "success".into(),
14547                },
14548                is_error: false,
14549            },
14550            &mut state,
14551        );
14552        apply_all(&mut state, &mut rx);
14553
14554        // Render one live frame (records the anchor through the shared
14555        // interior-mutable channel).
14556        let backend = TestBackend::new(80, 24);
14557        let mut terminal = Terminal::new(backend).expect("backend");
14558        let (tx, _drain) = mpsc::unbounded_channel();
14559        terminal
14560            .draw(|frame| render_frame(frame, &state, &InlineHandle::new_for_tests(tx)))
14561            .expect("draw");
14562        let buf = terminal.backend().buffer().clone();
14563        let frame_text: String = (0..buf.area().height)
14564            .map(|y| {
14565                (0..buf.area().width)
14566                    .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
14567                    .collect::<String>()
14568            })
14569            .collect::<Vec<_>>()
14570            .join("\n");
14571        assert!(
14572            frame_text.contains("[image: generate_image:"),
14573            "live frame paints the fallback row"
14574        );
14575
14576        // Post-draw emit: full kitty stream for the anchored box.
14577        let seq = state.image_previews.emit_live(state.committed_entries);
14578        assert!(seq.contains("\x1b["));
14579        assert!(seq.contains("\x1b_Ga=t,f=100"), "transmit");
14580        assert!(seq.contains("a=p"), "placement");
14581        assert_eq!(state.image_previews.pending_len(), 0, "placed and consumed");
14582    }
14583
14584    /// `extract_generated_png` — the marker parse powering the hook.
14585    #[test]
14586    fn extract_generated_png_parses_first_image_and_rejects_garbage() {
14587        let payload = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
14588        assert_eq!(
14589            extract_generated_png(&image_result_content(&payload)),
14590            Some(payload),
14591            "first base64 blob after the marker decodes"
14592        );
14593        // Multiple images: only the first is previewed. Payloads clear
14594        // the 8-byte PNG-header sanity floor.
14595        let first: Vec<u8> = (1u8..=8).collect();
14596        let second: Vec<u8> = (9u8..=16).collect();
14597        let two = format!(
14598            "Generated 2 image(s).\n\nImage 1 (8 bytes, base64):\n{}\n\nImage 2 (8 bytes, base64):\n{}\n",
14599            general_purpose::STANDARD.encode(&first),
14600            general_purpose::STANDARD.encode(&second),
14601        );
14602        assert_eq!(extract_generated_png(&two), Some(first));
14603        // No marker / invalid base64 / sub-PNG-header payload → None.
14604        assert_eq!(extract_generated_png("plain text output"), None);
14605        assert_eq!(
14606            extract_generated_png("Image 1 (8 bytes, base64):\n!!!not-base64!!!\n"),
14607            None
14608        );
14609        assert_eq!(
14610            extract_generated_png("Image 1 (2 bytes, base64):\n AQID \n"),
14611            None,
14612            "payloads shorter than a PNG header are rejected"
14613        );
14614    }
14615}