Skip to main content

rpi_cli/
interactive_tui.rs

1//! Interactive mode for pi-cli.
2//!
3//! Full-screen terminal UI with a streaming transcript, an editor, a live
4//! status indicator, and tool-execution display. Mirrors the TypeScript
5//! `packages/coding-agent/src/modes/interactive/interactive-mode.ts` event→UI
6//! mapping (`handleEvent`), driven by the live `AgentEvent` stream the harness
7//! emits via the `BroadcastEmitter` installed in [`crate::session`].
8//!
9//! Key architecture facts (see `docs/tui-gap-analysis.md`):
10//! - `TuiAltScreen::start()` and `show_overlay` are stubs, so this module owns
11//!   a `spawn_blocking` crossterm `read()` loop for key dispatch and a
12//!   `tokio::spawn` task that drains `broadcast::Receiver<AgentEvent>` into UI
13//!   mutations.
14//! - The layout root is built ONCE at startup (mirrors the TS
15//!   `fullscreenLayoutRoot`); per-message we mutate only `chat_container` /
16//!   `status_container` / `autocomplete_container` children and call
17//!   `request_render(false)` so the differential renderer repaints just the
18//!   changed rows.
19//! - Selectors (`/model` `/session` `/theme`) are implemented by **swapping the
20//!   `editor_container` child** (the TS `showSelector` swap pattern,
21//!   `interactive-mode.ts:4354-4377`) — the `show_overlay` stub is avoided
22//!   entirely. An `active_selector` state field holds the live `SelectList`;
23//!   while it is `Some` the key loop routes to it first and restores the editor
24//!   on done/cancel.
25
26use std::collections::HashMap;
27use std::io::IsTerminal;
28use std::sync::Arc;
29use std::sync::mpsc::channel;
30
31use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
32use tokio::sync::broadcast;
33
34use rpi_agent::{AgentEvent, AgentMessage};
35use rpi_ai::types::{AssistantMessage, Content};
36use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
37use rpi_tui::{
38    AutocompleteManager, CombinedAutocompleteProvider, Container, Editor, EditorOptions,
39    EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, Loader, ProcessTerminal,
40    ScrollView, ScrollViewOptions, SlashCommand, SlashCommandAutocompleteProvider, Spacer,
41    StackChild, StackEntry, Text, TuiAltScreen, TUI, VStack, AssistantBlock,
42    AssistantMessageComponent, AssistantMessageOptions, AutocompleteSuggestions,
43    FooterComponent, SelectList, SelectItem, ThemeManager, ThemePreset,
44    ToolExecutionComponent, render_diff,
45    BashExecutionComponent, BashTruncation, UserMessageComponent,
46};
47
48#[allow(unused_imports)]
49use rpi_tui::BashStatus;
50
51use crate::args::Args;
52
53// ===========================================================================
54// Slash commands
55// ===========================================================================
56
57/// Result of slash command handling.
58enum SlashCommandResult {
59    /// Exit the application.
60    Exit,
61    /// Clear the chat.
62    ClearChat,
63    /// Unknown command.
64    Unknown,
65    /// Not a command, send as message.
66    SendMessage(String),
67    /// Show help information.
68    Help,
69    /// Show version.
70    Version,
71    /// Show hotkeys.
72    Hotkeys,
73    /// Open the model selector overlay.
74    SelectModel,
75    /// Open the thinking-level selector overlay.
76    SelectThinking,
77    /// Open the tools toggle selector overlay.
78    SelectTools,
79    /// Open the image-display toggle overlay.
80    SelectImages,
81    /// Show the armin easter-egg.
82    Armin,
83    /// Show the earendil announcement.
84    Earendil,
85    /// Open the session selector overlay.
86    SelectSession,
87    /// Open the theme selector overlay.
88    SelectTheme,
89    /// Compact the conversation (lane.compact).
90    Compact,
91    /// Copy the last assistant message to the clipboard.
92    Copy,
93    /// Not supported in this v1 build (carries the command for the message).
94    Unsupported(String),
95}
96
97/// Handle slash commands. Returns the result indicating what action to take.
98///
99/// Mirrors the v1-applicable subset of the TS `BUILTIN_SLASH_COMMANDS`
100/// (`.reference/.../core/slash-commands.ts`). Commands beyond the v1 surface
101/// resolve to `Unsupported` with a consistent message.
102fn handle_slash_command(text: &str) -> SlashCommandResult {
103    let parts: Vec<&str> = text.split_whitespace().collect();
104    if parts.is_empty() {
105        return SlashCommandResult::SendMessage(text.to_string());
106    }
107
108    let command = parts[0];
109    match command {
110        "/help" | "/?" => SlashCommandResult::Help,
111        "/clear" | "/new" => SlashCommandResult::ClearChat,
112        "/exit" | "/quit" | "/q" => SlashCommandResult::Exit,
113        "/version" | "/v" => SlashCommandResult::Version,
114        "/model" | "/m" => SlashCommandResult::SelectModel,
115        "/thinking" | "/think" => SlashCommandResult::SelectThinking,
116        "/tools" => SlashCommandResult::SelectTools,
117        "/images" => SlashCommandResult::SelectImages,
118        "/armin" => SlashCommandResult::Armin,
119        "/earendil" => SlashCommandResult::Earendil,
120        "/hotkeys" => SlashCommandResult::Hotkeys,
121        "/session" | "/resume" => SlashCommandResult::SelectSession,
122        "/theme" => SlashCommandResult::SelectTheme,
123        "/compact" => SlashCommandResult::Compact,
124        "/copy" => SlashCommandResult::Copy,
125        // `/name` is recognized-v1 but inert (no session-renaming surface yet).
126        "/name" => SlashCommandResult::Unsupported("/name".to_string()),
127        // The remaining TS builtins are out of v1 scope.
128        "/settings"
129        | "/scoped-models"
130        | "/export"
131        | "/import"
132        | "/share"
133        | "/fork"
134        | "/clone"
135        | "/tree"
136        | "/trust"
137        | "/login"
138        | "/logout"
139        | "/reload" => SlashCommandResult::Unsupported(command.to_string()),
140        _ => SlashCommandResult::Unknown,
141    }
142}
143
144/// The v1 slash commands surfaced to the autocomplete provider (the TS
145/// `BUILTIN_SLASH_COMMANDS` v1 subset, with descriptions). Kept in sync with
146/// [`handle_slash_command`] so `/`-autocomplete lists exactly the commands the
147/// dispatcher recognizes.
148fn v1_slash_commands() -> Vec<SlashCommand> {
149    vec![
150        SlashCommand { name: "/help".into(), description: "Show available commands".into() },
151        SlashCommand { name: "/clear".into(), description: "Clear the conversation".into() },
152        SlashCommand { name: "/new".into(), description: "Clear the conversation".into() },
153        SlashCommand { name: "/exit".into(), description: "Exit the application".into() },
154        SlashCommand { name: "/quit".into(), description: "Exit the application".into() },
155        SlashCommand { name: "/version".into(), description: "Show version information".into() },
156        SlashCommand { name: "/model".into(), description: "Choose a model (selector)".into() },
157        SlashCommand { name: "/thinking".into(), description: "Set thinking level (selector)".into() },
158        SlashCommand { name: "/tools".into(), description: "Toggle tools on/off".into() },
159        SlashCommand { name: "/images".into(), description: "Toggle inline images".into() },
160        SlashCommand { name: "/session".into(), description: "List saved sessions".into() },
161        SlashCommand { name: "/theme".into(), description: "Choose a theme (selector)".into() },
162        SlashCommand { name: "/compact".into(), description: "Compact the conversation".into() },
163        SlashCommand { name: "/copy".into(), description: "Copy last reply to clipboard".into() },
164        SlashCommand { name: "/hotkeys".into(), description: "Show keyboard shortcuts".into() },
165        SlashCommand { name: "/armin".into(), description: "??? (easter egg)".into() },
166        SlashCommand { name: "/earendil".into(), description: "Announcement".into() },
167    ]
168}
169
170// ===========================================================================
171// Channel + helpers
172// ===========================================================================
173
174/// Message type for communication between the key/callback threads and the
175/// main async loop.
176enum TuiMessage {
177    UserInput(String),
178    Exit,
179    /// Clear the transcript (from `/clear`).
180    ClearChat,
181    /// Compact the conversation (from `/compact`).
182    Compact,
183    /// Copy the last assistant reply to the clipboard (from `/copy`).
184    Copy,
185}
186
187/// Extract the concatenated text content from an assistant message (mirrors
188/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
189fn assistant_text(msg: &AssistantMessage) -> String {
190    msg.content
191        .iter()
192        .filter_map(|c| match c {
193            Content::Text(t) => Some(t.text.clone()),
194            _ => None,
195        })
196        .collect()
197}
198
199/// Project an assistant message's content into the provider-free
200/// [`AssistantBlock`] list (text + thinking blocks, in document order) the
201/// `AssistantMessageComponent` renders. Tool-call/image blocks are dropped —
202/// they're rendered by their own components in the transcript. This keeps the
203/// thinking blocks visible in the TUI (they previously vanished because the
204/// stream path only fed the concatenated *text* into the component).
205fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
206    msg.content
207        .iter()
208        .filter_map(|c| match c {
209            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
210            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
211            _ => None,
212        })
213        .collect()
214}
215
216/// The name displayed for a model id (last path segment / after the final
217/// `:`), to keep the footer compact.
218fn short_model_name(id: &str) -> String {
219    id.rsplit([':', '/'])
220        .next()
221        .filter(|s| !s.is_empty())
222        .unwrap_or(id)
223        .to_string()
224}
225
226// ===========================================================================
227// Streaming run status
228// ===========================================================================
229
230/// The live status of the agent run, fed to the footer + status slot.
231#[derive(Clone, Copy, PartialEq, Eq)]
232enum RunStatus {
233    Idle,
234    Working,
235    Aborting,
236}
237
238/// Which selector overlay (if any) is currently swapped into the editor slot.
239#[derive(Clone, Copy, PartialEq, Eq)]
240enum SelectorKind {
241    /// `/model` — available models (live switch via `lane.set_model`).
242    Model,
243    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
244    Thinking,
245    /// `/tools` — toggle builtin tools on/off.
246    Tools,
247    /// `/images` — toggle inline image rendering.
248    Images,
249    /// `/session` — saved JSONL sessions (restore not implemented in v1).
250    Session,
251    /// `/theme` — dark / light / monochrome presets applied live.
252    Theme,
253}
254
255/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
256/// and the render-tick task.
257struct TuiState {
258    /// The in-flight streaming assistant message (cleared on finalize).
259    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
260    /// Tool-execution components keyed by `tool_call_id`.
261    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
262    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
263    /// generic tool map so bash output streams into a `BashExecutionComponent`
264    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
265    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
266    /// The most recently created tool component (bash or generic). Ctrl+T
267    /// toggles `expanded` on this — a pragmatic "expand last tool" since the
268    /// key loop has no per-line focus. Updated on every tool/bash Start.
269    last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
270    /// Run status for the status indicator + interrupt routing.
271    status: std::sync::Mutex<RunStatus>,
272    /// The footer, updated live by the drain task.
273    footer: Arc<FooterComponent>,
274    /// The status-container (status slot in the dock) — cleared/filled with a
275    /// loader while a run is active.
276    status_container: Arc<Container>,
277    /// The chat transcript container.
278    chat_container: Arc<Container>,
279    /// The active loader shown while `Working`.
280    loader: Arc<Loader>,
281    /// The last finalized assistant text (for `/copy`). Updated by the drain
282    /// task on `MessageEnd` / `AgentEnd`.
283    last_assistant_text: std::sync::Mutex<String>,
284    /// The active selector overlay, swapped into the editor slot. `Some` while
285    /// a selector is open; the key loop routes to it first and restores the
286    /// editor on done/cancel.
287    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
288    /// The autocomplete manager (slash + @file providers) consulted on every
289    /// editor keystroke.
290    autocomplete: AutocompleteManager,
291    /// The container rendered above the editor holding the live autocomplete
292    /// suggestion list (cleared when there are no suggestions).
293    autocomplete_container: Arc<Container>,
294    /// The owned theme manager — `/theme` applies presets here. The global
295    /// `theme()` is read-only after OnceLock init, so per-instance state is the
296    /// only way to apply a preset at runtime.
297    theme_manager: Arc<ThemeManager>,
298    /// The alt-screen handle, held so `set_status` can reflect run state in the
299    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
300    /// that never call `set_status` with a title.
301    tui: Option<Arc<TuiAltScreen>>,
302    /// The model id currently shown in the footer + used as the Ctrl+M
303    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
304    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
305    current_model_id: std::sync::Mutex<String>,
306    /// Whether inline image rendering is enabled (`/images` toggle). Stored
307    /// even though image wiring is minimal this pass — the flag is consulted
308    /// where images would be shown and echoed back by `/images`.
309    show_images: std::sync::Mutex<bool>,
310}
311
312impl TuiState {
313    fn set_status(&self, status: RunStatus) {
314        *self.status.lock().unwrap() = status;
315        match status {
316            RunStatus::Working => {
317                self.footer.set_status("Working…");
318                // Reflect the in-flight turn in the terminal window/tab title
319                // (OSC 2). No-op when `tui` is absent (unit tests).
320                if let Some(tui) = &self.tui {
321                    tui.set_title("rpi — working");
322                }
323                self.status_container.clear();
324                self.loader.start();
325                self.status_container.add_child(self.loader.clone());
326            }
327            RunStatus::Aborting => {
328                self.footer.set_status("Aborting…");
329            }
330            RunStatus::Idle => {
331                self.footer.set_status("");
332                if let Some(tui) = &self.tui {
333                    tui.set_title("rpi");
334                }
335                self.loader.stop();
336                self.status_container.clear();
337            }
338        }
339    }
340
341    /// Whether a selector overlay is currently open (routes keys to it first).
342    fn selector_open(&self) -> bool {
343        self.active_selector.lock().unwrap().is_some()
344    }
345
346    /// Record a freshly created tool component as the "most recent" so Ctrl+T
347    /// can toggle its expansion. Idempotent overwrites — only the latest lives.
348    fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
349        *self.last_tool_comp.lock().unwrap() = Some(comp);
350    }
351
352    /// Toggle `expanded` on the most recent tool component (Ctrl+T). Returns
353    /// `true` if a component was toggled. Limitation: the key loop tracks no
354    /// per-line focus, so this always targets the *last* tool shown — not the
355    /// one under the cursor. Documented in the plan; a focused expansion would
356    /// need mouse/line hit-testing which is out of scope this pass.
357    fn toggle_expand_last_tool(&self) -> bool {
358        if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
359            let cur = comp.is_expanded();
360            comp.set_expanded(!cur);
361            true
362        } else {
363            false
364        }
365    }
366
367    /// The model id currently tracked as active (footer + Ctrl+M anchor).
368    fn current_model_id(&self) -> String {
369        self.current_model_id.lock().unwrap().clone()
370    }
371
372    /// Update the tracked model id + footer label after a switch (live or
373    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
374    fn set_current_model(&self, model: &rpi_ai::Model) {
375        *self.current_model_id.lock().unwrap() = model.id.clone();
376        self.footer.set_model(&short_model_name(&model.id));
377    }
378}
379
380// ===========================================================================
381// interactive_tui — the entry point
382// ===========================================================================
383
384/// TUI-based interactive mode.
385///
386/// `event_rx` carries the live `AgentEvent` stream (installed by
387/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
388/// fn), it falls back to a blocking, await-final-text path.
389///
390/// `model_catalog` is the read-only catalog the `/model` selector displays.
391///
392/// This implementation mirrors the TypeScript `InteractiveMode` class:
393/// build the layout root once, drain `AgentEvent`s into UI mutations that
394/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
395/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
396/// autocomplete are layered on via the editor-container swap pattern.
397pub async fn interactive_tui(
398    harness: &AgentHarness,
399    event_rx: Option<broadcast::Receiver<AgentEvent>>,
400    args: &Args,
401    model_catalog: Vec<rpi_ai::Model>,
402    initial: Option<String>,
403    extra_messages: &[String],
404    theme: Option<&str>,
405) -> i32 {
406    let lane: Arc<dyn AgentLane> = harness.lane("main");
407
408    // Resolve the active model once, up front. The full id feeds the TuiState
409    // tracking field + the selectors/key loop (which run on a blocking thread
410    // and can't await `lane.get_model()`); the short name feeds the footer.
411    let lane_model_id = lane
412        .get_model()
413        .await
414        .map(|m| m.id)
415        .unwrap_or_default();
416    let model_name = short_model_name(&lane_model_id);
417
418    // The cwd for @file autocomplete + session discovery.
419    let cwd = std::env::current_dir()
420        .map(|p| p.to_path_buf())
421        .unwrap_or_else(|_| std::path::PathBuf::from("."));
422
423    // Channel between the key/callback threads and the main async loop.
424    let (tx, rx) = channel::<TuiMessage>();
425
426    // ---- TUI + containers ----
427    let terminal = Box::new(ProcessTerminal::new());
428    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
429
430    let chat_container = Arc::new(Container::new());
431    add_welcome_message(&chat_container);
432
433    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
434    // banner + the earendil announcement once, then write the sentinel. The TS
435    // original is a multi-step dialog (theme picker + analytics opt-in); this
436    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
437    // analytics deferred — no telemetry wiring). See `extras.rs`.
438    crate::extras::maybe_first_time_setup(&chat_container);
439
440    // `document_container` wraps the welcome header + chat so the scrollview
441    // follows the whole transcript (mirrors TS `documentContainer`).
442    let document_container = Arc::new(Container::new());
443    document_container.add_child(chat_container.clone());
444
445    let scroll_view = Arc::new(ScrollView::new(
446        document_container.clone(),
447        ScrollViewOptions {
448            follow: FollowMode::End,
449            primary: true,
450            ..Default::default()
451        },
452    ));
453
454    // ---- Editor ----
455    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
456    // editor renders full-width `─` top/bottom borders with padding-only lines
457    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
458    let editor = Arc::new(Editor::new(
459        EditorOptions {
460            padding_x: 1,
461            ..Default::default()
462        },
463        EditorStyle::default(),
464        Arc::new(rpi_tui::Keybindings::new()),
465    ));
466
467    // ---- Footer + status ----
468    let footer = Arc::new(FooterComponent::new());
469    footer.set_model(&model_name);
470    footer.set_hints("Enter: Send | Shift+Enter: New line | Ctrl+C: Abort/Exit | Esc: Abort | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help");
471
472    let status_container = Arc::new(Container::new());
473    let loader = Arc::new(Loader::with_text("Working…"));
474
475    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
476    let autocomplete = AutocompleteManager::new();
477    {
478        let mut combined = CombinedAutocompleteProvider::new();
479        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
480            v1_slash_commands(),
481        )));
482        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(cwd.clone())));
483        autocomplete.set_provider(Arc::new(combined));
484    }
485    let autocomplete_container = Arc::new(Container::new());
486
487    let state = Arc::new(TuiState {
488        current_assistant: std::sync::Mutex::new(None),
489        tool_components: std::sync::Mutex::new(HashMap::new()),
490        bash_components: std::sync::Mutex::new(HashMap::new()),
491        last_tool_comp: std::sync::Mutex::new(None),
492        status: std::sync::Mutex::new(RunStatus::Idle),
493        footer: footer.clone(),
494        status_container: status_container.clone(),
495        chat_container: chat_container.clone(),
496        loader: loader.clone(),
497        last_assistant_text: std::sync::Mutex::new(String::new()),
498        active_selector: std::sync::Mutex::new(None),
499        autocomplete,
500        autocomplete_container: autocomplete_container.clone(),
501        theme_manager: Arc::new(ThemeManager::new()),
502        tui: Some(tui.clone()),
503        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
504        show_images: std::sync::Mutex::new(true),
505    });
506
507    // Apply the saved theme from `~/.rpi/agent/settings.json` (best-effort).
508    // The host passes `theme` in; when it matches a known preset it is applied
509    // immediately so launch opens in the user's chosen theme (matching pi
510    // reading `Settings.theme` at startup). Unknown values are ignored.
511    if let Some(theme_name) = theme {
512        let preset = match theme_name {
513            "light" => Some(ThemePreset::Light),
514            "monochrome" => Some(ThemePreset::Monochrome),
515            "dark" => Some(ThemePreset::Dark),
516            _ => None,
517        };
518        if let Some(preset) = preset {
519            state.theme_manager.apply_preset(preset);
520        }
521    }
522
523    // Capture the model catalog + cwd for the selector builders + the key loop
524    // (the callbacks fire on blocking threads and need owned data).
525    let model_catalog_arc = Arc::new(model_catalog.clone());
526    let lane_model_id = lane
527        .get_model()
528        .await
529        .map(|m| m.id)
530        .unwrap_or_default();
531
532    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
533    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
534    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
535    //
536    // The scrollview gets `basis(0)` so the constrained stack allocator starts
537    // it at zero height and grows it to fill the space the dock does not need
538    // — this keeps the dock (editor borders + footer) pinned to the bottom and
539    // never shrinks it below the editor's 3 rows (top border + content + bottom
540    // border). The editor_container is `shrink(0).min_size(3)` so a tall
541    // transcript can never clip the bordered editor below its minimum.
542    let editor_container = Arc::new(Container::new());
543    editor_container.add_child(editor.clone());
544
545    let dock = Arc::new(VStack::from_children(vec![
546        StackChild::Entry(StackEntry::new(status_container.clone())),
547        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
548        StackChild::Entry(
549            StackEntry::new(editor_container.clone())
550                .shrink(0)
551                .min_size(3),
552        ),
553        StackChild::Entry(StackEntry::new(footer.clone())),
554    ]));
555
556    let root = VStack::from_children(vec![
557        StackChild::Entry(
558            StackEntry::new(scroll_view.clone())
559                .basis(0)
560                .grow(1)
561                .shrink(1)
562                .min_size(1),
563        ),
564        StackChild::Entry(StackEntry::new(dock).shrink(1)),
565    ]);
566
567    tui.set_layout_root(Some(Arc::new(root)));
568    tui.set_focus(Some(editor.clone()));
569    editor.set_focused(true);
570
571    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
572    let chat_for_cb = chat_container.clone();
573    let tui_for_cb = tui.clone();
574    let tx_for_cb = tx.clone();
575    let state_for_cb = state.clone();
576    let editor_for_cb = editor.clone();
577    let lane_for_cb = lane.clone();
578    // Clone the shared selector inputs for the closure; the originals stay
579    // available for the key-dispatch loop below (Ctrl+L opens /model too).
580    let editor_container_for_cb = editor_container.clone();
581    let model_catalog_for_cb = model_catalog_arc.clone();
582    let lane_model_id_for_cb = lane_model_id.clone();
583    let cwd_for_cb = cwd.clone();
584    editor.on_submit(Arc::new(move |text: &str| {
585        let text = text.trim();
586        if text.is_empty() {
587            return;
588        }
589
590        if text.starts_with('/') {
591            match handle_slash_command(text) {
592                SlashCommandResult::Exit => {
593                    let _ = tx_for_cb.send(TuiMessage::Exit);
594                }
595                SlashCommandResult::ClearChat => {
596                    let _ = tx_for_cb.send(TuiMessage::ClearChat);
597                }
598                SlashCommandResult::Help => {
599                    add_help_message(&chat_for_cb);
600                    tui_for_cb.request_render(false);
601                }
602                SlashCommandResult::Version => {
603                    add_version_message(&chat_for_cb);
604                    tui_for_cb.request_render(false);
605                }
606                SlashCommandResult::Hotkeys => {
607                    add_hotkeys_message(&chat_for_cb);
608                    tui_for_cb.request_render(false);
609                }
610                SlashCommandResult::SelectModel => {
611                    open_model_selector(
612                        &state_for_cb,
613                        &editor_container_for_cb,
614                        &editor_for_cb,
615                        &tui_for_cb,
616                        &model_catalog_for_cb,
617                        &lane_for_cb,
618                        &lane_model_id_for_cb,
619                        &chat_for_cb,
620                    );
621                }
622                SlashCommandResult::SelectThinking => {
623                    open_thinking_selector(
624                        &state_for_cb,
625                        &editor_container_for_cb,
626                        &editor_for_cb,
627                        &tui_for_cb,
628                        &lane_for_cb,
629                        &model_catalog_for_cb,
630                        &lane_model_id_for_cb,
631                        &chat_for_cb,
632                    );
633                }
634                SlashCommandResult::SelectTools => {
635                    open_tools_selector(
636                        &state_for_cb,
637                        &editor_container_for_cb,
638                        &editor_for_cb,
639                        &tui_for_cb,
640                        &lane_for_cb,
641                        &chat_for_cb,
642                    );
643                }
644                SlashCommandResult::SelectImages => {
645                    open_images_selector(
646                        &state_for_cb,
647                        &editor_container_for_cb,
648                        &editor_for_cb,
649                        &tui_for_cb,
650                        &chat_for_cb,
651                    );
652                }
653                SlashCommandResult::Armin => {
654                    crate::extras::add_armin(&chat_for_cb);
655                    tui_for_cb.request_render(false);
656                }
657                SlashCommandResult::Earendil => {
658                    crate::extras::add_earendil(&chat_for_cb);
659                    tui_for_cb.request_render(false);
660                }
661                SlashCommandResult::SelectSession => {
662                    open_session_selector(
663                        &state_for_cb,
664                        &editor_container_for_cb,
665                        &editor_for_cb,
666                        &tui_for_cb,
667                        &cwd_for_cb,
668                    );
669                }
670                SlashCommandResult::SelectTheme => {
671                    open_theme_selector(
672                        &state_for_cb,
673                        &editor_container_for_cb,
674                        &editor_for_cb,
675                        &tui_for_cb,
676                    );
677                }
678                SlashCommandResult::Compact => {
679                    let _ = tx_for_cb.send(TuiMessage::Compact);
680                }
681                SlashCommandResult::Copy => {
682                    let _ = tx_for_cb.send(TuiMessage::Copy);
683                }
684                SlashCommandResult::Unsupported(cmd) => {
685                    add_note_message(
686                        &chat_for_cb,
687                        &format!("{cmd} is not supported in v1."),
688                    );
689                    tui_for_cb.request_render(false);
690                }
691                SlashCommandResult::Unknown => {
692                    add_error_message(
693                        &chat_for_cb,
694                        &format!("Unknown command: {text}. Type /help for available commands."),
695                    );
696                    tui_for_cb.request_render(false);
697                }
698                SlashCommandResult::SendMessage(msg) => {
699                    add_user_message(&chat_for_cb, &msg);
700                    tui_for_cb.request_render(false);
701                    let _ = tx_for_cb.send(TuiMessage::UserInput(msg));
702                }
703            }
704            return;
705        }
706
707        add_user_message(&chat_for_cb, text);
708        tui_for_cb.request_render(false);
709        let _ = tx_for_cb.send(TuiMessage::UserInput(text.to_string()));
710    }));
711
712    tui.start_readerless();
713
714    // ---- Streaming drain task ----
715    let drain_handle = if let Some(rx) = event_rx {
716        let tui_drain = tui.clone();
717        let state_drain = state.clone();
718        let chat_drain = chat_container.clone();
719        Some(tokio::spawn(async move {
720            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
721        }))
722    } else {
723        None
724    };
725
726    // ---- Render-tick task (advances the loader spinner while Working) ----
727    //
728    // The `Loader` only advances its frame on render; without a periodic
729    // `request_render` the spinner visibly freezes between events.
730    let tui_tick = tui.clone();
731    let state_tick = state.clone();
732    let tick_handle = tokio::spawn(async move {
733        let mut interval = tokio::time::interval(std::time::Duration::from_millis(120));
734        interval.tick().await; // discard immediate
735        loop {
736            interval.tick().await;
737            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
738            if working {
739                tui_tick.request_render(false);
740            }
741        }
742    });
743
744    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
745    let running = Arc::new(std::sync::Mutex::new(true));
746    let running_key = running.clone();
747    let tx_for_key = tx.clone();
748    let tui_for_key = tui.clone();
749    let editor_for_key = editor.clone();
750    let editor_container_for_key = editor_container.clone();
751    let scroll_for_key = scroll_view.clone();
752    let lane_for_key = lane.clone();
753    let state_for_key = state.clone();
754    // The Ctrl+L model selector needs the catalog + current id; these are
755    // already-known owned values (no async needed in the blocking key loop).
756    let catalog_for_key = model_catalog_arc.clone();
757    let lane_model_id_for_key = lane_model_id.clone();
758    let chat_for_key = chat_container.clone();
759
760    tokio::task::spawn_blocking(move || {
761        loop {
762            if !*running_key.lock().unwrap() {
763                break;
764            }
765            let Ok(ev) = crossterm::event::read() else {
766                continue;
767            };
768            // `Event::Resize` is delivered as its own event (not a Key). With
769            // `start_readerless` there is no competing terminal-reader thread to
770            // handle it, so refresh the cached terminal size here and force a
771            // full redraw so the constrained layout re-fits the new dimensions.
772            if let Event::Resize(_cols, _rows) = ev {
773                tui_for_key.refresh_size();
774                continue;
775            }
776            let Event::Key(key) = ev else { continue; };
777            // Drop release/repeat events — on Windows a single keystroke
778            // yields both a Press and a Release; without this filter every
779            // char is inserted twice. (Mirrors the TS `isKeyRelease` guard;
780            // the editor never sets `wants_key_release`.) On terminals that
781            // only emit Press this is a no-op.
782            if key.kind != KeyEventKind::Press {
783                continue;
784            }
785
786            // 1. A selector overlay is open → route to it first. Only Esc
787            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
788            //    escape to the selector; on done/cancel the selector callbacks
789            //    restore the editor and clear `active_selector`.
790            if state_for_key.selector_open() {
791                // Esc always cancels the selector (even with modifiers off).
792                if key.code == KeyCode::Esc {
793                    close_selector(
794                        &state_for_key,
795                        &editor_container_for_key,
796                        &editor_for_key,
797                        &tui_for_key,
798                    );
799                    continue;
800                }
801                let (selector, _kind) = state_for_key
802                    .active_selector
803                    .lock()
804                    .unwrap()
805                    .clone()
806                    .expect("selector_open guaranteed Some");
807                selector.handle_key(key);
808                tui_for_key.request_render(false);
809                continue;
810            }
811
812            // 2. Ctrl+C: abort a run if one is active, else exit.
813            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
814                let status = *state_for_key.status.lock().unwrap();
815                if status == RunStatus::Working {
816                    state_for_key.set_status(RunStatus::Aborting);
817                    let lane = lane_for_key.clone();
818                    tokio::spawn(async move {
819                        let _ = lane.abort().await;
820                    });
821                } else {
822                    let _ = tx_for_key.send(TuiMessage::Exit);
823                }
824                continue;
825            }
826
827            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
828            //     selector is open Esc already cancelled it above; when idle,
829            //     Esc falls through to the editor (no-op-ish). Only fire while
830            //     Working so an idle Esc doesn't abort a non-existent run.
831            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
832                let status = *state_for_key.status.lock().unwrap();
833                if status == RunStatus::Working {
834                    state_for_key.set_status(RunStatus::Aborting);
835                    let lane = lane_for_key.clone();
836                    tokio::spawn(async move {
837                        let _ = lane.abort().await;
838                    });
839                    continue;
840                }
841            }
842
843            // 2c. Ctrl+T: toggle expansion on the most recent tool component.
844            //     The key loop tracks no per-line focus, so this is an "expand
845            //     last tool" affordance rather than a cursor-targeted toggle
846            //     (documented limitation; see `toggle_expand_last_tool`).
847            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
848                state_for_key.toggle_expand_last_tool();
849                tui_for_key.request_render(false);
850                continue;
851            }
852
853            // 2d. Ctrl+M: cycle to the next model in the catalog after the one
854            //     currently tracked in `current_model_id`, apply it live via
855            //     `lane.set_model` (takes effect on the next user message — the
856            //     in-flight run's config is already snapshotted), and update the
857            //     footer. `set_model` is async so it runs on a spawned task.
858            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
859                if let Some(next) = cycle_next_model(&catalog_for_key, &state_for_key.current_model_id()) {
860                    state_for_key.set_current_model(&next);
861                    let lane = lane_for_key.clone();
862                    tokio::spawn(async move {
863                        let _ = lane.set_model(next).await;
864                    });
865                    tui_for_key.request_render(false);
866                }
867                continue;
868            }
869
870            // 3. Ctrl+L: open the model selector (TS binds Ctrl+L to
871            //    model-select). Selecting now applies live via `lane.set_model`
872            //    (next-prompt effect); the catalog + current id were captured
873            //    before this blocking loop.
874            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
875                open_model_selector(
876                    &state_for_key,
877                    &editor_container_for_key,
878                    &editor_for_key,
879                    &tui_for_key,
880                    &catalog_for_key,
881                    &lane_for_key,
882                    &lane_model_id_for_key,
883                    &chat_for_key,
884                );
885                continue;
886            }
887
888            // 4. Tab: accept the top autocomplete suggestion (if any).
889            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
890                if accept_top_suggestion(&state_for_key, &editor_for_key) {
891                    tui_for_key.request_render(false);
892                }
893                continue;
894            }
895
896            // 5. Global transcript scroll: PageUp/PageDown move the scrollview.
897            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
898                scroll_for_key.scroll_by(-10);
899                tui_for_key.request_render(false);
900                continue;
901            }
902            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
903                scroll_for_key.scroll_by(10);
904                tui_for_key.request_render(false);
905                continue;
906            }
907
908            // 6. Otherwise forward to the editor + refresh autocomplete.
909            editor_for_key.handle_key(key);
910            refresh_autocomplete(&state_for_key, &editor_for_key);
911            tui_for_key.request_render(false);
912        }
913    });
914
915    // ---- Initial prompts (run before reading from the channel) ----
916    let mut prompts: Vec<String> = Vec::new();
917    if let Some(init) = initial {
918        prompts.push(init);
919    }
920    for m in extra_messages {
921        prompts.push(m.clone());
922    }
923    for prompt in prompts {
924        if !*running.lock().unwrap() {
925            break;
926        }
927        add_user_message(&chat_container, &prompt);
928        tui.request_render(false);
929        run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
930    }
931
932    // ---- Main loop: process submitted input + lifecycle messages ----
933    loop {
934        if !*running.lock().unwrap() {
935            break;
936        }
937        match rx.try_recv() {
938            Ok(TuiMessage::UserInput(prompt)) => {
939                run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
940            }
941            Ok(TuiMessage::ClearChat) => {
942                chat_container.clear();
943                add_welcome_message(&chat_container);
944                tui.request_render(false);
945            }
946            Ok(TuiMessage::Compact) => {
947                run_compact(&lane, &tui, &state).await;
948            }
949            Ok(TuiMessage::Copy) => {
950                copy_last_assistant(&state, &chat_container);
951                tui.request_render(false);
952            }
953            Ok(TuiMessage::Exit) => {
954                *running.lock().unwrap() = false;
955                break;
956            }
957            Err(std::sync::mpsc::TryRecvError::Empty) => {
958                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
959            }
960            Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
961        }
962    }
963
964    // ---- Shutdown ----
965    tick_handle.abort();
966    if let Some(handle) = drain_handle {
967        handle.abort();
968    }
969    tui.stop(Default::default());
970    println!("\nGoodbye!");
971    let _ = args;
972
973    0
974}
975
976// ===========================================================================
977// Run a single prompt (streaming or blocking)
978// ===========================================================================
979
980/// Drive a single prompt through the lane. When `streaming` is true, the
981/// `AgentEvent` drain task renders the response live and this function only
982/// awaits completion (to surface hard errors). When false (no `event_rx`),
983/// it falls back to the blocking await-final-text path.
984async fn run_prompt_streaming(
985    lane: &Arc<dyn AgentLane>,
986    prompt: &str,
987    tui: &Arc<TuiAltScreen>,
988    state: &Arc<TuiState>,
989    streaming: bool,
990) {
991    // Ensure the run starts in a clean streaming state.
992    state.set_status(RunStatus::Working);
993    tui.request_render(false);
994
995    let outcome = lane.prompt_text(prompt, Vec::new()).await;
996
997    // The drain task finalized the assistant message via MessageEnd/AgentEnd,
998    // but guard against runs that ended without a terminal event (e.g. a hard
999    // provider rejection before any streaming) by clearing streaming state.
1000    {
1001        let mut cur = state.current_assistant.lock().unwrap();
1002        if let Some(comp) = cur.take() {
1003            comp.set_streaming(false);
1004        }
1005    }
1006
1007    state.set_status(RunStatus::Idle);
1008
1009    match outcome {
1010        Ok(result) => match &result.outcome {
1011            HarnessRunOutcome::Failed { error, final_message, .. } => {
1012                // Only add an error line if the stream did NOT already render
1013                // an assistant message for it (drain task leaves
1014                // current_assistant Some only on an abrupt end).
1015                let already_rendered = final_message.is_some();
1016                if !already_rendered {
1017                    let msg = final_message
1018                        .as_ref()
1019                        .and_then(|m| m.error_message.clone())
1020                        .unwrap_or_else(|| format!("{error:?}"));
1021                    add_error_message(&state.chat_container, &msg);
1022                }
1023            }
1024            HarnessRunOutcome::Suspended { .. } => {
1025                add_error_message(
1026                    &state.chat_container,
1027                    "Run suspended (deferred) — resume is not supported in v1.",
1028                );
1029            }
1030            HarnessRunOutcome::Aborted { final_message, .. } => {
1031                // Aborted runs render their own partial/final message via the
1032                // stream; only add a note on the blocking fallback path.
1033                if !streaming {
1034                    add_error_message(&state.chat_container, "Request aborted.");
1035                    let _ = final_message; // (rendered by the stream in streaming mode)
1036                }
1037            }
1038            HarnessRunOutcome::Completed { final_message, .. } => {
1039                if !streaming {
1040                    let text = assistant_text(final_message);
1041                    if !text.is_empty() {
1042                        add_assistant_message_blocking(&state.chat_container, &text);
1043                        *state.last_assistant_text.lock().unwrap() = text;
1044                    }
1045                }
1046            }
1047        },
1048        Err(e) => {
1049            add_error_message(&state.chat_container, &e.to_string());
1050        }
1051    }
1052
1053    tui.request_render(false);
1054}
1055
1056/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
1057/// Reports the outcome as a transcript note; v1's compaction summarizes the
1058/// session in place, so no streaming display is wired (compaction emits no
1059/// `AgentEvent`s — only the harness bus `RunEnd`).
1060async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
1061    state.set_status(RunStatus::Working);
1062    tui.request_render(false);
1063    match lane.compact(None).await {
1064        Ok(_) => {
1065            add_note_message(&state.chat_container, "Conversation compacted.");
1066        }
1067        Err(e) => {
1068            add_error_message(
1069                &state.chat_container,
1070                &format!("Compact failed: {e}"),
1071            );
1072        }
1073    }
1074    state.set_status(RunStatus::Idle);
1075    tui.request_render(false);
1076}
1077
1078/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
1079/// when no clipboard is available (or the `clipboard` feature is off), prints a
1080/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
1081fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
1082    let text = state.last_assistant_text.lock().unwrap().clone();
1083    if text.is_empty() {
1084        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
1085        return;
1086    }
1087    if copy_to_clipboard(&text) {
1088        add_note_message(chat, "Copied last reply to the clipboard.");
1089    } else {
1090        // Clipboard unavailable — print the text to the transcript so the user
1091        // can select/copy it manually (degrades gracefully in headless envs).
1092        let preview: String = text.chars().take(200).collect();
1093        add_note_message(
1094            chat,
1095            &format!("Clipboard unavailable. Last reply: {preview}{}", if text.chars().count() > 200 { "…" } else { "" }),
1096        );
1097    }
1098}
1099
1100/// Best-effort clipboard write. Enabled only with the `clipboard` feature
1101/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
1102#[cfg(feature = "clipboard")]
1103fn copy_to_clipboard(text: &str) -> bool {
1104    match arboard::Clipboard::new() {
1105        Ok(mut cb) => cb.set_text(text).is_ok(),
1106        Err(_) => false,
1107    }
1108}
1109
1110#[cfg(not(feature = "clipboard"))]
1111fn copy_to_clipboard(_text: &str) -> bool {
1112    false
1113}
1114
1115/// Blocking fallback (no `event_rx`): render the final assistant text as a
1116/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
1117fn add_assistant_message_blocking(container: &Arc<Container>, text: &str) {
1118    if text.is_empty() {
1119        return;
1120    }
1121    let msg = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
1122    msg.update_text(text);
1123    container.add_child(msg);
1124    container.add_child(Arc::new(Spacer::new(1)));
1125}
1126
1127// ===========================================================================
1128// AgentEvent drain task — the streaming core
1129// ===========================================================================
1130
1131/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
1132/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
1133/// lifetime of the TUI.
1134async fn drain_agent_events(
1135    mut rx: broadcast::Receiver<AgentEvent>,
1136    tui: Arc<TuiAltScreen>,
1137    state: Arc<TuiState>,
1138    chat: Arc<Container>,
1139) {
1140    loop {
1141        match rx.recv().await {
1142            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
1143            Err(broadcast::error::RecvError::Lagged(_)) => {
1144                // We dropped some intermediate deltas; the next MessageUpdate/
1145                // MessageEnd carries a full partial snapshot so the UI re-syncs.
1146                continue;
1147            }
1148            Err(broadcast::error::RecvError::Closed) => break,
1149        }
1150    }
1151}
1152
1153/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
1154/// (`interactive-mode.ts:3068-3396`).
1155async fn handle_agent_event(
1156    event: AgentEvent,
1157    tui: &Arc<TuiAltScreen>,
1158    state: &Arc<TuiState>,
1159    chat: &Arc<Container>,
1160) {
1161    match event {
1162        AgentEvent::AgentStart => {
1163            state.set_status(RunStatus::Working);
1164            tui.request_render(false);
1165        }
1166
1167        AgentEvent::AgentEnd { .. } => {
1168            // Finalize any still-streaming assistant message.
1169            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1170                comp.set_streaming(false);
1171            }
1172            state.set_status(RunStatus::Idle);
1173            tui.request_render(false);
1174        }
1175
1176        AgentEvent::TurnStart => {
1177            // A new turn: reset the streaming-assistant guard so the next
1178            // MessageStart creates a fresh component.
1179            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1180                comp.set_streaming(false);
1181            }
1182        }
1183
1184        AgentEvent::TurnEnd { message, tool_results } => {
1185            // Finalize the assistant message for this turn.
1186            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1187                if let AgentMessage::Assistant(a) = &message {
1188                    comp.update_blocks(&assistant_blocks(a));
1189                }
1190                comp.set_streaming(false);
1191            }
1192            // Any tool results whose components were never ended by a
1193            // ToolExecutionEnd get a static rendering here (best-effort). The
1194            // normal path removes the component via ToolExecutionEnd; this is
1195            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
1196            let tools = state.tool_components.lock().unwrap();
1197            for tr in &tool_results {
1198                if tools.contains_key(&tr.tool_call_id) {
1199                    // Will be removed below via ToolExecutionEnd in the normal
1200                    // path; leave as-is if still present.
1201                    let _ = tr;
1202                }
1203            }
1204            drop(tools);
1205            tui.request_render(false);
1206        }
1207
1208        AgentEvent::MessageStart { message } => match message {
1209            AgentMessage::Assistant(a) => {
1210                let comp = Arc::new(AssistantMessageComponent::new(
1211                    AssistantMessageOptions::default(),
1212                ));
1213                comp.set_streaming(true);
1214                // Render text AND thinking blocks in order (the old path fed
1215                // only the concatenated text, so thinking blocks never showed).
1216                comp.update_blocks(&assistant_blocks(&a));
1217                chat.add_child(comp.clone());
1218                chat.add_child(Arc::new(Spacer::new(0)));
1219                *state.current_assistant.lock().unwrap() = Some(comp);
1220                tui.request_render(false);
1221            }
1222            // User / ToolResult / Custom starts are echoed at submit time or
1223            // via the tool-execution components; ignore here to avoid dupes.
1224            _ => {}
1225        },
1226
1227        AgentEvent::MessageUpdate { message, assistant_message_event } => {
1228            if let AgentMessage::Assistant(a) = &message {
1229                let text = assistant_text(a);
1230                // Scan content for finalized tool calls → proactively create
1231                // tool components (TS shows the tool as soon as the assistant
1232                // emits the ToolCall; ToolExecutionStart coalesces if it
1233                // already exists).
1234                for c in &a.content {
1235                    if let Content::ToolCall(tc) = c {
1236                        let mut tools = state.tool_components.lock().unwrap();
1237                        if !tools.contains_key(&tc.id) {
1238                            let comp = Arc::new(ToolExecutionComponent::new(
1239                                &tc.name,
1240                                &tc.arguments.to_string(),
1241                            ));
1242                            comp.set_running();
1243                            chat.add_child(comp.clone());
1244                            tools.insert(tc.id.clone(), comp);
1245                        }
1246                    }
1247                }
1248                let _ = assistant_message_event; // snapshot already applied via `a`
1249                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
1250                    // Stream the full block list (text + thinking) each update
1251                    // so thinking blocks render live as they arrive.
1252                    comp.update_blocks(&assistant_blocks(a));
1253                }
1254                *state.last_assistant_text.lock().unwrap() = text;
1255                tui.request_render(false);
1256            }
1257        }
1258
1259        AgentEvent::MessageEnd { message } => {
1260            if let AgentMessage::Assistant(a) = &message {
1261                let text = assistant_text(a);
1262                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
1263                    comp.update_blocks(&assistant_blocks(a));
1264                    comp.set_streaming(false);
1265                }
1266                // Cache the finalized text for `/copy`.
1267                if !text.is_empty() {
1268                    *state.last_assistant_text.lock().unwrap() = text;
1269                }
1270            }
1271            tui.request_render(false);
1272        }
1273
1274        AgentEvent::ToolExecutionStart { tool_call_id, tool_name, args } => {
1275            if tool_name == "bash" {
1276                // Bash streams into a dedicated BashExecutionComponent (command
1277                // header + live preview + exit/truncation status) rather than a
1278                // generic ToolExecutionComponent. The command comes from the
1279                // `command` field of the bash tool args.
1280                let command = args
1281                    .get("command")
1282                    .and_then(|v| v.as_str())
1283                    .unwrap_or("")
1284                    .to_string();
1285                let comp = Arc::new(BashExecutionComponent::new(command));
1286                chat.add_child(comp.clone());
1287                state
1288                    .bash_components
1289                    .lock()
1290                    .unwrap()
1291                    .insert(tool_call_id.clone(), comp);
1292            } else {
1293                let comp = {
1294                    let mut tools = state.tool_components.lock().unwrap();
1295                    if let Some(existing) = tools.get(&tool_call_id) {
1296                        existing.set_args(&args.to_string());
1297                        existing.clone()
1298                    } else {
1299                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
1300                        comp.set_running();
1301                        chat.add_child(comp.clone());
1302                        tools.insert(tool_call_id.clone(), comp.clone());
1303                        comp
1304                    }
1305                };
1306                state.remember_tool(comp);
1307            }
1308            tui.request_render(false);
1309        }
1310
1311        AgentEvent::ToolExecutionUpdate { tool_call_id, tool_name, partial_result, .. } => {
1312            if tool_name == "bash" {
1313                // Append the streamed chunk to the bash component's preview.
1314                let chunk = summarize_tool_result(&partial_result);
1315                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
1316                    bash.append_output(&chunk);
1317                } else {
1318                    // No component yet — create a running bash one so the
1319                    // partial shows (command unknown at Update time; leave blank).
1320                    let comp = Arc::new(BashExecutionComponent::new(""));
1321                    comp.append_output(&chunk);
1322                    chat.add_child(comp.clone());
1323                    state
1324                        .bash_components
1325                        .lock()
1326                        .unwrap()
1327                        .insert(tool_call_id.clone(), comp);
1328                }
1329            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
1330                let summary = summarize_tool_result(&partial_result);
1331                comp.set_result(&summary, false);
1332                apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
1333                state.remember_tool(comp.clone());
1334            } else {
1335                // No component yet — create a running one so the partial shows.
1336                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
1337                comp.set_running();
1338                comp.set_result(&summarize_tool_result(&partial_result), false);
1339                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
1340                chat.add_child(comp.clone());
1341                state
1342                    .tool_components
1343                    .lock()
1344                    .unwrap()
1345                    .insert(tool_call_id.clone(), comp.clone());
1346                state.remember_tool(comp);
1347            }
1348            tui.request_render(false);
1349        }
1350
1351        AgentEvent::ToolExecutionEnd { tool_call_id, tool_name, result, is_error } => {
1352            if tool_name == "bash" {
1353                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
1354                if let Some(bash) = bash {
1355                    finalize_bash(&bash, &result, is_error);
1356                } else {
1357                    // Bash ended without a Start/Update — render a finalized
1358                    // component directly from the result text.
1359                    let command = result
1360                        .details
1361                        .get("command")
1362                        .and_then(|v| v.as_str())
1363                        .unwrap_or("")
1364                        .to_string();
1365                    let comp = Arc::new(BashExecutionComponent::new(command));
1366                    comp.append_output(&summarize_tool_result(&result));
1367                    finalize_bash(&comp, &result, is_error);
1368                    chat.add_child(comp);
1369                }
1370            } else {
1371                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
1372                if let Some(comp) = comp {
1373                    comp.set_result(&summarize_tool_result(&result), is_error);
1374                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
1375                } else {
1376                    // Tool ended without a Start/Update (e.g. a very fast tool):
1377                    // render a finalized component directly.
1378                    let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
1379                    comp.set_result(&summarize_tool_result(&result), is_error);
1380                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
1381                    chat.add_child(comp.clone());
1382                    state.remember_tool(comp);
1383                }
1384            }
1385            tui.request_render(false);
1386        }
1387    }
1388}
1389
1390/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
1391/// tool result and mark the component complete. Mirrors the TS bash finalize
1392/// path; only the fields `BashExecutionComponent` needs are read.
1393fn finalize_bash(comp: &Arc<BashExecutionComponent>, result: &rpi_agent::AgentToolResult, is_error: bool) {
1394    // The exit code isn't in details directly (TS carries it elsewhere); use
1395    // `is_error` as the error signal and 0/1 as a best-effort exit code.
1396    let exit_code = if is_error { Some(1) } else { Some(0) };
1397    let truncated = result
1398        .details
1399        .get("truncation")
1400        .and_then(|t| t.get("truncated"))
1401        .and_then(|v| v.as_bool())
1402        .unwrap_or(false);
1403    let full_output_path = result
1404        .details
1405        .get("full_output_path")
1406        .and_then(|v| v.as_str())
1407        .map(|s| s.to_string());
1408    let truncation = BashTruncation {
1409        truncated,
1410        full_output_path,
1411    };
1412    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
1413    comp.set_complete(exit_code, cancelled, truncation);
1414}
1415
1416/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
1417/// display-diff string, render it with colors and attach to the component so
1418/// the changes show in the transcript. `write` has no diff (details: Null) and
1419/// stays a plain summary.
1420fn apply_edit_diff(
1421    comp: &Arc<ToolExecutionComponent>,
1422    tool_name: &str,
1423    details: &serde_json::Value,
1424    tui: &Arc<TuiAltScreen>,
1425) {
1426    if tool_name != "edit" {
1427        return;
1428    }
1429    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
1430        return;
1431    };
1432    if diff_text.is_empty() {
1433        return;
1434    }
1435    let width = tui.width();
1436    let lines = render_diff(diff_text, width);
1437    comp.set_diff(lines);
1438}
1439
1440/// Render an `AgentToolResult` as a single-line summary for the
1441/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
1442fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
1443    use rpi_agent::TextContentOrImage;
1444    let mut parts: Vec<String> = Vec::new();
1445    for c in &result.content {
1446        if let TextContentOrImage::Text(t) = c {
1447            parts.push(t.text.clone());
1448        }
1449    }
1450    let joined = parts.join("\n");
1451    // Keep the tool line compact: collapse to a single line, trim length.
1452    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
1453    if one_line.chars().count() > 200 {
1454        let truncated: String = one_line.chars().take(200).collect();
1455        format!("{truncated}…")
1456    } else {
1457        one_line
1458    }
1459}
1460
1461// ===========================================================================
1462// Selectors — editor-container swap (TS showSelector pattern)
1463// ===========================================================================
1464
1465/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
1466/// hiding the editor while the selector is open. Records the selector in
1467/// `state.active_selector` so the key loop routes to it.
1468fn open_selector(
1469    state: &Arc<TuiState>,
1470    editor_container: &Arc<Container>,
1471    editor: &Arc<Editor>,
1472    tui: &Arc<TuiAltScreen>,
1473    list: Arc<SelectList>,
1474    kind: SelectorKind,
1475) {
1476    // Unfocus the editor so its cursor marker doesn't render behind the list.
1477    editor.set_focused(false);
1478    // Swap: clear the container and add just the list.
1479    editor_container.clear();
1480    editor_container.add_child(list.clone());
1481    *state.active_selector.lock().unwrap() = Some((list, kind));
1482    tui.request_render(false);
1483}
1484
1485/// Restore the editor into the `editor_container` and clear the active
1486/// selector. Called by selector `on_cancel` and the Esc handler.
1487fn close_selector(state: &Arc<TuiState>, editor_container: &Arc<Container>, editor: &Arc<Editor>, tui: &Arc<TuiAltScreen>) {
1488    editor_container.clear();
1489    editor_container.add_child(editor.clone());
1490    editor.set_focused(true);
1491    *state.active_selector.lock().unwrap() = None;
1492    tui.request_render(false);
1493}
1494
1495/// Build + open the `/model` selector. Items are the resolved catalog (display
1496/// label = model name; description = id), with the current model marked.
1497/// Selecting applies the model **live** via `lane.set_model` (takes effect on
1498/// the next user message — the in-flight run's config is already snapshotted),
1499/// updates the footer, and notes the next-prompt effect.
1500fn open_model_selector(
1501    state: &Arc<TuiState>,
1502    editor_container: &Arc<Container>,
1503    editor: &Arc<Editor>,
1504    tui: &Arc<TuiAltScreen>,
1505    catalog: &[rpi_ai::Model],
1506    lane: &Arc<dyn AgentLane>,
1507    lane_model_id: &str,
1508    chat: &Arc<Container>,
1509) {
1510    let mut items: Vec<SelectItem> = Vec::new();
1511    for m in catalog {
1512        let label = if m.name.is_empty() { short_model_name(&m.id) } else { m.name.clone() };
1513        let marker = if m.id.eq_ignore_ascii_case(lane_model_id) { " (current)" } else { "" };
1514        items.push(
1515            SelectItem::new(&m.id, &label)
1516                .with_description(&format!("{id}{marker}", id = m.id)),
1517        );
1518    }
1519    if items.is_empty() {
1520        add_note_message(
1521            chat,
1522            "No models in the catalog. Use --model at startup to select one.",
1523        );
1524        tui.request_render(false);
1525        return;
1526    }
1527    let list = Arc::new(SelectList::new(items, 10));
1528
1529    // Capture the catalog + lane so the on_select closure can resolve the
1530    // chosen Model and apply it. `on_select` fires on the blocking key thread,
1531    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
1532    let catalog_arc = catalog.to_vec();
1533    let state_sel = state.clone();
1534    let ec_sel = editor_container.clone();
1535    let editor_sel = editor.clone();
1536    let tui_sel = tui.clone();
1537    let chat_sel = chat.clone();
1538    let lane_sel = lane.clone();
1539    list.on_select(Arc::new(move |item| {
1540        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1541            add_note_message(&chat_sel, &format!("Model {} not found in catalog.", item.label));
1542            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1543            return;
1544        };
1545        state_sel.set_current_model(&model);
1546        let lane = lane_sel.clone();
1547        tokio::spawn(async move {
1548            let _ = lane.set_model(model).await;
1549        });
1550        add_note_message(
1551            &chat_sel,
1552            &format!(
1553                "Model set to {} — applies to the next message.",
1554                short_model_name(&item.value)
1555            ),
1556        );
1557        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1558    }));
1559    let state_cancel = state.clone();
1560    let ec_cancel = editor_container.clone();
1561    let editor_cancel = editor.clone();
1562    let tui_cancel = tui.clone();
1563    list.on_cancel(Arc::new(move || {
1564        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1565    }));
1566
1567    open_selector(state, editor_container, editor, tui, list, SelectorKind::Model);
1568}
1569
1570/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
1571/// Returns `None` only when the catalog is empty or the current id isn't
1572/// found (in which case the first entry is returned — a no-op if it IS the
1573/// current). Used by the Ctrl+M model-cycle hotkey.
1574fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
1575    if catalog.is_empty() {
1576        return None;
1577    }
1578    let idx = catalog
1579        .iter()
1580        .position(|m| m.id.eq_ignore_ascii_case(current_id));
1581    match idx {
1582        Some(i) => {
1583            let next = (i + 1) % catalog.len();
1584            Some(catalog[next].clone())
1585        }
1586        None => Some(catalog[0].clone()),
1587    }
1588}
1589
1590/// Build + open the `/session` selector. Lists JSONL session files under the
1591/// default session dir (`<cwd>/.pi/sessions`). Selecting reports "restore not
1592/// implemented in v1" (existing constraint) but shows the list for
1593/// discoverability.
1594fn open_session_selector(
1595    state: &Arc<TuiState>,
1596    editor_container: &Arc<Container>,
1597    editor: &Arc<Editor>,
1598    tui: &Arc<TuiAltScreen>,
1599    cwd: &std::path::Path,
1600) {
1601    let dir = crate::session::default_session_dir(cwd);
1602    let mut items: Vec<SelectItem> = Vec::new();
1603    if let Ok(entries) = std::fs::read_dir(&dir) {
1604        for entry in entries.flatten() {
1605            let path = entry.path();
1606            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
1607                continue;
1608            }
1609            let stem = path
1610                .file_stem()
1611                .and_then(|s| s.to_str())
1612                .unwrap_or("(unnamed)")
1613                .to_string();
1614            let display = path
1615                .file_name()
1616                .and_then(|s| s.to_str())
1617                .unwrap_or(&stem)
1618                .to_string();
1619            items.push(SelectItem::new(&stem, &display));
1620        }
1621    }
1622    if items.is_empty() {
1623        add_note_message(
1624            &state.chat_container,
1625            "No saved sessions found. Sessions are created automatically in interactive mode.",
1626        );
1627        tui.request_render(false);
1628        return;
1629    }
1630    let list = Arc::new(SelectList::new(items, 10));
1631
1632    let state_sel = state.clone();
1633    let ec_sel = editor_container.clone();
1634    let editor_sel = editor.clone();
1635    let tui_sel = tui.clone();
1636    let chat_sel = state.chat_container.clone();
1637    list.on_select(Arc::new(move |item| {
1638        add_note_message(
1639            &chat_sel,
1640            &format!("Session {} — restore is not implemented in v1.", item.label),
1641        );
1642        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1643    }));
1644    let state_cancel = state.clone();
1645    let ec_cancel = editor_container.clone();
1646    let editor_cancel = editor.clone();
1647    let tui_cancel = tui.clone();
1648    list.on_cancel(Arc::new(move || {
1649        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1650    }));
1651
1652    open_selector(state, editor_container, editor, tui, list, SelectorKind::Session);
1653}
1654
1655/// Build + open the `/theme` selector. Presets [dark, light, monochrome];
1656/// selecting applies it live via the owned `ThemeManager` + re-renders.
1657fn open_theme_selector(
1658    state: &Arc<TuiState>,
1659    editor_container: &Arc<Container>,
1660    editor: &Arc<Editor>,
1661    tui: &Arc<TuiAltScreen>,
1662) {
1663    let items = vec![
1664        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1665        SelectItem::new("light", "Light").with_description("Light background"),
1666        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1667    ];
1668    let list = Arc::new(SelectList::new(items, 10));
1669
1670    let state_sel = state.clone();
1671    let ec_sel = editor_container.clone();
1672    let editor_sel = editor.clone();
1673    let tui_sel = tui.clone();
1674    let chat_sel = state.chat_container.clone();
1675    list.on_select(Arc::new(move |item| {
1676        let preset = match item.value.as_str() {
1677            "light" => ThemePreset::Light,
1678            "monochrome" => ThemePreset::Monochrome,
1679            _ => ThemePreset::Dark,
1680        };
1681        state_sel.theme_manager.apply_preset(preset);
1682        // A quick accent note so the user sees the change registered even if
1683        // the terminal's own colors mask the preset difference.
1684        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
1685        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1686        tui_sel.render_now(true);
1687    }));
1688    let state_cancel = state.clone();
1689    let ec_cancel = editor_container.clone();
1690    let editor_cancel = editor.clone();
1691    let tui_cancel = tui.clone();
1692    list.on_cancel(Arc::new(move || {
1693        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1694    }));
1695
1696    open_selector(state, editor_container, editor, tui, list, SelectorKind::Theme);
1697}
1698
1699// ===========================================================================
1700// Feasible selectors — /thinking, /tools, /images
1701// ===========================================================================
1702
1703/// One-line descriptions for each thinking level, ported from
1704/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
1705fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
1706    use rpi_ai::types::ThinkingLevel::*;
1707    match level {
1708        Off => "Off — No reasoning",
1709        Minimal => "Minimal — Brief reasoning (~1k tokens)",
1710        Low => "Low — Light reasoning (~1k tokens)",
1711        Medium => "Medium — Moderate reasoning (~80% of max)",
1712        High => "High — Extensive reasoning (~95% of max)",
1713        Xhigh => "Xhigh — Near-maximal reasoning",
1714        Max => "Max — Maximum reasoning",
1715    }
1716}
1717
1718/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
1719/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
1720fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
1721    use rpi_ai::types::ThinkingLevel::*;
1722    match level {
1723        Off => "off",
1724        Minimal => "minimal",
1725        Low => "low",
1726        Medium => "medium",
1727        High => "high",
1728        Xhigh => "xhigh",
1729        Max => "max",
1730    }
1731}
1732
1733/// Parse a thinking-level name back to the enum (case-insensitive). Returns
1734/// `None` for an unknown name; used by the `/thinking` selector callback.
1735fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
1736    use rpi_ai::types::ThinkingLevel::*;
1737    match name.to_ascii_lowercase().as_str() {
1738        "off" => Some(Off),
1739        "minimal" => Some(Minimal),
1740        "low" => Some(Low),
1741        "medium" => Some(Medium),
1742        "high" => Some(High),
1743        "xhigh" => Some(Xhigh),
1744        "max" => Some(Max),
1745        _ => None,
1746    }
1747}
1748
1749/// Build + open the `/thinking` selector. Items are the levels the current
1750/// model supports (`Model::supported_thinking_levels`), each with a
1751/// description; the current level (read beforehand via `lane.get_thinking_level`)
1752/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
1753///
1754/// `on_select` fires on the blocking key thread, so it can't await
1755/// `lane.get_thinking_level()` to know the current level — the opener resolves
1756/// it first (best-effort) and preselects; the toggle on_select just applies
1757/// whatever was picked.
1758fn open_thinking_selector(
1759    state: &Arc<TuiState>,
1760    editor_container: &Arc<Container>,
1761    editor: &Arc<Editor>,
1762    tui: &Arc<TuiAltScreen>,
1763    lane: &Arc<dyn AgentLane>,
1764    catalog: &[rpi_ai::Model],
1765    lane_model_id: &str,
1766    chat: &Arc<Container>,
1767) {
1768    // Find the current model in the catalog to read its supported levels. If
1769    // absent, fall back to all levels so the selector still opens.
1770    let model = catalog
1771        .iter()
1772        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1773    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1774        .map(|m| m.supported_thinking_levels())
1775        .unwrap_or_else(|| {
1776            use rpi_ai::types::ThinkingLevel::*;
1777            vec![Off, Minimal, Low, Medium, High]
1778        });
1779    let mut items: Vec<SelectItem> = Vec::new();
1780    for lvl in &levels {
1781        let name = thinking_level_name(*lvl);
1782        items.push(
1783            SelectItem::new(name, name)
1784                .with_description(thinking_level_description(*lvl)),
1785        );
1786    }
1787    if items.is_empty() {
1788        add_note_message(chat, "This model has no supported thinking levels.");
1789        tui.request_render(false);
1790        return;
1791    }
1792    let list = Arc::new(SelectList::new(items, 10));
1793
1794    let state_sel = state.clone();
1795    let ec_sel = editor_container.clone();
1796    let editor_sel = editor.clone();
1797    let tui_sel = tui.clone();
1798    let chat_sel = chat.clone();
1799    let lane_sel = lane.clone();
1800    list.on_select(Arc::new(move |item| {
1801        let Some(level) = thinking_level_from_name(&item.value) else {
1802            add_note_message(&chat_sel, &format!("Unknown thinking level: {}.", item.label));
1803            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1804            return;
1805        };
1806        let lane = lane_sel.clone();
1807        let footer_sel = state_sel.footer.clone();
1808        tokio::spawn(async move {
1809            let _ = lane.set_thinking_level(level).await;
1810        });
1811        // Reflect the chosen level in the footer's model suffix (pi parity:
1812        // `model • thinking off` / `model • medium`). The shown text for the
1813        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
1814        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1815        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
1816        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1817    }));
1818    let state_cancel = state.clone();
1819    let ec_cancel = editor_container.clone();
1820    let editor_cancel = editor.clone();
1821    let tui_cancel = tui.clone();
1822    list.on_cancel(Arc::new(move || {
1823        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1824    }));
1825
1826    open_selector(state, editor_container, editor, tui, list, SelectorKind::Thinking);
1827}
1828
1829/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
1830/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
1831/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
1832/// — the blocking key thread can't await) and selecting a tool **toggles** it
1833/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
1834fn open_tools_selector(
1835    state: &Arc<TuiState>,
1836    editor_container: &Arc<Container>,
1837    editor: &Arc<Editor>,
1838    tui: &Arc<TuiAltScreen>,
1839    lane: &Arc<dyn AgentLane>,
1840    chat: &Arc<Container>,
1841) {
1842    // Best-effort read of the current active set. The opener runs on the async
1843    // runtime (it's called from the main loop's channel dispatch or the submit
1844    // closure that lives on the blocking thread — but `handle.block_on` is safe
1845    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
1846    let active = match tokio::runtime::Handle::try_current() {
1847        Ok(h) => h.block_on(async { lane.get_active_tools().await }).unwrap_or_default(),
1848        Err(_) => Vec::new(),
1849    };
1850    let mut items: Vec<SelectItem> = Vec::new();
1851    for name in crate::session::BUILTIN_TOOL_NAMES {
1852        let on = active.iter().any(|a| a == name);
1853        let label = if on { format!("{name} (on)") } else { (*name).to_string() };
1854        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
1855    }
1856    let list = Arc::new(SelectList::new(items, 10));
1857
1858    // Capture the active set so on_select can toggle without re-reading.
1859    let active_captured = active.clone();
1860    let state_sel = state.clone();
1861    let ec_sel = editor_container.clone();
1862    let editor_sel = editor.clone();
1863    let tui_sel = tui.clone();
1864    let chat_sel = chat.clone();
1865    let lane_sel = lane.clone();
1866    list.on_select(Arc::new(move |item| {
1867        let mut next = active_captured.clone();
1868        if let Some(pos) = next.iter().position(|a| a == &item.value) {
1869            next.remove(pos);
1870        } else {
1871            next.push(item.value.clone());
1872        }
1873        let on = next.iter().any(|a| a == &item.value);
1874        let lane = lane_sel.clone();
1875        let next_clone = next.clone();
1876        tokio::spawn(async move {
1877            let _ = lane.set_active_tools(next_clone).await;
1878        });
1879        let list_str = if next.is_empty() {
1880            "(none)".to_string()
1881        } else {
1882            next.join(", ")
1883        };
1884        add_note_message(
1885            &chat_sel,
1886            &format!(
1887                "{} {} — active tools: {}",
1888                item.value,
1889                if on { "enabled" } else { "disabled" },
1890                list_str
1891            ),
1892        );
1893        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1894    }));
1895    let state_cancel = state.clone();
1896    let ec_cancel = editor_container.clone();
1897    let editor_cancel = editor.clone();
1898    let tui_cancel = tui.clone();
1899    list.on_cancel(Arc::new(move || {
1900        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1901    }));
1902
1903    open_selector(state, editor_container, editor, tui, list, SelectorKind::Tools);
1904}
1905
1906/// Build + open the `/images` selector (Yes/No). Stores the choice in
1907/// `state.show_images` and notes it. Image wiring is minimal this pass — the
1908/// flag is consulted where images would be shown and echoed back here.
1909fn open_images_selector(
1910    state: &Arc<TuiState>,
1911    editor_container: &Arc<Container>,
1912    editor: &Arc<Editor>,
1913    tui: &Arc<TuiAltScreen>,
1914    chat: &Arc<Container>,
1915) {
1916    let current = *state.show_images.lock().unwrap();
1917    let items = vec![
1918        SelectItem::new("yes", "Yes")
1919            .with_description(if current { "Inline images (current)" } else { "Inline images" }),
1920        SelectItem::new("no", "No")
1921            .with_description(if current { "Placeholder only" } else { "Placeholder only (current)" }),
1922    ];
1923    let list = Arc::new(SelectList::new(items, 5));
1924
1925    let state_sel = state.clone();
1926    let ec_sel = editor_container.clone();
1927    let editor_sel = editor.clone();
1928    let tui_sel = tui.clone();
1929    let chat_sel = chat.clone();
1930    list.on_select(Arc::new(move |item| {
1931        let on = item.value == "yes";
1932        *state_sel.show_images.lock().unwrap() = on;
1933        add_note_message(
1934            &chat_sel,
1935            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
1936        );
1937        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1938    }));
1939    let state_cancel = state.clone();
1940    let ec_cancel = editor_container.clone();
1941    let editor_cancel = editor.clone();
1942    let tui_cancel = tui.clone();
1943    list.on_cancel(Arc::new(move || {
1944        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1945    }));
1946
1947    open_selector(state, editor_container, editor, tui, list, SelectorKind::Images);
1948}
1949
1950// ===========================================================================
1951// Autocomplete
1952// ===========================================================================
1953
1954/// Refresh the autocomplete suggestion list from the current editor text +
1955/// cursor. Renders the suggestions into `autocomplete_container` (above the
1956/// editor) or clears it when there are none.
1957fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
1958    let text = editor.get_text();
1959    let (_row, col) = editor.cursor_position();
1960    // The editor's `cursor_col` is a byte offset into the current line; for
1961    // single-line input (the common case) that equals the byte offset into
1962    // `get_text()`, which is exactly what the autocomplete providers expect to
1963    // slice on. Clamp to the text length so a stale/multi-line col can't
1964    // overshoot. Providers snap to a char boundary internally as a safety net
1965    // (`autocomplete::snap_cursor`), so a byte col landing mid-character never
1966    // panics.
1967    let cursor = col.min(text.len());
1968    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
1969    render_autocomplete(state, suggestions);
1970}
1971
1972/// Render (or clear) the autocomplete suggestion list into the container.
1973fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
1974    state.autocomplete_container.clear();
1975    let Some(sugg) = suggestions else {
1976        return;
1977    };
1978    if sugg.items.is_empty() {
1979        return;
1980    }
1981    // Build a compact list: top item marked with `→`, rest with `  `.
1982    // Cap at 5 lines so the dock doesn't swallow the transcript.
1983    let accent = state.theme_manager.get().colors.accent;
1984    let muted = state.theme_manager.get().colors.muted;
1985    for (i, item) in sugg.items.iter().take(5).enumerate() {
1986        let prefix = if i == 0 { "→ " } else { "  " };
1987        let label = item.display_text();
1988        let line = if i == 0 {
1989            format!("{prefix}{} {}", accent.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
1990        } else {
1991            format!("{prefix}{} {}", muted.fg(label), muted.fg(item.description.as_deref().unwrap_or("")))
1992        };
1993        state
1994            .autocomplete_container
1995            .add_child(Arc::new(Text::new(line, 1, 0)));
1996    }
1997}
1998
1999/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
2000/// suggestion text, reposition the caret, and clear the suggestion list.
2001/// Returns `true` if a suggestion was accepted.
2002fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
2003    let text = editor.get_text();
2004    let (_row, col) = editor.cursor_position();
2005    let cursor = col.min(text.len());
2006    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
2007        return false;
2008    };
2009    let Some(top) = sugg.items.first() else {
2010        return false;
2011    };
2012    // Replace the [start, end) span with the suggestion text. `start`/`end`
2013    // are byte offsets emitted by the providers on char boundaries, so the
2014    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
2015    let start = sugg.start.min(text.len());
2016    let end = sugg.end.min(text.len());
2017    let mut replaced = String::with_capacity(text.len() + top.text.len());
2018    replaced.push_str(&text[..start]);
2019    replaced.push_str(&top.text);
2020    if top.insert_space && !replaced.ends_with('/') {
2021        replaced.push(' ');
2022    }
2023    // New caret position: after the inserted text (byte offset; the editor
2024    // snaps `set_cursor` to a char boundary as a safety net).
2025    let new_cursor = replaced.len().min(
2026        start + top.text.len()
2027            + if top.insert_space && !top.text.ends_with('/') {
2028                1
2029            } else {
2030                0
2031            },
2032    );
2033    let _ = end;
2034    editor.set_text(&replaced);
2035    editor.set_cursor(0, new_cursor);
2036    state.autocomplete_container.clear();
2037    true
2038}
2039
2040// ===========================================================================
2041// Transcript message helpers
2042// ===========================================================================
2043
2044/// Add the welcome header to the chat container.
2045fn add_welcome_message(container: &Arc<Container>) {
2046    container.add_child(Arc::new(Text::new("rpi interactive TUI", 1, 0)));
2047    container.add_child(Arc::new(Spacer::new(1)));
2048    container.add_child(Arc::new(Text::new(
2049        "Type your message and press Enter to send.",
2050        1, 0,
2051    )));
2052    container.add_child(Arc::new(Text::new(
2053        "Ctrl+C: Abort/Exit | Esc: Abort | Enter: Send | Shift+Enter: New line | Tab: Complete | Ctrl+L: Model | Ctrl+M: Cycle | Ctrl+T: Expand tool | /help",
2054        1, 0,
2055    )));
2056    container.add_child(Arc::new(Spacer::new(1)));
2057}
2058
2059/// Add the `/help` command listing to the chat container.
2060fn add_help_message(container: &Arc<Container>) {
2061    container.add_child(Arc::new(Text::new("📚 Available Commands:", 1, 0)));
2062    container.add_child(Arc::new(Spacer::new(1)));
2063    container.add_child(Arc::new(Text::new("  /help, /?       — Show this help message", 1, 0)));
2064    container.add_child(Arc::new(Text::new("  /clear, /new    — Clear the conversation", 1, 0)));
2065    container.add_child(Arc::new(Text::new("  /exit, /quit, /q — Exit the application", 1, 0)));
2066    container.add_child(Arc::new(Text::new("  /version, /v    — Show version information", 1, 0)));
2067    container.add_child(Arc::new(Text::new("  /model, /m      — Choose a model (live switch)", 1, 0)));
2068    container.add_child(Arc::new(Text::new("  /thinking, /think — Set reasoning depth (selector)", 1, 0)));
2069    container.add_child(Arc::new(Text::new("  /tools          — Toggle built-in tools on/off", 1, 0)));
2070    container.add_child(Arc::new(Text::new("  /images         — Toggle inline image rendering", 1, 0)));
2071    container.add_child(Arc::new(Text::new("  /session        — List saved sessions", 1, 0)));
2072    container.add_child(Arc::new(Text::new("  /theme          — Choose a theme (selector)", 1, 0)));
2073    container.add_child(Arc::new(Text::new("  /compact        — Compact the conversation", 1, 0)));
2074    container.add_child(Arc::new(Text::new("  /copy           — Copy last reply to clipboard", 1, 0)));
2075    container.add_child(Arc::new(Text::new("  /hotkeys        — Show keyboard shortcuts", 1, 0)));
2076    container.add_child(Arc::new(Text::new("  /armin          — 🐾 Easter egg", 1, 0)));
2077    container.add_child(Arc::new(Text::new("  /earendil       — Earendil announcement", 1, 0)));
2078    container.add_child(Arc::new(Spacer::new(1)));
2079}
2080
2081/// Add the `/version` block to the chat container.
2082fn add_version_message(container: &Arc<Container>) {
2083    container.add_child(Arc::new(Text::new("📦 Version Information:", 1, 0)));
2084    container.add_child(Arc::new(Spacer::new(1)));
2085    container.add_child(Arc::new(Text::new("  rpi-cli v0.1.2", 1, 0)));
2086    container.add_child(Arc::new(Text::new(
2087        "  Rust implementation of pi coding agent TUI",
2088        1, 0,
2089    )));
2090    container.add_child(Arc::new(Spacer::new(1)));
2091}
2092
2093/// Add the `/hotkeys` block to the chat container.
2094fn add_hotkeys_message(container: &Arc<Container>) {
2095    container.add_child(Arc::new(Text::new("⌨️  Keyboard Shortcuts:", 1, 0)));
2096    container.add_child(Arc::new(Spacer::new(1)));
2097    container.add_child(Arc::new(Text::new("  Enter         — Send message", 1, 0)));
2098    container.add_child(Arc::new(Text::new("  Shift+Enter   — New line", 1, 0)));
2099    container.add_child(Arc::new(Text::new("  Tab           — Accept autocomplete suggestion", 1, 0)));
2100    container.add_child(Arc::new(Text::new("  Ctrl+A / Ctrl+E — Line start / end", 1, 0)));
2101    container.add_child(Arc::new(Text::new("  Ctrl+K / Ctrl+U — Delete to end / start of line", 1, 0)));
2102    container.add_child(Arc::new(Text::new("  Ctrl+C        — Abort a run, or exit when idle", 1, 0)));
2103    container.add_child(Arc::new(Text::new("  Esc           — Abort a running prompt", 1, 0)));
2104    container.add_child(Arc::new(Text::new("  Ctrl+L        — Open model selector", 1, 0)));
2105    container.add_child(Arc::new(Text::new("  Ctrl+M        — Cycle to the next model (live)", 1, 0)));
2106    container.add_child(Arc::new(Text::new("  Ctrl+T        — Expand/collapse last tool result", 1, 0)));
2107    container.add_child(Arc::new(Text::new("  PageUp/Down   — Scroll transcript", 1, 0)));
2108    container.add_child(Arc::new(Spacer::new(1)));
2109}
2110
2111/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
2112/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
2113/// plain `> text` echo.
2114fn add_user_message(container: &Arc<Container>, text: &str) {
2115    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
2116    container.add_child(Arc::new(Spacer::new(0)));
2117}
2118
2119/// Add an error message to the chat container.
2120fn add_error_message(container: &Arc<Container>, text: &str) {
2121    container.add_child(Arc::new(Text::new(format!("❌ {text}"), 1, 0)));
2122    container.add_child(Arc::new(Spacer::new(1)));
2123}
2124
2125/// Add a neutral note (e.g. unsupported-command message) to the chat container.
2126fn add_note_message(container: &Arc<Container>, text: &str) {
2127    container.add_child(Arc::new(Text::new(format!("ℹ️  {text}"), 1, 0)));
2128    container.add_child(Arc::new(Spacer::new(1)));
2129}
2130
2131// ===========================================================================
2132// TUI support + entry detection
2133// ===========================================================================
2134
2135/// Check if the terminal supports TUI mode.
2136pub fn is_tui_supported() -> bool {
2137    std::io::stdout().is_terminal()
2138}
2139
2140// Keep the `Color` import used (theme accent rendering in autocomplete).
2141#[allow(unused_imports)]
2142use rpi_tui::Color as _Color;
2143
2144#[cfg(test)]
2145mod tests {
2146    use super::*;
2147    use rpi_tui::Component;
2148
2149    #[test]
2150    fn test_layout_renders_welcome_message() {
2151        let chat = Arc::new(Container::new());
2152        add_welcome_message(&chat);
2153
2154        let scroll = Arc::new(ScrollView::new(
2155            chat.clone(),
2156            ScrollViewOptions {
2157                follow: FollowMode::End,
2158                primary: true,
2159                ..Default::default()
2160            },
2161        ));
2162
2163        let editor = Arc::new(Editor::new(
2164            EditorOptions {
2165                padding_x: 1,
2166                ..Default::default()
2167            },
2168            EditorStyle::default(),
2169            Arc::new(rpi_tui::Keybindings::new()),
2170        ));
2171        let dock = Arc::new(Container::new());
2172        dock.add_child(editor);
2173
2174        let footer = Arc::new(FooterComponent::new());
2175
2176        let root = VStack::from_children(vec![
2177            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
2178            StackChild::Entry(StackEntry::new(dock)),
2179            StackChild::Entry(StackEntry::new(footer)),
2180        ]);
2181
2182        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
2183
2184        let all: String = frame.lines.join("\n");
2185        assert!(all.contains("rpi interactive"), "Welcome message not found. Rendered: {}", all);
2186        assert!(all.contains("Type your message"), "Help text not found. Rendered: {}", all);
2187    }
2188
2189    #[test]
2190    fn test_chat_container_has_welcome_content() {
2191        let chat = Arc::new(Container::new());
2192        add_welcome_message(&chat);
2193
2194        let lines = chat.render(80);
2195        let all: String = lines.join("\n");
2196        assert!(all.contains("rpi interactive"), "Welcome message not in chat container: {:?}", lines);
2197    }
2198
2199    #[test]
2200    fn test_slash_command_dispatch() {
2201        assert!(matches!(handle_slash_command("/help"), SlashCommandResult::Help));
2202        assert!(matches!(handle_slash_command("/clear"), SlashCommandResult::ClearChat));
2203        assert!(matches!(handle_slash_command("/q"), SlashCommandResult::Exit));
2204        assert!(matches!(handle_slash_command("/hotkeys"), SlashCommandResult::Hotkeys));
2205        assert!(matches!(
2206            handle_slash_command("/model"),
2207            SlashCommandResult::SelectModel
2208        ));
2209        assert!(matches!(
2210            handle_slash_command("/theme"),
2211            SlashCommandResult::SelectTheme
2212        ));
2213        assert!(matches!(handle_slash_command("/session"), SlashCommandResult::SelectSession));
2214        assert!(matches!(handle_slash_command("/compact"), SlashCommandResult::Compact));
2215        assert!(matches!(handle_slash_command("/copy"), SlashCommandResult::Copy));
2216        assert!(matches!(
2217            handle_slash_command("/thinking"),
2218            SlashCommandResult::SelectThinking
2219        ));
2220        assert!(matches!(
2221            handle_slash_command("/think"),
2222            SlashCommandResult::SelectThinking
2223        ));
2224        assert!(matches!(
2225            handle_slash_command("/tools"),
2226            SlashCommandResult::SelectTools
2227        ));
2228        assert!(matches!(
2229            handle_slash_command("/images"),
2230            SlashCommandResult::SelectImages
2231        ));
2232        assert!(matches!(handle_slash_command("/armin"), SlashCommandResult::Armin));
2233        assert!(matches!(handle_slash_command("/earendil"), SlashCommandResult::Earendil));
2234        assert!(matches!(
2235            handle_slash_command("/settings"),
2236            SlashCommandResult::Unsupported(_)
2237        ));
2238        assert!(matches!(handle_slash_command("/nope"), SlashCommandResult::Unknown));
2239        // Empty input resolves to SendMessage (defensive; the submit handler
2240        // guards on `starts_with('/')` so this path is only hit for blanks).
2241        assert!(matches!(
2242            handle_slash_command(""),
2243            SlashCommandResult::SendMessage(_)
2244        ));
2245    }
2246
2247    #[test]
2248    fn test_v1_slash_commands_cover_dispatcher() {
2249        // Every command the dispatcher recognizes as non-Unsupported/Unknown
2250        // should appear in the autocomplete list (so `/`-autocomplete stays in
2251        // sync with the actual command surface).
2252        let cmds = v1_slash_commands();
2253        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
2254        for recognized in ["/help", "/clear", "/new", "/exit", "/quit", "/version",
2255            "/model", "/session", "/theme", "/compact", "/copy", "/hotkeys"]
2256        {
2257            assert!(names.contains(&recognized), "{recognized} missing from autocomplete list");
2258        }
2259    }
2260
2261    #[test]
2262    fn test_agent_event_mapping_creates_assistant_and_tool() {
2263        // Synthetic AgentEvent sequence → UI mutations, exercised against the
2264        // real drain handler with a no-op TUI stand-in.
2265        use rpi_ai::types::{StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType, ToolCall, ToolCallType, Usage};
2266
2267        let state = Arc::new(TuiState {
2268            current_assistant: std::sync::Mutex::new(None),
2269            tool_components: std::sync::Mutex::new(HashMap::new()),
2270            bash_components: std::sync::Mutex::new(HashMap::new()),
2271            last_tool_comp: std::sync::Mutex::new(None),
2272            status: std::sync::Mutex::new(RunStatus::Idle),
2273            footer: Arc::new(FooterComponent::new()),
2274            status_container: Arc::new(Container::new()),
2275            chat_container: Arc::new(Container::new()),
2276            loader: Arc::new(Loader::new()),
2277            last_assistant_text: std::sync::Mutex::new(String::new()),
2278            active_selector: std::sync::Mutex::new(None),
2279            autocomplete: AutocompleteManager::new(),
2280            autocomplete_container: Arc::new(Container::new()),
2281            theme_manager: Arc::new(ThemeManager::new()),
2282            tui: None,
2283            current_model_id: std::sync::Mutex::new(String::new()),
2284            show_images: std::sync::Mutex::new(true),
2285        });
2286
2287        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
2288        // terminal; instead, exercise the *mutation* half directly against a
2289        // captured chat container via a synthetic message-start event's data.
2290        let assistant = AssistantMessage {
2291            role: rpi_ai::types::AssistantRole,
2292            content: vec![
2293                Content::Thinking(ThinkingContent {
2294                    kind: ThinkingContentType,
2295                    thinking: "Reasoning about the reply.".into(),
2296                    thinking_signature: None,
2297                    redacted: false,
2298                }),
2299                Content::Text(TextContent {
2300                    kind: TextContentType,
2301                    text: "Hello.".into(),
2302                    text_signature: None,
2303                }),
2304                Content::ToolCall(ToolCall {
2305                    kind: ToolCallType,
2306                    id: "tc1".into(),
2307                    name: "bash".into(),
2308                    arguments: serde_json::json!({"command": "echo hi"}),
2309                    thought_signature: None,
2310                    namespace: None,
2311                }),
2312            ],
2313            api: rpi_ai::Api::AnthropicMessages,
2314            provider: "anthropic".into(),
2315            model: "claude-sonnet-5".into(),
2316            response_model: None,
2317            response_id: None,
2318            usage: Usage::zero(),
2319            stop_reason: StopReason::Stop,
2320            deferred: None,
2321            error_message: None,
2322            raw_stop_reason: None,
2323            end_turn: None,
2324            timestamp: 0,
2325        };
2326
2327        // Manually apply the MessageStart assistant branch logic (mirrors the
2328        // drain handler, without needing a TuiAltScreen).
2329        let comp = Arc::new(AssistantMessageComponent::new(AssistantMessageOptions::default()));
2330        comp.set_streaming(true);
2331        comp.update_blocks(&assistant_blocks(&assistant));
2332        let chat = Arc::new(Container::new());
2333        chat.add_child(comp.clone());
2334        *state.current_assistant.lock().unwrap() = Some(comp);
2335
2336        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
2337        for c in &assistant.content {
2338            if let Content::ToolCall(tc) = c {
2339                let mut tools = state.tool_components.lock().unwrap();
2340                if !tools.contains_key(&tc.id) {
2341                    let tc_comp = Arc::new(ToolExecutionComponent::new(
2342                        &tc.name,
2343                        &tc.arguments.to_string(),
2344                    ));
2345                    tc_comp.set_running();
2346                    chat.add_child(tc_comp.clone());
2347                    tools.insert(tc.id.clone(), tc_comp);
2348                }
2349            }
2350        }
2351
2352        // Assert: the assistant component rendered the text + the thinking
2353        // block (the update_blocks path keeps thinking visible), and a tool
2354        // component was registered.
2355        let rendered = chat.render(80);
2356        let joined: String = rendered.join("\n");
2357        assert!(joined.contains("Hello."), "assistant text not rendered: {joined}");
2358        assert!(
2359            joined.contains("Reasoning about the reply."),
2360            "thinking block not rendered: {joined}"
2361        );
2362        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
2363        assert!(state.current_assistant.lock().unwrap().is_some());
2364
2365        // Manually apply ToolExecutionEnd (mirrors drain).
2366        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
2367        ended.set_result("hi", false);
2368        assert!(state.tool_components.lock().unwrap().is_empty());
2369    }
2370
2371    #[test]
2372    fn test_short_model_name() {
2373        assert_eq!(short_model_name("anthropic:claude-sonnet-5"), "claude-sonnet-5");
2374        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
2375    }
2376
2377    #[test]
2378    fn test_cycle_next_model_wraps_around() {
2379        use rpi_ai::{Api, Model};
2380        let mk = |id: &str| {
2381            Model::new(id, id, Api::AnthropicMessages, "anthropic", "https://api.anthropic.com")
2382        };
2383        let catalog = [mk("a"), mk("b"), mk("c")];
2384        // Next after "a" is "b"; after "c" wraps to "a".
2385        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
2386        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
2387        // An unknown current id falls back to the first model.
2388        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
2389        // Empty catalog yields None.
2390        let empty: Vec<Model> = vec![];
2391        assert!(cycle_next_model(&empty, "a").is_none());
2392    }
2393
2394    #[test]
2395    fn test_autocomplete_slash_suggestions_render() {
2396        // The autocomplete container should render at least one suggestion
2397        // line when the editor holds a `/` prefix, and clear when it doesn't.
2398        let state = Arc::new(TuiState {
2399            current_assistant: std::sync::Mutex::new(None),
2400            tool_components: std::sync::Mutex::new(HashMap::new()),
2401            bash_components: std::sync::Mutex::new(HashMap::new()),
2402            last_tool_comp: std::sync::Mutex::new(None),
2403            status: std::sync::Mutex::new(RunStatus::Idle),
2404            footer: Arc::new(FooterComponent::new()),
2405            status_container: Arc::new(Container::new()),
2406            chat_container: Arc::new(Container::new()),
2407            loader: Arc::new(Loader::new()),
2408            last_assistant_text: std::sync::Mutex::new(String::new()),
2409            active_selector: std::sync::Mutex::new(None),
2410            autocomplete: AutocompleteManager::new(),
2411            autocomplete_container: Arc::new(Container::new()),
2412            theme_manager: Arc::new(ThemeManager::new()),
2413            tui: None,
2414            current_model_id: std::sync::Mutex::new(String::new()),
2415            show_images: std::sync::Mutex::new(true),
2416        });
2417        {
2418            let mut combined = CombinedAutocompleteProvider::new();
2419            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2420                v1_slash_commands(),
2421            )));
2422            state.autocomplete.set_provider(Arc::new(combined));
2423        }
2424
2425        let editor = Arc::new(Editor::simple());
2426        editor.set_text("/he");
2427        editor.set_cursor(0, 3);
2428        refresh_autocomplete(&state, &editor);
2429        let lines = state.autocomplete_container.render(80);
2430        let joined: String = lines.join("\n");
2431        assert!(joined.contains("/help"), "slash suggestions not rendered: {joined}");
2432
2433        // Clear: no suggestions for plain text.
2434        editor.set_text("hello");
2435        editor.set_cursor(0, 5);
2436        refresh_autocomplete(&state, &editor);
2437        assert!(state.autocomplete_container.render(80).is_empty());
2438    }
2439
2440    #[test]
2441    fn test_select_list_swap_restores_editor() {
2442        // The editor-container swap: opening a selector replaces the editor
2443        // child; closing restores it. Verify the container child count + the
2444        // active_selector flag round-trip.
2445        let state = Arc::new(TuiState {
2446            current_assistant: std::sync::Mutex::new(None),
2447            tool_components: std::sync::Mutex::new(HashMap::new()),
2448            bash_components: std::sync::Mutex::new(HashMap::new()),
2449            last_tool_comp: std::sync::Mutex::new(None),
2450            status: std::sync::Mutex::new(RunStatus::Idle),
2451            footer: Arc::new(FooterComponent::new()),
2452            status_container: Arc::new(Container::new()),
2453            chat_container: Arc::new(Container::new()),
2454            loader: Arc::new(Loader::new()),
2455            last_assistant_text: std::sync::Mutex::new(String::new()),
2456            active_selector: std::sync::Mutex::new(None),
2457            autocomplete: AutocompleteManager::new(),
2458            autocomplete_container: Arc::new(Container::new()),
2459            theme_manager: Arc::new(ThemeManager::new()),
2460            tui: None,
2461            current_model_id: std::sync::Mutex::new(String::new()),
2462            show_images: std::sync::Mutex::new(true),
2463        });
2464        let editor_container = Arc::new(Container::new());
2465        let editor = Arc::new(Editor::simple());
2466        editor_container.add_child(editor.clone());
2467        assert!(!state.selector_open());
2468
2469        let tui_terminal = Box::new(ProcessTerminal::new());
2470        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
2471        let list = Arc::new(SelectList::new(
2472            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
2473            5,
2474        ));
2475        open_selector(&state, &editor_container, &editor, &tui, list, SelectorKind::Theme);
2476        assert!(state.selector_open());
2477        // list only (editor swapped out).
2478        assert_eq!(editor_container.child_count(), 1);
2479
2480        close_selector(&state, &editor_container, &editor, &tui);
2481        assert!(!state.selector_open());
2482        // editor restored.
2483        assert_eq!(editor_container.child_count(), 1);
2484    }
2485
2486    #[test]
2487    fn test_accept_top_suggestion_replaces_prefix() {
2488        // `/he` + Tab → `/help ` (slash command provider inserts a space).
2489        let state = Arc::new(TuiState {
2490            current_assistant: std::sync::Mutex::new(None),
2491            tool_components: std::sync::Mutex::new(HashMap::new()),
2492            bash_components: std::sync::Mutex::new(HashMap::new()),
2493            last_tool_comp: std::sync::Mutex::new(None),
2494            status: std::sync::Mutex::new(RunStatus::Idle),
2495            footer: Arc::new(FooterComponent::new()),
2496            status_container: Arc::new(Container::new()),
2497            chat_container: Arc::new(Container::new()),
2498            loader: Arc::new(Loader::new()),
2499            last_assistant_text: std::sync::Mutex::new(String::new()),
2500            active_selector: std::sync::Mutex::new(None),
2501            autocomplete: AutocompleteManager::new(),
2502            autocomplete_container: Arc::new(Container::new()),
2503            theme_manager: Arc::new(ThemeManager::new()),
2504            tui: None,
2505            current_model_id: std::sync::Mutex::new(String::new()),
2506            show_images: std::sync::Mutex::new(true),
2507        });
2508        {
2509            let mut combined = CombinedAutocompleteProvider::new();
2510            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2511                v1_slash_commands(),
2512            )));
2513            state.autocomplete.set_provider(Arc::new(combined));
2514        }
2515        let editor = Arc::new(Editor::simple());
2516        editor.set_text("/he");
2517        editor.set_cursor(0, 3);
2518        refresh_autocomplete(&state, &editor);
2519        let accepted = accept_top_suggestion(&state, &editor);
2520        assert!(accepted, "should accept the top suggestion");
2521        let text = editor.get_text();
2522        assert!(
2523            text.starts_with("/help"),
2524            "editor text should start with /help, got {text}"
2525        );
2526    }
2527}