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()` still has a readerless companion, so this module
11//!   owns 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;
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33
34use rpi_agent::{AgentEvent, AgentMessage};
35use rpi_ai::types::{AssistantMessage, Content, UserMessage};
36use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
37use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
38use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
39#[cfg(test)]
40use rpi_tui::strip_ansi;
41use rpi_tui::{
42    apply_theme_preset, render_diff, AssistantBlock, AssistantMessageComponent,
43    AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
44    BashTruncation, CombinedAutocompleteProvider, Container, DynamicBorder, Editor, EditorOptions,
45    EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode, FooterComponent, Loader,
46    ProcessTerminal, ScrollView, ScrollViewOptions, SelectItem, SelectList,
47    SlashCommand as SlashCommandEntry, SlashCommandAutocompleteProvider, Spacer, StackChild,
48    StackEntry, Text, ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen,
49    UserMessageComponent, VStack, TUI,
50};
51use rpi_tui::{bold as tui_bold, theme as current_theme};
52
53#[allow(unused_imports)]
54use rpi_tui::BashStatus;
55
56use crate::args::Args;
57
58/// B5e: the markdown-transformer trait object the assistant-message render path
59/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
60/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
61/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
62/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
63/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
64type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
65
66/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
67/// render path applies to raw assistant text before styling. Wraps any plugin
68/// `register_markdown_transformer` handlers registered in `snapshot` (chained
69/// in registration order: each handler's output feeds the next). `None` when
70/// no markdown transformers are registered (the component defaults to the
71/// identity transform + this avoids a closure allocation on the hot render
72/// path).
73///
74/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
75/// borrow that built it (the snapshot's `active` flag guards dispatch in
76/// `emit_resources_discover`/event translation; a reloaded session's old
77/// snapshot flips false, so a stale closure no-ops rather than driving a
78/// half-swapped registry — the transformer falls back to the input unchanged
79/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
80///
81/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
82/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
83/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
84/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
85/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
86/// `free_string` → parse `{"markdown":…}`).
87fn build_markdown_transformer(
88    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
89) -> Option<MarkdownTransformer> {
90    let snapshot = snapshot?;
91    // Pre-check: if no markdown renderers are registered, return None so the
92    // component uses the identity path (no per-delta closure call). The
93    // renderers list is a per-call `renderers_of` clone; snapshotting it once
94    // here keeps the closure cheap on the hot path.
95    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
96    if renderers.is_empty() {
97        return None;
98    }
99    Some(Arc::new(move |raw: &str| -> String {
100        transform_markdown_chain(&snapshot, &renderers, raw)
101    }))
102}
103
104/// Drive the markdown-transformer chain for one input string. Each registered
105/// handler receives the previous handler's output (or the raw input for the
106/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
107/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
108/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
109/// an inactive snapshot — the chain short-circuits to the current text
110/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
111fn transform_markdown_chain(
112    snapshot: &rpi_extensions::RegistrySnapshot,
113    renderers: &[rpi_extensions::RegisteredRenderer],
114    raw: &str,
115) -> String {
116    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
117    // The renderers were captured from this snapshot; if it has gone inactive,
118    // fall back to the raw input so the UI never renders stale-transformed text
119    // from a dead plugin.
120    if !snapshot.is_active() {
121        return raw.to_string();
122    }
123
124    let mut current = raw.to_string();
125    for renderer in renderers {
126        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
127            Ok(s) => s,
128            Err(_) => return current, // serialize failure — keep current, stop chain
129        };
130        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
131        // borrowed `StbStringRef` + an out-param. The plugin warrants
132        // `poll`/`render` are non-blocking + thread-safe (the same contract
133        // the tool adapter relies on). `user_data` is the plugin's opaque
134        // pointer, stable for the registry lifetime (the keepalive keeps the
135        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
136        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
137        // panic must not unwind across the FFI boundary (same policy as the
138        // tool partial cb + the runtime_action trampoline).
139        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
140            let mut out = rpi_plugin_sdk::StbString::empty();
141            let rc = (renderer.render_fn)(
142                rpi_plugin_sdk::StbStringRef::from_str(&input),
143                &mut out as *mut rpi_plugin_sdk::StbString,
144                renderer.user_data,
145            );
146            let text = if rc == 0 {
147                let s = out.to_string_lossy();
148                Some(s)
149            } else {
150                None
151            };
152            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
153            // have written an error JSON the plugin allocated). `free_with` is
154            // idempotent on an empty `StbString`.
155            out.free_with(Some(renderer.plugin_free_string));
156            text
157        }));
158        let out_text = match outcome {
159            Ok(Some(s)) => s,
160            Ok(None) => return current, // rc != 0 — skip this handler, keep current
161            Err(_) => return current,   // panic — skip, keep current (do not abort: the
162                                         // render path is not the action trampoline; a panicking transformer
163                                         // degrades to identity rather than killing the process. Logged via
164                                         // the `tracing` crate's panic hook.)
165        };
166        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
167        // keeps the current text (skip this handler).
168        let next = serde_json::from_str::<serde_json::Value>(&out_text)
169            .ok()
170            .and_then(|v| {
171                v.get("markdown")
172                    .and_then(|m| m.as_str())
173                    .map(|s| s.to_string())
174            })
175            .unwrap_or(current);
176        current = next;
177    }
178    current
179}
180
181// ===========================================================================
182// Slash commands — trait + registry
183// ===========================================================================
184//
185// Each built-in slash command is one `impl SlashCommand`. The commands are
186// registered at startup into a [`CommandRegistry`] (one source of truth) that
187// serves both dispatch ("given this token, run the command") and autocomplete
188// ("list the visible commands"). This replaces the old two-list + sync-test
189// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
190// kept in lock-step by hand.
191//
192// `execute` runs on the blocking key/compose thread (the editor `on_submit`
193// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
194//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
195//     `tokio::spawn` the work and return immediately;
196//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
197//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
198//   - everything else mutates the chat container + requests a render directly.
199
200/// The borrowed world a slash command runs against. All fields are `Arc` (or a
201/// cheap `String` snapshot), so one `CommandContext` clones freely into each
202/// command without per-capture ceremony — this struct is exactly the set of
203/// `*_for_cb` clones the old submit closure used to make individually.
204#[derive(Clone)]
205struct CommandContext {
206    chat: Arc<Container>,
207    tui: Arc<TuiAltScreen>,
208    tx: mpsc::UnboundedSender<TuiMessage>,
209    state: Arc<TuiState>,
210    editor: Arc<Editor>,
211    editor_container: Arc<Container>,
212    lane: Arc<dyn AgentLane>,
213    model_catalog: Arc<Vec<rpi_ai::Model>>,
214    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
215    /// blocking key loop starts. Selectors/key loop can't await, so they read
216    /// this owned string instead. Semantically unchanged from pre-refactor.
217    lane_model_id: String,
218    cwd: std::path::PathBuf,
219    /// Harness resources snapshot (skills + prompt templates) for `/context`.
220    /// Captured once at TUI startup because the blocking submit thread can't
221    /// `.await get_resources()`.
222    resources: Arc<rpi_harness::types::AgentHarnessResources>,
223    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
224    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
225    /// without an `.await` (the command can't drive reload directly — it signals
226    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
227    /// `reload_extension_resources` routine on the async runtime).
228    reload_context: Arc<crate::session::ReloadContext>,
229}
230
231/// One slash command.
232trait SlashCommand: Send + Sync {
233    /// Canonical name, with the leading `/` (e.g. "/model").
234    fn name(&self) -> &str;
235    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
236    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
237    /// `/`-autocomplete list (most aliases stay hidden).
238    fn aliases(&self) -> &'static [&'static str] {
239        &[]
240    }
241    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
242    /// commands (`/context`, `/name`, …) return `false`.
243    fn visible(&self) -> bool {
244        true
245    }
246    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
247    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
248    /// the list to keep it short. `/new` and `/quit` override this to surface.
249    fn alias_visible(&self) -> &'static [&'static str] {
250        &[]
251    }
252    /// Description shown in autocomplete and `/help`. A non-empty description is
253    /// required to surface in autocomplete even when `visible()` is true.
254    fn description(&self) -> &'static str {
255        ""
256    }
257    fn description_owned(&self) -> String {
258        self.description().to_string()
259    }
260    /// Execute the command. Only invoked for inputs starting with `/` whose
261    /// first token matches `name()` or an alias. `args` is the whitespace-
262    /// trimmed remainder after the command token ("" when none). Must stay
263    /// synchronous (see the module-level note) — async work goes through
264    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
265    fn execute(&self, ctx: &CommandContext, args: &str);
266}
267
268/// Holds all registered slash commands; the single source of truth for both
269/// dispatch and the autocomplete list.
270struct CommandRegistry {
271    commands: Vec<Arc<dyn SlashCommand>>,
272}
273
274impl CommandRegistry {
275    fn new() -> Self {
276        Self {
277            commands: Vec::new(),
278        }
279    }
280
281    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
282        self.commands.push(cmd);
283    }
284
285    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
286    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
287    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
288        self.commands
289            .iter()
290            .find(|c| c.name() == token || c.aliases().contains(&token))
291    }
292
293    /// The autocomplete entries, derived from the registry so it can never drift
294    /// from what dispatch recognizes. Surfaces the canonical name when
295    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
296    /// Order = registration order; built-ins are registered before templates,
297    /// so they win on a fuzzy tie (unchanged).
298    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
299        let mut out: Vec<SlashCommandEntry> = Vec::new();
300        for c in &self.commands {
301            let description = c.description_owned();
302            if c.visible() && !description.is_empty() {
303                out.push(SlashCommandEntry {
304                    name: c.name().into(),
305                    description: description.clone(),
306                });
307            }
308            // Surfaced aliases share the command's description.
309            for alias in c.alias_visible() {
310                out.push(SlashCommandEntry {
311                    name: (*alias).into(),
312                    description: description.clone(),
313                });
314            }
315        }
316        out
317    }
318}
319
320/// Resolve the command for a `/`-prefixed input and run it, or emit the
321/// unknown-command error if nothing matches. Non-slash text never reaches here
322/// — callers route only `/`-prefixed inputs and send plain text directly.
323fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
324    let mut parts = text.split_whitespace();
325    let token = parts.next().unwrap_or("");
326    let args = parts.collect::<Vec<_>>().join(" ");
327    match registry.find(token) {
328        Some(cmd) => cmd.execute(ctx, &args),
329        None => {
330            add_error_message(
331                &ctx.chat,
332                &format!("Unknown command: {text}. Type /help for available commands."),
333            );
334            ctx.tui.request_render(false);
335        }
336    }
337}
338
339/// A slash command registered by a native extension. The command metadata is
340/// captured for autocomplete, while the handler is looked up from the live
341/// session on every invocation so `/reload` takes effect without rebuilding
342/// the editor callback.
343struct ExtensionCommand {
344    name: String,
345    description: String,
346    session: crate::session::ExtensionSessionCell,
347}
348
349impl SlashCommand for ExtensionCommand {
350    fn name(&self) -> &str {
351        &self.name
352    }
353
354    fn description(&self) -> &'static str {
355        "extension command"
356    }
357
358    fn description_owned(&self) -> String {
359        self.description.clone()
360    }
361
362    fn execute(&self, ctx: &CommandContext, args: &str) {
363        let result = invoke_extension_command(&self.session, &self.name, args);
364        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
365    }
366}
367
368fn invoke_extension_command(
369    session: &crate::session::ExtensionSessionCell,
370    name: &str,
371    args: &str,
372) -> Option<serde_json::Value> {
373    let command = session
374        .lock()
375        .ok()
376        .and_then(|s| s.snapshot_arc())
377        .and_then(|snap| {
378            snap.commands()
379                .iter()
380                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
381                .cloned()
382        })?;
383    let input = serde_json::json!({ "args": args, "command": name });
384    let input = serde_json::to_string(&input).ok()?;
385    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
386        let mut out = rpi_plugin_sdk::StbString::empty();
387        let rc = (command.handler)(
388            rpi_plugin_sdk::StbStringRef::from_str(&input),
389            &mut out as *mut rpi_plugin_sdk::StbString,
390            command.user_data,
391        );
392        let text = if rc == 0 {
393            Some(out.to_string_lossy())
394        } else {
395            None
396        };
397        rpi_extensions::host_free_string(out);
398        text
399    }))
400    .ok()
401    .flatten()?;
402    serde_json::from_str(&outcome).ok()
403}
404
405fn handle_extension_ui_result(
406    result: Option<serde_json::Value>,
407    ctx: &CommandContext,
408    session: crate::session::ExtensionSessionCell,
409    command_name: String,
410) {
411    let Some(value) = result else {
412        add_error_message(&ctx.chat, "Extension command failed.");
413        ctx.tui.request_render(false);
414        return;
415    };
416    match value.get("kind").and_then(|v| v.as_str()) {
417        Some("message") | None => {
418            let fallback = value.to_string();
419            let text = value
420                .get("text")
421                .and_then(|v| v.as_str())
422                .unwrap_or(&fallback)
423                .to_string();
424            if !text.is_empty() {
425                add_note_message(&ctx.chat, &text);
426            }
427            ctx.tui.request_render(false);
428        }
429        Some("selector") => open_extension_selector(ctx, session, command_name, value),
430        Some("editor") => open_extension_editor(ctx, session, command_name, value),
431        Some(other) => {
432            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
433            ctx.tui.request_render(false);
434        }
435    }
436}
437
438fn open_extension_selector(
439    ctx: &CommandContext,
440    session: crate::session::ExtensionSessionCell,
441    command_name: String,
442    value: serde_json::Value,
443) {
444    let items = value
445        .get("items")
446        .and_then(|v| v.as_array())
447        .map(|items| {
448            items
449                .iter()
450                .filter_map(|item| {
451                    let value = item.get("value")?.as_str()?;
452                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
453                    let mut out = SelectItem::new(value, label);
454                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
455                        out = out.with_description(desc);
456                    }
457                    Some(out)
458                })
459                .collect::<Vec<_>>()
460        })
461        .unwrap_or_default();
462    if items.is_empty() {
463        add_error_message(&ctx.chat, "Extension selector has no items.");
464        ctx.tui.request_render(false);
465        return;
466    }
467    let list = Arc::new(SelectList::new(items, 10));
468    let state = ctx.state.clone();
469    let ec = ctx.editor_container.clone();
470    let editor = ctx.editor.clone();
471    let tui = ctx.tui.clone();
472    let session_select = session.clone();
473    let command_select = command_name.clone();
474    let ctx_select = ctx.clone();
475    list.on_select(Arc::new(move |item| {
476        let args = serde_json::json!({ "action": "select", "value": item.value });
477        let result = invoke_extension_command(
478            &session_select,
479            &command_select,
480            &serde_json::to_string(&args).unwrap_or_default(),
481        );
482        close_selector(&state, &ec, &editor, &tui);
483        handle_extension_ui_result(
484            result,
485            &ctx_select,
486            session_select.clone(),
487            command_select.clone(),
488        );
489    }));
490    let state_cancel = ctx.state.clone();
491    let ec_cancel = ctx.editor_container.clone();
492    let editor_cancel = ctx.editor.clone();
493    let tui_cancel = ctx.tui.clone();
494    list.on_cancel(Arc::new(move || {
495        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
496    }));
497    open_selector(
498        &ctx.state,
499        &ctx.editor_container,
500        &ctx.editor,
501        &ctx.tui,
502        list,
503        SelectorKind::Extension,
504    );
505}
506
507fn open_extension_editor(
508    ctx: &CommandContext,
509    session: crate::session::ExtensionSessionCell,
510    command_name: String,
511    value: serde_json::Value,
512) {
513    let initial = value
514        .get("initialText")
515        .or_else(|| value.get("text"))
516        .and_then(|v| v.as_str())
517        .unwrap_or_default()
518        .to_string();
519    let editor = Arc::new(Editor::new(
520        EditorOptions {
521            padding_x: 1,
522            autocomplete_max_visible: 0,
523            placeholder: value
524                .get("placeholder")
525                .and_then(|v| v.as_str())
526                .map(str::to_string),
527            initial_text: Some(initial),
528        },
529        EditorStyle {
530            prompt: "> ".to_string(),
531            placeholder: String::new(),
532        },
533        Arc::new(rpi_tui::Keybindings::new()),
534    ));
535    editor.set_focused(true);
536    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
537    ctx.editor_container.clear();
538    ctx.editor_container.add_child(editor.clone());
539
540    let state = ctx.state.clone();
541    let ec = ctx.editor_container.clone();
542    let original = ctx.editor.clone();
543    let session_submit = session.clone();
544    let command_submit = command_name.clone();
545    let ctx_submit = ctx.clone();
546    editor.on_submit(Arc::new(move |text| {
547        let args = serde_json::json!({ "action": "edit", "text": text });
548        let result = invoke_extension_command(
549            &session_submit,
550            &command_submit,
551            &serde_json::to_string(&args).unwrap_or_default(),
552        );
553        close_extension_editor(&state, &ec, &original);
554        handle_extension_ui_result(
555            result,
556            &ctx_submit,
557            session_submit.clone(),
558            command_submit.clone(),
559        );
560    }));
561    ctx.tui.set_focus(Some(editor));
562    ctx.tui.request_render(false);
563}
564
565fn close_extension_editor(
566    state: &Arc<TuiState>,
567    editor_container: &Arc<Container>,
568    editor: &Arc<Editor>,
569) {
570    editor_container.clear();
571    editor_container.add_child(editor.clone());
572    *state.active_extension_editor.lock().unwrap() = None;
573    editor.set_focused(true);
574}
575
576// ---- Built-in command implementations ----
577
578struct HelpCommand;
579impl SlashCommand for HelpCommand {
580    fn name(&self) -> &'static str {
581        "/help"
582    }
583    fn aliases(&self) -> &'static [&'static str] {
584        &["/?"]
585    }
586    fn description(&self) -> &'static str {
587        "Show available commands"
588    }
589    fn execute(&self, ctx: &CommandContext, _args: &str) {
590        add_help_message(&ctx.chat);
591        ctx.tui.request_render(false);
592    }
593}
594
595struct ClearChatCommand;
596impl SlashCommand for ClearChatCommand {
597    fn name(&self) -> &'static str {
598        "/clear"
599    }
600    fn aliases(&self) -> &'static [&'static str] {
601        &["/new"]
602    }
603    // `/new` carries its own weight as a discoverable entry, so surface it.
604    fn alias_visible(&self) -> &'static [&'static str] {
605        &["/new"]
606    }
607    fn description(&self) -> &'static str {
608        "Clear the conversation"
609    }
610    fn execute(&self, ctx: &CommandContext, _args: &str) {
611        let _ = ctx.tx.send(TuiMessage::ClearChat);
612    }
613}
614
615struct ExitCommand;
616impl SlashCommand for ExitCommand {
617    fn name(&self) -> &'static str {
618        "/exit"
619    }
620    fn aliases(&self) -> &'static [&'static str] {
621        &["/quit", "/q"]
622    }
623    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
624    fn alias_visible(&self) -> &'static [&'static str] {
625        &["/quit"]
626    }
627    fn description(&self) -> &'static str {
628        "Exit the application"
629    }
630    fn execute(&self, ctx: &CommandContext, _args: &str) {
631        let _ = ctx.tx.send(TuiMessage::Exit);
632    }
633}
634
635struct VersionCommand;
636impl SlashCommand for VersionCommand {
637    fn name(&self) -> &'static str {
638        "/version"
639    }
640    fn aliases(&self) -> &'static [&'static str] {
641        &["/v"]
642    }
643    fn description(&self) -> &'static str {
644        "Show version information"
645    }
646    fn execute(&self, ctx: &CommandContext, _args: &str) {
647        add_version_message(&ctx.chat);
648        ctx.tui.request_render(false);
649    }
650}
651
652struct HotkeysCommand;
653impl SlashCommand for HotkeysCommand {
654    fn name(&self) -> &'static str {
655        "/hotkeys"
656    }
657    fn description(&self) -> &'static str {
658        "Show keyboard shortcuts"
659    }
660    fn execute(&self, ctx: &CommandContext, _args: &str) {
661        add_hotkeys_message(&ctx.chat);
662        ctx.tui.request_render(false);
663    }
664}
665
666struct ModelCommand;
667impl SlashCommand for ModelCommand {
668    fn name(&self) -> &'static str {
669        "/model"
670    }
671    fn aliases(&self) -> &'static [&'static str] {
672        &["/m"]
673    }
674    fn description(&self) -> &'static str {
675        "Choose a model (selector)"
676    }
677    fn execute(&self, ctx: &CommandContext, args: &str) {
678        let term = args.trim();
679        if !term.is_empty() {
680            // /model <name> — direct switch by id (pi handleModelCommand).
681            let Some(model) = ctx
682                .model_catalog
683                .iter()
684                .find(|m| m.id.eq_ignore_ascii_case(term))
685                .cloned()
686            else {
687                add_error_message(
688                    &ctx.chat,
689                    &format!("No model matches \"{term}\". Try /model for the list."),
690                );
691                ctx.tui.request_render(false);
692                return;
693            };
694            let model_id = model.id.clone();
695            ctx.state.set_current_model(&model);
696            let lane = ctx.lane.clone();
697            tokio::spawn(async move {
698                let _ = lane.set_model(model).await;
699            });
700            add_note_message(
701                &ctx.chat,
702                &format!(
703                    "Model set to {} — applies to the next message.",
704                    short_model_name(&model_id)
705                ),
706            );
707            ctx.tui.request_render(false);
708            return;
709        }
710        open_model_selector(
711            &ctx.state,
712            &ctx.editor_container,
713            &ctx.editor,
714            &ctx.tui,
715            &ctx.model_catalog,
716            &ctx.lane,
717            &ctx.lane_model_id,
718            &ctx.chat,
719        );
720    }
721}
722
723struct ThinkingCommand;
724impl SlashCommand for ThinkingCommand {
725    fn name(&self) -> &'static str {
726        "/thinking"
727    }
728    fn aliases(&self) -> &'static [&'static str] {
729        &["/think"]
730    }
731    fn description(&self) -> &'static str {
732        "Set thinking level (selector)"
733    }
734    fn execute(&self, ctx: &CommandContext, args: &str) {
735        let level_name = args.trim();
736        if !level_name.is_empty() {
737            // /thinking <level> — direct set (pi supports the param form).
738            let Some(level) = thinking_level_from_name(level_name) else {
739                add_error_message(
740                    &ctx.chat,
741                    &format!(
742                        "Unknown thinking level \"{level_name}\". Valid: {}",
743                        crate::args::VALID_THINKING_LEVELS.join(", ")
744                    ),
745                );
746                ctx.tui.request_render(false);
747                return;
748            };
749            let lane = ctx.lane.clone();
750            let footer = ctx.state.footer.clone();
751            tokio::spawn(async move {
752                let _ = lane.set_thinking_level(level).await;
753            });
754            footer.set_thinking_level(Some(thinking_level_name(level)));
755            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
756            ctx.tui.request_render(false);
757            return;
758        }
759        open_thinking_selector(
760            &ctx.state,
761            &ctx.editor_container,
762            &ctx.editor,
763            &ctx.tui,
764            &ctx.lane,
765            &ctx.model_catalog,
766            &ctx.lane_model_id,
767            &ctx.chat,
768        );
769    }
770}
771
772struct ToolsCommand;
773impl SlashCommand for ToolsCommand {
774    fn name(&self) -> &'static str {
775        "/tools"
776    }
777    fn description(&self) -> &'static str {
778        "Toggle tools on/off"
779    }
780    fn execute(&self, ctx: &CommandContext, _args: &str) {
781        open_tools_selector(
782            &ctx.state,
783            &ctx.editor_container,
784            &ctx.editor,
785            &ctx.tui,
786            &ctx.lane,
787            &ctx.chat,
788        );
789    }
790}
791
792struct ImagesCommand;
793impl SlashCommand for ImagesCommand {
794    fn name(&self) -> &'static str {
795        "/images"
796    }
797    fn description(&self) -> &'static str {
798        "Toggle inline images"
799    }
800    fn execute(&self, ctx: &CommandContext, _args: &str) {
801        open_images_selector(
802            &ctx.state,
803            &ctx.editor_container,
804            &ctx.editor,
805            &ctx.tui,
806            &ctx.chat,
807        );
808    }
809}
810
811struct SessionCommand;
812impl SlashCommand for SessionCommand {
813    fn name(&self) -> &'static str {
814        "/session"
815    }
816    fn aliases(&self) -> &'static [&'static str] {
817        &["/resume"]
818    }
819    fn description(&self) -> &'static str {
820        "List saved sessions"
821    }
822    fn execute(&self, ctx: &CommandContext, _args: &str) {
823        open_session_selector(
824            &ctx.state,
825            &ctx.editor_container,
826            &ctx.editor,
827            &ctx.tui,
828            &ctx.cwd,
829            &ctx.tx,
830        );
831    }
832}
833
834struct ThemeCommand;
835impl SlashCommand for ThemeCommand {
836    fn name(&self) -> &'static str {
837        "/theme"
838    }
839    fn description(&self) -> &'static str {
840        "Choose a theme (selector)"
841    }
842    fn execute(&self, ctx: &CommandContext, args: &str) {
843        let name = args.trim().to_ascii_lowercase();
844        if !name.is_empty() {
845            // /theme <name> — direct apply + persist (matches /settings Theme).
846            let preset = match name.as_str() {
847                "light" => ThemePreset::Light,
848                "monochrome" => ThemePreset::Monochrome,
849                "dark" => ThemePreset::Dark,
850                _ => {
851                    add_error_message(
852                        &ctx.chat,
853                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
854                    );
855                    ctx.tui.request_render(false);
856                    return;
857                }
858            };
859            apply_theme_preset(preset);
860            let mut settings = crate::settings::load_settings().unwrap_or_default();
861            settings.theme = Some(name.clone());
862            let _ = crate::settings::save_settings(&settings);
863            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
864            ctx.tui.request_render(false);
865            ctx.tui.render_now(true);
866            return;
867        }
868        open_theme_selector(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
869    }
870}
871
872struct CompactCommand;
873impl SlashCommand for CompactCommand {
874    fn name(&self) -> &'static str {
875        "/compact"
876    }
877    fn description(&self) -> &'static str {
878        "Compact the conversation"
879    }
880    fn execute(&self, ctx: &CommandContext, _args: &str) {
881        let _ = ctx.tx.send(TuiMessage::Compact);
882    }
883}
884
885struct CopyCommand;
886impl SlashCommand for CopyCommand {
887    fn name(&self) -> &'static str {
888        "/copy"
889    }
890    fn description(&self) -> &'static str {
891        "Copy last reply to clipboard"
892    }
893    fn execute(&self, ctx: &CommandContext, _args: &str) {
894        let _ = ctx.tx.send(TuiMessage::Copy);
895    }
896}
897
898struct ExportCommand;
899impl SlashCommand for ExportCommand {
900    fn name(&self) -> &'static str {
901        "/export"
902    }
903    fn description(&self) -> &'static str {
904        "Export session to a markdown file"
905    }
906    fn execute(&self, ctx: &CommandContext, _args: &str) {
907        let _ = ctx.tx.send(TuiMessage::ExportSession);
908    }
909}
910
911struct ForkCommand;
912impl SlashCommand for ForkCommand {
913    fn name(&self) -> &'static str {
914        "/fork"
915    }
916    fn description(&self) -> &'static str {
917        "Fork the session into a new one"
918    }
919    fn execute(&self, ctx: &CommandContext, _args: &str) {
920        let _ = ctx.tx.send(TuiMessage::ForkSession);
921    }
922}
923
924/// `/clone` is the native Pi spelling for duplicating the current session.
925/// Reuse the same durable fork path as `/fork`; both create a child session
926/// and rebind the live harness to it.
927struct CloneCommand;
928impl SlashCommand for CloneCommand {
929    fn name(&self) -> &'static str {
930        "/clone"
931    }
932    fn description(&self) -> &'static str {
933        "Duplicate the current session"
934    }
935    fn execute(&self, ctx: &CommandContext, _args: &str) {
936        let _ = ctx.tx.send(TuiMessage::ForkSession);
937    }
938}
939
940struct TreeCommand;
941impl SlashCommand for TreeCommand {
942    fn name(&self) -> &'static str {
943        "/tree"
944    }
945    fn description(&self) -> &'static str {
946        "Navigate the current session tree"
947    }
948    fn execute(&self, ctx: &CommandContext, _args: &str) {
949        let _ = ctx.tx.send(TuiMessage::OpenTree);
950    }
951}
952
953struct LoginCommand;
954impl SlashCommand for LoginCommand {
955    fn name(&self) -> &'static str {
956        "/login"
957    }
958    fn description(&self) -> &'static str {
959        "Save an Anthropic API key"
960    }
961    fn execute(&self, ctx: &CommandContext, args: &str) {
962        let key = args.trim();
963        if key.is_empty() {
964            add_note_message(&ctx.chat, "Usage: /login <api-key>");
965        } else {
966            let result = crate::config::upsert_credential(
967                "anthropic",
968                crate::config::Credential::ApiKey {
969                    key: Some(key.to_string()),
970                    env: None,
971                },
972            );
973            match result {
974                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
975                Err(error) => {
976                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
977                }
978            }
979        }
980        ctx.tui.request_render(false);
981    }
982}
983
984struct LogoutCommand;
985impl SlashCommand for LogoutCommand {
986    fn name(&self) -> &'static str {
987        "/logout"
988    }
989    fn description(&self) -> &'static str {
990        "Remove saved Anthropic credentials"
991    }
992    fn execute(&self, ctx: &CommandContext, _args: &str) {
993        match crate::config::delete_credential("anthropic") {
994            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
995            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
996            Err(error) => {
997                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
998            }
999        }
1000        ctx.tui.request_render(false);
1001    }
1002}
1003
1004struct TrustCommand;
1005impl SlashCommand for TrustCommand {
1006    fn name(&self) -> &'static str {
1007        "/trust"
1008    }
1009    fn description(&self) -> &'static str {
1010        "Trust the current project"
1011    }
1012    fn execute(&self, ctx: &CommandContext, args: &str) {
1013        let value = match args.trim().to_ascii_lowercase().as_str() {
1014            "" | "yes" | "y" | "true" => Some(true),
1015            "no" | "n" | "false" => Some(false),
1016            "clear" | "reset" | "none" => None,
1017            _ => {
1018                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
1019                ctx.tui.request_render(false);
1020                return;
1021            }
1022        };
1023        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1024        match crate::config::set_project_trust(&cwd, value) {
1025            Ok(()) => {
1026                let label = match value {
1027                    Some(true) => "trusted",
1028                    Some(false) => "untrusted",
1029                    None => "trust decision cleared",
1030                };
1031                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
1032            }
1033            Err(error) => add_error_message(
1034                &ctx.chat,
1035                &format!("Could not save trust decision: {error}"),
1036            ),
1037        }
1038        ctx.tui.request_render(false);
1039    }
1040}
1041
1042struct NameCommand;
1043impl SlashCommand for NameCommand {
1044    fn name(&self) -> &'static str {
1045        "/name"
1046    }
1047    fn description(&self) -> &'static str {
1048        "Set session display name"
1049    }
1050    fn execute(&self, ctx: &CommandContext, args: &str) {
1051        let name = args.trim();
1052        if name.is_empty() {
1053            add_note_message(
1054                &ctx.chat,
1055                "Usage: /name <display name> — sets the current session's name.",
1056            );
1057            ctx.tui.request_render(false);
1058            return;
1059        }
1060        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
1061    }
1062}
1063
1064struct ImportCommand;
1065impl SlashCommand for ImportCommand {
1066    fn name(&self) -> &'static str {
1067        "/import"
1068    }
1069    fn description(&self) -> &'static str {
1070        "Import a session file (path)"
1071    }
1072    fn execute(&self, ctx: &CommandContext, args: &str) {
1073        let path = args.trim();
1074        if path.is_empty() {
1075            add_note_message(
1076                &ctx.chat,
1077                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
1078            );
1079            ctx.tui.request_render(false);
1080            return;
1081        }
1082        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
1083    }
1084}
1085
1086struct SettingsCommand;
1087impl SlashCommand for SettingsCommand {
1088    fn name(&self) -> &'static str {
1089        "/settings"
1090    }
1091    fn description(&self) -> &'static str {
1092        "Open settings menu"
1093    }
1094    fn execute(&self, ctx: &CommandContext, _args: &str) {
1095        open_settings_selector(
1096            &ctx.state,
1097            &ctx.editor_container,
1098            &ctx.editor,
1099            &ctx.tui,
1100            &ctx.lane,
1101            &ctx.model_catalog,
1102            &ctx.lane_model_id,
1103            &ctx.chat,
1104        );
1105    }
1106}
1107
1108struct ScopedModelsCommand;
1109impl SlashCommand for ScopedModelsCommand {
1110    fn name(&self) -> &'static str {
1111        "/scoped-models"
1112    }
1113    fn description(&self) -> &'static str {
1114        "Choose models for Ctrl+M cycling"
1115    }
1116    fn execute(&self, ctx: &CommandContext, _args: &str) {
1117        open_scoped_models_selector(
1118            &ctx.state,
1119            &ctx.editor_container,
1120            &ctx.editor,
1121            &ctx.tui,
1122            &ctx.model_catalog,
1123            &ctx.chat,
1124        );
1125    }
1126}
1127
1128struct ShareCommand;
1129impl SlashCommand for ShareCommand {
1130    fn name(&self) -> &'static str {
1131        "/share"
1132    }
1133    fn description(&self) -> &'static str {
1134        "Share session (gist via gh, or clipboard)"
1135    }
1136    fn execute(&self, ctx: &CommandContext, _args: &str) {
1137        let _ = ctx.tx.send(TuiMessage::ShareSession);
1138    }
1139}
1140
1141struct ArminCommand;
1142impl SlashCommand for ArminCommand {
1143    fn name(&self) -> &'static str {
1144        "/armin"
1145    }
1146    fn description(&self) -> &'static str {
1147        "??? (easter egg)"
1148    }
1149    fn execute(&self, ctx: &CommandContext, _args: &str) {
1150        crate::extras::add_armin(&ctx.chat);
1151        ctx.tui.request_render(false);
1152    }
1153}
1154
1155struct EarendilCommand;
1156impl SlashCommand for EarendilCommand {
1157    fn name(&self) -> &'static str {
1158        "/earendil"
1159    }
1160    fn description(&self) -> &'static str {
1161        "Announcement"
1162    }
1163    fn execute(&self, ctx: &CommandContext, _args: &str) {
1164        crate::extras::add_earendil(&ctx.chat);
1165        ctx.tui.request_render(false);
1166    }
1167}
1168
1169/// `/context` — lists discovered context files, skills, and prompt templates.
1170/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
1171/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
1172struct ContextCommand;
1173impl SlashCommand for ContextCommand {
1174    fn name(&self) -> &'static str {
1175        "/context"
1176    }
1177    fn visible(&self) -> bool {
1178        false
1179    }
1180    fn execute(&self, ctx: &CommandContext, _args: &str) {
1181        show_context_panel(&ctx.chat, &ctx.resources);
1182        ctx.tui.request_render(false);
1183    }
1184}
1185
1186/// `/reload` — re-run extension + resource discovery into the LIVE harness
1187/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
1188/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
1189/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
1190/// The command itself runs on the blocking submit thread, so it can't drive
1191/// the async `reload_extension_resources` routine directly — it signals the main
1192/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
1193/// (A plugin's `runtime_action(Reload)` signals the same loop via the
1194/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
1195/// self-unmapping race a synchronous plugin-initiated reload would have.)
1196struct ReloadCommand;
1197impl SlashCommand for ReloadCommand {
1198    fn name(&self) -> &'static str {
1199        "/reload"
1200    }
1201    fn description(&self) -> &'static str {
1202        "Reload extensions, skills, prompts"
1203    }
1204    fn execute(&self, ctx: &CommandContext, _args: &str) {
1205        // Signal the main loop. It owns the `&AgentHarness` borrow the
1206        // `reload_extension_resources` routine needs (the blocking submit thread
1207        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
1208        add_note_message(&ctx.chat, "Reloading extensions + resources…");
1209        ctx.tui.request_render(false);
1210        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
1211    }
1212}
1213
1214/// Build the full command registry: active built-ins first (so they win on a
1215/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
1216/// commands are merged in separately by the autocomplete builder (they dispatch
1217/// via template expansion, not this registry).
1218fn build_builtin_registry() -> CommandRegistry {
1219    let mut r = CommandRegistry::new();
1220    r.register(Arc::new(HelpCommand));
1221    r.register(Arc::new(ClearChatCommand));
1222    r.register(Arc::new(ExitCommand));
1223    r.register(Arc::new(VersionCommand));
1224    r.register(Arc::new(ModelCommand));
1225    r.register(Arc::new(ThinkingCommand));
1226    r.register(Arc::new(ToolsCommand));
1227    r.register(Arc::new(ImagesCommand));
1228    r.register(Arc::new(SessionCommand));
1229    r.register(Arc::new(ThemeCommand));
1230    r.register(Arc::new(CompactCommand));
1231    r.register(Arc::new(CopyCommand));
1232    r.register(Arc::new(HotkeysCommand));
1233    r.register(Arc::new(ArminCommand));
1234    r.register(Arc::new(EarendilCommand));
1235    r.register(Arc::new(ContextCommand));
1236    // Recognized but inert in v1 (one struct backs them all). The TS builtins
1237    // out of v1 scope; each carries a description so autocomplete surfaces its
1238    // existence even though running it reports "not supported".
1239    r.register(Arc::new(NameCommand));
1240    r.register(Arc::new(SettingsCommand));
1241    r.register(Arc::new(ScopedModelsCommand));
1242    r.register(Arc::new(ExportCommand));
1243    r.register(Arc::new(ImportCommand));
1244    r.register(Arc::new(ShareCommand));
1245    r.register(Arc::new(ForkCommand));
1246    r.register(Arc::new(CloneCommand));
1247    r.register(Arc::new(TreeCommand));
1248    r.register(Arc::new(TrustCommand));
1249    r.register(Arc::new(LoginCommand));
1250    r.register(Arc::new(LogoutCommand));
1251    r.register(Arc::new(ReloadCommand));
1252    r
1253}
1254
1255fn register_extension_commands(
1256    registry: &mut CommandRegistry,
1257    session: crate::session::ExtensionSessionCell,
1258) {
1259    let commands = session
1260        .lock()
1261        .ok()
1262        .and_then(|s| s.snapshot_arc())
1263        .map(|snap| snap.commands().to_vec())
1264        .unwrap_or_default();
1265    for command in commands {
1266        let name = if command.name.starts_with('/') {
1267            command.name.clone()
1268        } else {
1269            format!("/{}", command.name)
1270        };
1271        if registry.find(&name).is_some() {
1272            continue;
1273        }
1274        registry.register(Arc::new(ExtensionCommand {
1275            name,
1276            description: command.description,
1277            session: session.clone(),
1278        }));
1279    }
1280}
1281
1282// ===========================================================================
1283// Channel + helpers
1284// ===========================================================================
1285
1286/// Message type for communication between the key/callback threads and the
1287/// main async loop.
1288enum TuiMessage {
1289    UserInput(String),
1290    OpenTree,
1291    NavigateTree(String),
1292    Exit,
1293    /// Clear the transcript (from `/clear`).
1294    ClearChat,
1295    /// Compact the conversation (from `/compact`).
1296    Compact,
1297    /// Copy the last assistant reply to the clipboard (from `/copy`).
1298    Copy,
1299    /// Hot-switch to another saved session (from the `/session` selector):
1300    /// the payload is the session id the selector's item value carried.
1301    SwitchSession(String),
1302    /// Export the current session to a markdown file (from `/export`).
1303    ExportSession,
1304    /// Fork the current session into a new one and switch to it (from `/fork`).
1305    ForkSession,
1306    /// Rename the current session (from `/name <name>`).
1307    SetSessionName(String),
1308    /// Import a JSONL session file into the session dir and switch to it
1309    /// (from `/import <path>`).
1310    ImportSession(String),
1311    /// Share the current session (`/share`): `gh gist create` when the gh CLI
1312    /// is available, otherwise copy the transcript to the clipboard.
1313    ShareSession,
1314    /// `/reload` — re-run extension + resource discovery into the live harness
1315    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
1316    /// mailbox) signal the main loop, which awaits
1317    /// `reload_extension_resources` on the async runtime.
1318    ReloadExtensions,
1319}
1320
1321/// Extract the concatenated text content from an assistant message (mirrors
1322/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
1323fn assistant_text(msg: &AssistantMessage) -> String {
1324    msg.content
1325        .iter()
1326        .filter_map(|c| match c {
1327            Content::Text(t) => Some(t.text.clone()),
1328            _ => None,
1329        })
1330        .collect()
1331}
1332
1333/// The user message's text (Text content or the text blocks of a Blocks
1334/// payload — images are skipped, consistent with the v1 text-only prompt path).
1335fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
1336    match &msg.content {
1337        rpi_ai::types::UserContent::Text(s) => s.clone(),
1338        rpi_ai::types::UserContent::Blocks(blocks) => blocks
1339            .iter()
1340            .filter_map(|c| match c {
1341                Content::Text(t) => Some(t.text.clone()),
1342                _ => None,
1343            })
1344            .collect(),
1345    }
1346}
1347
1348/// Render the `/settings` panel: the saved settings.json values the session
1349/// honors, plus pointers to the commands that edit them (theme via `/theme`,
1350/// defaults via flags, cycle scope via `/scoped-models`). Kept for the
1351/// read-only summary; the interactive menu is [`open_settings_selector`].
1352fn show_settings_panel(chat: &Arc<Container>) {
1353    let s = crate::settings::load_settings().unwrap_or_default();
1354    let mut lines: Vec<String> = Vec::new();
1355    lines.push("⚙️  Saved settings:".into());
1356    lines.push(format!(
1357        "  Theme: {} (edit with /theme)",
1358        s.theme.as_deref().unwrap_or("(default)")
1359    ));
1360    lines.push(format!(
1361        "  Default model: {} (set at launch with --model)",
1362        s.default_model.as_deref().unwrap_or("(none)")
1363    ));
1364    lines.push(format!(
1365        "  Default thinking: {} (set at launch with --thinking)",
1366        s.default_thinking_level.as_deref().unwrap_or("(default)")
1367    ));
1368    match &s.scoped_models {
1369        Some(list) if !list.is_empty() => lines.push(format!(
1370            "  Ctrl+M cycle scope: {} (edit with /scoped-models)",
1371            list.join(", ")
1372        )),
1373        _ => lines.push("  Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
1374    }
1375    let body = lines.join("\n");
1376    container_note_block(chat, &body);
1377}
1378
1379/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
1380/// settings.json when present, otherwise every model. The current model is
1381/// always included (fallback) so cycling can never strand the user off-scope.
1382fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
1383    let scoped = crate::settings::load_settings()
1384        .ok()
1385        .and_then(|s| s.scoped_models)
1386        .unwrap_or_default();
1387    if scoped.is_empty() {
1388        return catalog.to_vec();
1389    }
1390    let mut out: Vec<rpi_ai::Model> = catalog
1391        .iter()
1392        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
1393        .cloned()
1394        .collect();
1395    // Never strand the user: if the current model isn't in scope, keep it.
1396    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
1397        if let Some(cur) = catalog
1398            .iter()
1399            .find(|m| m.id.eq_ignore_ascii_case(current_id))
1400        {
1401            out.push(cur.clone());
1402        }
1403    }
1404    out
1405}
1406
1407/// Interactive `/settings` menu: a top-level selector over the editable
1408/// settings, each opening a sub-selector that applies the choice AND persists
1409/// it to settings.json (theme / default model / default thinking / cycle
1410/// scope). Selecting a menu item swaps the current selector for the
1411/// sub-selector (the `active_selector` slot is single, so each open replaces
1412/// the previous list); the sub-selector's cancel restores the editor.
1413fn open_settings_selector(
1414    state: &Arc<TuiState>,
1415    editor_container: &Arc<Container>,
1416    editor: &Arc<Editor>,
1417    tui: &Arc<TuiAltScreen>,
1418    lane: &Arc<dyn AgentLane>,
1419    catalog: &[rpi_ai::Model],
1420    lane_model_id: &str,
1421    chat: &Arc<Container>,
1422) {
1423    let settings = crate::settings::load_settings().unwrap_or_default();
1424    let mut items: Vec<SelectItem> = Vec::new();
1425    items.push(
1426        SelectItem::new("theme", "Theme")
1427            .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
1428    );
1429    items.push(
1430        SelectItem::new("model", "Default model").with_description(
1431            &settings
1432                .default_model
1433                .clone()
1434                .unwrap_or_else(|| "(none)".into()),
1435        ),
1436    );
1437    items.push(
1438        SelectItem::new("thinking", "Default thinking").with_description(
1439            &settings
1440                .default_thinking_level
1441                .clone()
1442                .unwrap_or_else(|| "(default)".into()),
1443        ),
1444    );
1445    let scope_desc = match &settings.scoped_models {
1446        Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
1447        _ => "all models".to_string(),
1448    };
1449    items
1450        .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
1451    let list = Arc::new(SelectList::new(items, 10));
1452
1453    let state_sel = state.clone();
1454    let ec_sel = editor_container.clone();
1455    let editor_sel = editor.clone();
1456    let tui_sel = tui.clone();
1457    let lane_sel = lane.clone();
1458    let chat_sel = chat.clone();
1459    let catalog_sel = catalog.to_vec();
1460    let lane_model_sel = lane_model_id.to_string();
1461    list.on_select(Arc::new(move |item| {
1462        // Swap this menu for the sub-selector; each sub-selector saves its
1463        // choice to settings.json on select.
1464        match item.value.as_str() {
1465            "theme" => {
1466                open_settings_theme_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel, &chat_sel)
1467            }
1468            "model" => open_settings_model_selector(
1469                &state_sel,
1470                &ec_sel,
1471                &editor_sel,
1472                &tui_sel,
1473                &lane_sel,
1474                &catalog_sel,
1475                &lane_model_sel,
1476                &chat_sel,
1477            ),
1478            "thinking" => open_settings_thinking_selector(
1479                &state_sel,
1480                &ec_sel,
1481                &editor_sel,
1482                &tui_sel,
1483                &lane_sel,
1484                &catalog_sel,
1485                &lane_model_sel,
1486                &chat_sel,
1487            ),
1488            "scoped-models" => open_scoped_models_selector(
1489                &state_sel,
1490                &ec_sel,
1491                &editor_sel,
1492                &tui_sel,
1493                &catalog_sel,
1494                &chat_sel,
1495            ),
1496            _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
1497        }
1498    }));
1499    let state_cancel = state.clone();
1500    let ec_cancel = editor_container.clone();
1501    let editor_cancel = editor.clone();
1502    let tui_cancel = tui.clone();
1503    list.on_cancel(Arc::new(move || {
1504        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1505    }));
1506
1507    open_selector(
1508        state,
1509        editor_container,
1510        editor,
1511        tui,
1512        list,
1513        SelectorKind::Settings,
1514    );
1515}
1516
1517/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
1518fn open_settings_theme_selector(
1519    state: &Arc<TuiState>,
1520    editor_container: &Arc<Container>,
1521    editor: &Arc<Editor>,
1522    tui: &Arc<TuiAltScreen>,
1523    chat: &Arc<Container>,
1524) {
1525    let items = vec![
1526        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
1527        SelectItem::new("light", "Light").with_description("Light background"),
1528        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
1529    ];
1530    let list = Arc::new(SelectList::new(items, 10));
1531
1532    let state_sel = state.clone();
1533    let ec_sel = editor_container.clone();
1534    let editor_sel = editor.clone();
1535    let tui_sel = tui.clone();
1536    let chat_sel = chat.clone();
1537    list.on_select(Arc::new(move |item| {
1538        let preset = match item.value.as_str() {
1539            "light" => ThemePreset::Light,
1540            "monochrome" => ThemePreset::Monochrome,
1541            _ => ThemePreset::Dark,
1542        };
1543        apply_theme_preset(preset);
1544        let mut settings = crate::settings::load_settings().unwrap_or_default();
1545        settings.theme = Some(item.value.clone());
1546        let saved = crate::settings::save_settings(&settings);
1547        add_note_message(
1548            &chat_sel,
1549            &format!(
1550                "Theme set to {} (saved{})",
1551                item.label,
1552                if saved.is_ok() { "" } else { ", not saved" },
1553            ),
1554        );
1555        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1556        tui_sel.render_now(true);
1557    }));
1558    let state_cancel = state.clone();
1559    let ec_cancel = editor_container.clone();
1560    let editor_cancel = editor.clone();
1561    let tui_cancel = tui.clone();
1562    list.on_cancel(Arc::new(move || {
1563        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1564    }));
1565
1566    open_selector(
1567        state,
1568        editor_container,
1569        editor,
1570        tui,
1571        list,
1572        SelectorKind::Settings,
1573    );
1574}
1575
1576/// Choose the default model AND persist it (`/settings` → Default model):
1577/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
1578/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
1579fn open_settings_model_selector(
1580    state: &Arc<TuiState>,
1581    editor_container: &Arc<Container>,
1582    editor: &Arc<Editor>,
1583    tui: &Arc<TuiAltScreen>,
1584    lane: &Arc<dyn AgentLane>,
1585    catalog: &[rpi_ai::Model],
1586    lane_model_id: &str,
1587    chat: &Arc<Container>,
1588) {
1589    let mut items: Vec<SelectItem> = Vec::new();
1590    for m in catalog {
1591        let label = if m.name.is_empty() {
1592            short_model_name(&m.id)
1593        } else {
1594            m.name.clone()
1595        };
1596        let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
1597            " (current)"
1598        } else {
1599            ""
1600        };
1601        items.push(
1602            SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
1603        );
1604    }
1605    if items.is_empty() {
1606        add_note_message(chat, "No models in the catalog.");
1607        tui.request_render(false);
1608        return;
1609    }
1610    let list = Arc::new(SelectList::new(items, 10));
1611
1612    let catalog_arc = catalog.to_vec();
1613    let state_sel = state.clone();
1614    let ec_sel = editor_container.clone();
1615    let editor_sel = editor.clone();
1616    let tui_sel = tui.clone();
1617    let chat_sel = chat.clone();
1618    let lane_sel = lane.clone();
1619    list.on_select(Arc::new(move |item| {
1620        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
1621            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
1622            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1623            return;
1624        };
1625        state_sel.set_current_model(&model);
1626        let lane = lane_sel.clone();
1627        tokio::spawn(async move {
1628            let _ = lane.set_model(model).await;
1629        });
1630        let mut settings = crate::settings::load_settings().unwrap_or_default();
1631        settings.default_model = Some(item.value.clone());
1632        let saved = crate::settings::save_settings(&settings);
1633        add_note_message(
1634            &chat_sel,
1635            &format!(
1636                "Default model set to {} (saved{}",
1637                short_model_name(&item.value),
1638                if saved.is_ok() { ")" } else { ", not saved)" },
1639            ),
1640        );
1641        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1642    }));
1643    let state_cancel = state.clone();
1644    let ec_cancel = editor_container.clone();
1645    let editor_cancel = editor.clone();
1646    let tui_cancel = tui.clone();
1647    list.on_cancel(Arc::new(move || {
1648        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1649    }));
1650
1651    open_selector(
1652        state,
1653        editor_container,
1654        editor,
1655        tui,
1656        list,
1657        SelectorKind::Settings,
1658    );
1659}
1660
1661/// Choose the default thinking level AND persist it (`/settings` → Default
1662/// thinking): applies live via `lane.set_thinking_level` and saves
1663/// `defaultThinkingLevel` to settings.json.
1664fn open_settings_thinking_selector(
1665    state: &Arc<TuiState>,
1666    editor_container: &Arc<Container>,
1667    editor: &Arc<Editor>,
1668    tui: &Arc<TuiAltScreen>,
1669    lane: &Arc<dyn AgentLane>,
1670    catalog: &[rpi_ai::Model],
1671    lane_model_id: &str,
1672    chat: &Arc<Container>,
1673) {
1674    let model = catalog
1675        .iter()
1676        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
1677    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
1678        .map(|m| m.supported_thinking_levels())
1679        .unwrap_or_else(|| {
1680            use rpi_ai::types::ThinkingLevel::*;
1681            vec![Off, Minimal, Low, Medium, High]
1682        });
1683    let mut items: Vec<SelectItem> = Vec::new();
1684    for lvl in &levels {
1685        let name = thinking_level_name(*lvl);
1686        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
1687    }
1688    if items.is_empty() {
1689        add_note_message(chat, "This model has no supported thinking levels.");
1690        tui.request_render(false);
1691        return;
1692    }
1693    let list = Arc::new(SelectList::new(items, 10));
1694
1695    let state_sel = state.clone();
1696    let ec_sel = editor_container.clone();
1697    let editor_sel = editor.clone();
1698    let tui_sel = tui.clone();
1699    let chat_sel = chat.clone();
1700    let lane_sel = lane.clone();
1701    list.on_select(Arc::new(move |item| {
1702        let Some(level) = thinking_level_from_name(&item.value) else {
1703            add_note_message(
1704                &chat_sel,
1705                &format!("Unknown thinking level: {}.", item.label),
1706            );
1707            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1708            return;
1709        };
1710        let lane = lane_sel.clone();
1711        let footer_sel = state_sel.footer.clone();
1712        tokio::spawn(async move {
1713            let _ = lane.set_thinking_level(level).await;
1714        });
1715        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
1716        let mut settings = crate::settings::load_settings().unwrap_or_default();
1717        settings.default_thinking_level = Some(item.value.clone());
1718        let saved = crate::settings::save_settings(&settings);
1719        add_note_message(
1720            &chat_sel,
1721            &format!(
1722                "Default thinking set to {} (saved{}",
1723                item.label,
1724                if saved.is_ok() { ")" } else { ", not saved)" },
1725            ),
1726        );
1727        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
1728    }));
1729    let state_cancel = state.clone();
1730    let ec_cancel = editor_container.clone();
1731    let editor_cancel = editor.clone();
1732    let tui_cancel = tui.clone();
1733    list.on_cancel(Arc::new(move || {
1734        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1735    }));
1736
1737    open_selector(
1738        state,
1739        editor_container,
1740        editor,
1741        tui,
1742        list,
1743        SelectorKind::Settings,
1744    );
1745}
1746
1747/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
1748/// item toggles it in the in-progress set (the selector stays open); Esc saves
1749/// the set to settings.json and closes. The active scoped set is echoed after
1750/// each toggle so the user sees the current selection.
1751fn open_scoped_models_selector(
1752    state: &Arc<TuiState>,
1753    editor_container: &Arc<Container>,
1754    editor: &Arc<Editor>,
1755    tui: &Arc<TuiAltScreen>,
1756    catalog: &[rpi_ai::Model],
1757    chat: &Arc<Container>,
1758) {
1759    if catalog.is_empty() {
1760        add_note_message(chat, "No models in the catalog.");
1761        tui.request_render(false);
1762        return;
1763    }
1764    // Seed the edit set from the saved scoped models.
1765    let seed: Vec<String> = crate::settings::load_settings()
1766        .ok()
1767        .and_then(|s| s.scoped_models)
1768        .unwrap_or_default();
1769    *state.scoped_edit.lock().unwrap() = Some(seed);
1770
1771    let mut items: Vec<SelectItem> = Vec::new();
1772    for m in catalog {
1773        items.push(SelectItem::new(&m.id, &m.id));
1774    }
1775    let list = Arc::new(SelectList::new(items, 10));
1776
1777    let state_sel = state.clone();
1778    let chat_sel = chat.clone();
1779    let tui_sel = tui.clone();
1780    list.on_select(Arc::new(move |item| {
1781        // Toggle the model in the in-progress set; the selector stays open.
1782        let mut set = state_sel.scoped_edit.lock().unwrap();
1783        let set = set.get_or_insert_with(Vec::new);
1784        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
1785            set.remove(pos);
1786            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
1787        } else {
1788            set.push(item.value.clone());
1789            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
1790        }
1791        tui_sel.request_render(false);
1792    }));
1793    let state_cancel = state.clone();
1794    let ec_cancel = editor_container.clone();
1795    let editor_cancel = editor.clone();
1796    let tui_cancel = tui.clone();
1797    let chat_cancel = chat.clone();
1798    list.on_cancel(Arc::new(move || {
1799        // Save the edited set to settings.json and close.
1800        let set = state_cancel
1801            .scoped_edit
1802            .lock()
1803            .unwrap()
1804            .take()
1805            .unwrap_or_default();
1806        let mut settings = crate::settings::load_settings().unwrap_or_default();
1807        settings.scoped_models = if set.is_empty() {
1808            None
1809        } else {
1810            Some(set.clone())
1811        };
1812        match crate::settings::save_settings(&settings) {
1813            Ok(()) => {
1814                if set.is_empty() {
1815                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
1816                } else {
1817                    add_note_message(
1818                        &chat_cancel,
1819                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
1820                    );
1821                }
1822            }
1823            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
1824        }
1825        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1826    }));
1827
1828    open_selector(
1829        state,
1830        editor_container,
1831        editor,
1832        tui,
1833        list,
1834        SelectorKind::ScopedModels,
1835    );
1836}
1837
1838/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
1839/// PATH, create a gist of the exported markdown; otherwise fall back to the
1840/// clipboard (best-effort) and note the local path.
1841async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
1842    use std::process::Stdio;
1843
1844    // Reuse the export builder for the transcript text.
1845    let tree = harness.session().view("main");
1846    let entries = match tree
1847        .find_entries(&EntryQuery {
1848            entry_type: None,
1849            custom_type: None,
1850            // Exports append entries top-to-bottom, so use chronological order
1851            // instead of the session query default (newest-first).
1852            order: Some(EntryOrder::OldestFirst),
1853            limit: None,
1854            cursor: None,
1855        })
1856        .await
1857    {
1858        Ok(e) => e,
1859        Err(e) => {
1860            add_error_message(chat, &format!("Could not read session: {e}"));
1861            return;
1862        }
1863    };
1864    let mut md = String::from("# Session\n\n");
1865    for e in entries {
1866        let Entry::Message(me) = e else { continue };
1867        match &me.message {
1868            AgentMessage::User(u) => {
1869                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1870            }
1871            AgentMessage::Assistant(a) => {
1872                let text = assistant_text(a);
1873                if !text.is_empty() {
1874                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1875                }
1876            }
1877            _ => {}
1878        }
1879    }
1880
1881    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
1882    let gh = std::process::Command::new("gh")
1883        .arg("gist")
1884        .arg("create")
1885        .arg("--filename")
1886        .arg("session.md")
1887        .arg("-")
1888        .stdin(Stdio::piped())
1889        .stdout(Stdio::piped())
1890        .stderr(Stdio::null())
1891        .spawn();
1892    if let Ok(mut child) = gh {
1893        use std::io::Write;
1894        if let Some(mut stdin) = child.stdin.take() {
1895            let _ = stdin.write_all(md.as_bytes());
1896            let _ = stdin.flush();
1897        }
1898        let out = child.wait_with_output().ok();
1899        if let Some(out) = out {
1900            if out.status.success() {
1901                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
1902                add_note_message(chat, &format!("Shared session: {url}"));
1903                return;
1904            }
1905        }
1906        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
1907    } else {
1908        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
1909    }
1910    // Clipboard fallback (or transcript echo when the clipboard feature is off).
1911    if copy_to_clipboard(&md) {
1912        add_note_message(chat, "Session transcript copied to the clipboard.");
1913    } else {
1914        add_note_message(
1915            chat,
1916            "Clipboard unavailable — use /export to write the transcript to a file.",
1917        );
1918    }
1919}
1920
1921/// Export the current session to a markdown transcript file. Writes
1922/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
1923/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
1924/// Best-effort: failures surface as a chat note.
1925/// Export the current session to a markdown transcript file. Writes
1926/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
1927/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
1928/// Best-effort: failures surface as a chat note.
1929async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
1930    let tree = harness.session().view("main");
1931    let entries = match tree
1932        .find_entries(&EntryQuery {
1933            entry_type: None,
1934            custom_type: None,
1935            // Keep exported entries in the same chronological order shown in
1936            // the transcript; the storage default is newest-first.
1937            order: Some(EntryOrder::OldestFirst),
1938            limit: None,
1939            cursor: None,
1940        })
1941        .await
1942    {
1943        Ok(e) => e,
1944        Err(e) => {
1945            add_error_message(chat, &format!("Could not read session: {e}"));
1946            return;
1947        }
1948    };
1949    let name = tree.get_name().await.ok().flatten().unwrap_or_default();
1950    let id = tree
1951        .get_leaf_id()
1952        .await
1953        .ok()
1954        .flatten()
1955        .unwrap_or_else(|| "session".to_string());
1956    let mut md = String::from("# Session\n\n");
1957    for e in entries {
1958        let Entry::Message(me) = e else { continue };
1959        match &me.message {
1960            AgentMessage::User(u) => {
1961                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1962            }
1963            AgentMessage::Assistant(a) => {
1964                let text = assistant_text(a);
1965                if !text.is_empty() {
1966                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1967                }
1968            }
1969            _ => {}
1970        }
1971    }
1972    let file_name = if name.is_empty() {
1973        format!("{id}.md")
1974    } else {
1975        format!("{name}.md")
1976    };
1977    let path = std::env::current_dir()
1978        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1979        .join(&file_name);
1980    match std::fs::write(&path, md) {
1981        Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
1982        Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
1983    }
1984}
1985
1986/// Fork the current session into a new JSONL session and switch to it (TS
1987/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
1988/// session the user continues in). Uses the repo's `fork_typed`, then swaps
1989/// the harness backing and renders the (empty-ish) fork transcript.
1990/// Hot-switch the harness to another saved session: abort any in-flight run,
1991/// open the target session file, swap the durable backing, and re-render the
1992/// transcript from the new history (mirrors pi's `/session` resume-in-place).
1993/// Shared by the `/session` selector, `/import`, and `/fork`. The current
1994/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
1995async fn switch_to_session(
1996    harness: &AgentHarness,
1997    lane: &Arc<dyn AgentLane>,
1998    id: &str,
1999    cwd: &std::path::Path,
2000    chat: &Arc<Container>,
2001    state: &Arc<TuiState>,
2002) -> bool {
2003    if *state.status.lock().unwrap() == RunStatus::Working {
2004        state.set_status(RunStatus::Aborting);
2005        let _ = lane.abort().await;
2006    }
2007    let cwd_str = cwd.to_string_lossy().to_string();
2008    match crate::session::open_session_by_id(id, &cwd_str).await {
2009        Ok(new_session) => {
2010            let _ = harness.set_session(new_session).await;
2011            chat.clear();
2012            add_welcome_message(chat);
2013            render_session_history(
2014                harness,
2015                chat,
2016                state.markdown_transformer(),
2017                Some(state.extension_session.clone()),
2018            )
2019            .await;
2020            state.set_status(RunStatus::Idle);
2021            add_note_message(chat, &format!("Switched to session {id}."));
2022            true
2023        }
2024        Err(e) => {
2025            state.set_status(RunStatus::Idle);
2026            add_error_message(chat, &format!("Could not open session {id}: {e}"));
2027            false
2028        }
2029    }
2030}
2031
2032/// `/import <path>`: copy a JSONL session file into the default session dir,
2033/// then hot-switch to it (the file name becomes its id — matching the
2034/// selector/`open_session_by_id` containment rules).
2035async fn import_session(
2036    harness: &AgentHarness,
2037    lane: &Arc<dyn AgentLane>,
2038    path: &str,
2039    cwd: &std::path::Path,
2040    chat: &Arc<Container>,
2041    state: &Arc<TuiState>,
2042) {
2043    use std::path::Path as FsPath;
2044
2045    let src = FsPath::new(path);
2046    if !src.is_file() {
2047        add_error_message(chat, &format!("Import source not found: {path}"));
2048        return;
2049    }
2050    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
2051        add_error_message(chat, "Import source has no file name.");
2052        return;
2053    };
2054    if !fname.ends_with(".jsonl") {
2055        add_error_message(chat, "Import source must be a .jsonl session file.");
2056        return;
2057    }
2058    let dir = crate::session::default_session_dir(cwd);
2059    if let Err(e) = std::fs::create_dir_all(&dir) {
2060        add_error_message(chat, &format!("Could not create session dir: {e}"));
2061        return;
2062    }
2063    let dest = dir.join(fname);
2064    match std::fs::copy(src, &dest) {
2065        Ok(_) => {
2066            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
2067            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
2068                add_note_message(chat, &format!("Imported session from {path}"));
2069            }
2070        }
2071        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
2072    }
2073}
2074
2075async fn fork_session(
2076    harness: &AgentHarness,
2077    cwd: &std::path::Path,
2078    chat: &Arc<Container>,
2079    state: &Arc<TuiState>,
2080) {
2081    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2082    use rpi_tools::FileSystem;
2083
2084    let cwd_str = cwd.to_string_lossy().to_string();
2085    let dir = crate::session::default_session_dir(cwd);
2086    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
2087    let fs: Arc<dyn FileSystem> = env.clone();
2088    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2089        fs,
2090        sessions_root: dir.to_string_lossy().into_owned(),
2091        clock: Arc::new(rpi_harness::session::memory::SystemClock),
2092        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
2093    });
2094    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2095    // it from the session list by the current session's id.
2096    let id = harness.session().storage().metadata().id.clone();
2097    let metas = match crate::session::list_session_metadata(&cwd_str).await {
2098        Ok(m) => m,
2099        Err(e) => {
2100            add_error_message(chat, &format!("Could not list sessions: {e}"));
2101            return;
2102        }
2103    };
2104    let Some(source) = metas.iter().find(|m| m.id == id) else {
2105        add_error_message(chat, &format!("Current session {id} not found on disk."));
2106        return;
2107    };
2108    let fork_storage = match repo
2109        .fork_typed(
2110            source,
2111            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2112                id: None,
2113                parent_session_id: Some(source.id.clone()),
2114                cwd: cwd_str.clone(),
2115                metadata: None,
2116            },
2117            &rpi_harness::session::types::ForkOptions::default(),
2118        )
2119        .await
2120    {
2121        Ok(s) => s,
2122        Err(e) => {
2123            add_error_message(chat, &format!("Could not fork session: {e}"));
2124            return;
2125        }
2126    };
2127    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
2128    let _ = harness.set_session(new_session).await;
2129    chat.clear();
2130    add_welcome_message(chat);
2131    render_session_history(
2132        harness,
2133        chat,
2134        state.markdown_transformer(),
2135        Some(state.extension_session.clone()),
2136    )
2137    .await;
2138    state.set_status(RunStatus::Idle);
2139    add_note_message(chat, "Forked into a new session.");
2140}
2141
2142/// Render the restored session's prior transcript (user + assistant messages)
2143/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
2144/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
2145/// any session read failure just starts with an empty transcript.
2146///
2147/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
2148/// the identity path. Each restored assistant component installs it so replayed
2149/// history renders through the same `register_markdown_transformer` handlers
2150/// the live stream does.
2151async fn render_session_history(
2152    harness: &AgentHarness,
2153    chat: &Arc<Container>,
2154    transformer: Option<MarkdownTransformer>,
2155    extension_session: Option<crate::session::ExtensionSessionCell>,
2156) {
2157    let tree = harness.session().view("main");
2158    let entries = match tree
2159        .find_entries(&EntryQuery {
2160            entry_type: None,
2161            custom_type: None,
2162            // Session queries default to newest-first for selectors and
2163            // pagination. The transcript appends children top-to-bottom, so
2164            // restored history must explicitly be chronological.
2165            order: Some(EntryOrder::OldestFirst),
2166            limit: None,
2167            cursor: None,
2168        })
2169        .await
2170    {
2171        Ok(e) => e,
2172        Err(_) => return,
2173    };
2174    let mut rendered_any = false;
2175    for e in entries {
2176        match e {
2177            Entry::Message(me) => match &me.message {
2178                AgentMessage::User(u) => {
2179                    add_user_message(chat, &user_message_text(u));
2180                    rendered_any = true;
2181                }
2182                AgentMessage::Assistant(a) => {
2183                    let comp = Arc::new(AssistantMessageComponent::new(
2184                        AssistantMessageOptions::default(),
2185                    ));
2186                    if let Some(t) = &transformer {
2187                        comp.set_markdown_transformer(Some(t.clone()));
2188                    }
2189                    comp.update_blocks(&assistant_blocks(a));
2190                    chat.add_child(comp);
2191                    // Single trailing spacer: the next transcript entry (user or
2192                    // assistant) follows one blank line below.
2193                    chat.add_child(Arc::new(Spacer::new(1)));
2194                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
2195                        add_note_message(chat, &text);
2196                    }
2197                    rendered_any = true;
2198                }
2199                AgentMessage::Custom(custom) => {
2200                    if let Some(session) = &extension_session {
2201                        if let Some(component) = extension_message_component(
2202                            session,
2203                            &custom.role,
2204                            &serde_json::json!({
2205                                "customType": custom.role,
2206                                "content": custom.content,
2207                                "details": custom.data,
2208                            }),
2209                            transformer.clone(),
2210                        ) {
2211                            chat.add_child(component);
2212                            chat.add_child(Arc::new(Spacer::new(1)));
2213                            rendered_any = true;
2214                            continue;
2215                        }
2216                    }
2217                    add_note_message(chat, &custom_message_fallback(&custom));
2218                    rendered_any = true;
2219                }
2220                _ => {}
2221            },
2222            Entry::Compaction(compaction) => {
2223                add_note_message(
2224                    chat,
2225                    &format!(
2226                        "Compacted {} tokens: {}",
2227                        compaction.tokens_before, compaction.summary
2228                    ),
2229                );
2230                rendered_any = true;
2231            }
2232            Entry::BranchSummary(summary) => {
2233                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
2234                rendered_any = true;
2235            }
2236            Entry::Custom(custom) => {
2237                let rendered = extension_session.as_ref().and_then(|session| {
2238                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
2239                });
2240                if let Some(component) = rendered {
2241                    chat.add_child(component);
2242                    chat.add_child(Arc::new(Spacer::new(1)));
2243                    rendered_any = true;
2244                } else if let Some(text) =
2245                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
2246                {
2247                    add_note_message(chat, &text);
2248                    rendered_any = true;
2249                }
2250            }
2251            Entry::ModelChange(change) => {
2252                add_note_message(
2253                    chat,
2254                    &format!("Model changed to {}:{}", change.provider, change.model_id),
2255                );
2256                rendered_any = true;
2257            }
2258            Entry::ThinkingLevel(change) => {
2259                add_note_message(
2260                    chat,
2261                    &format!("Thinking level: {:?}", change.thinking_level),
2262                );
2263                rendered_any = true;
2264            }
2265            Entry::ActiveTools(change) => {
2266                add_note_message(
2267                    chat,
2268                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
2269                );
2270                rendered_any = true;
2271            }
2272        }
2273    }
2274    if rendered_any {
2275        // No trailing spacer here — each entry already adds its own trailing
2276        // Spacer(1), so an extra would double the bottom gap.
2277    }
2278}
2279
2280fn invoke_extension_renderer(
2281    session: &crate::session::ExtensionSessionCell,
2282    kind: rpi_extensions::RegisteredRendererKind,
2283    payload: &serde_json::Value,
2284) -> Option<serde_json::Value> {
2285    let snapshot = session.lock().ok()?.snapshot_arc()?;
2286    let input = serde_json::to_string(payload).ok()?;
2287    for renderer in snapshot.renderers_of(kind) {
2288        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2289            let mut out = rpi_plugin_sdk::StbString::empty();
2290            let rc = (renderer.render_fn)(
2291                rpi_plugin_sdk::StbStringRef::from_str(&input),
2292                &mut out as *mut rpi_plugin_sdk::StbString,
2293                renderer.user_data,
2294            );
2295            let text = if rc == 0 {
2296                Some(out.to_string_lossy())
2297            } else {
2298                None
2299            };
2300            out.free_with(Some(renderer.plugin_free_string));
2301            text
2302        }))
2303        .ok()
2304        .flatten();
2305        let Some(text) = outcome else { continue };
2306        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2307            return Some(value);
2308        }
2309    }
2310    None
2311}
2312
2313fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
2314    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
2315        let text = lines
2316            .iter()
2317            .filter_map(|line| line.as_str())
2318            .collect::<Vec<_>>()
2319            .join("\n");
2320        return Some(Arc::new(Text::new(text, 0, 0)));
2321    }
2322    let text = value.get("text").and_then(|v| v.as_str())?;
2323    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2324        let component = Arc::new(AssistantMessageComponent::new(
2325            AssistantMessageOptions::default(),
2326        ));
2327        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2328        Some(component)
2329    } else {
2330        Some(Arc::new(Text::new(text, 0, 0)))
2331    }
2332}
2333
2334fn extension_message_component(
2335    session: &crate::session::ExtensionSessionCell,
2336    custom_type: &str,
2337    payload: &serde_json::Value,
2338    transformer: Option<MarkdownTransformer>,
2339) -> Option<Arc<dyn rpi_tui::Component>> {
2340    let value = invoke_extension_renderer(
2341        session,
2342        rpi_extensions::RegisteredRendererKind::Message,
2343        payload,
2344    )?;
2345    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2346        let text = value.get("text").and_then(|v| v.as_str())?;
2347        let component = Arc::new(AssistantMessageComponent::new(
2348            AssistantMessageOptions::default(),
2349        ));
2350        if let Some(transformer) = transformer {
2351            component.set_markdown_transformer(Some(transformer));
2352        }
2353        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2354        return Some(component);
2355    }
2356    extension_text_component(&value)
2357        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
2358}
2359
2360/// Render usage from a completed assistant message through the registered
2361/// message renderers. Hosts without a token-usage renderer return `None`.
2362fn extension_usage_text(
2363    session: Option<&crate::session::ExtensionSessionCell>,
2364    usage: &rpi_ai::types::Usage,
2365) -> Option<String> {
2366    let session = session?;
2367    let payload = serde_json::json!({
2368        "customType": "token-usage",
2369        "usage": usage,
2370    });
2371    let value = invoke_extension_renderer(
2372        session,
2373        rpi_extensions::RegisteredRendererKind::Message,
2374        &payload,
2375    )?;
2376    value
2377        .get("text")
2378        .and_then(|value| value.as_str())
2379        .filter(|text| !text.trim().is_empty())
2380        .map(ToOwned::to_owned)
2381}
2382
2383fn extension_entry_component(
2384    session: &crate::session::ExtensionSessionCell,
2385    custom_type: &str,
2386    data: Option<serde_json::Value>,
2387) -> Option<Arc<dyn rpi_tui::Component>> {
2388    let payload = serde_json::json!({
2389        "customType": custom_type,
2390        "data": data,
2391    });
2392    let value = invoke_extension_renderer(
2393        session,
2394        rpi_extensions::RegisteredRendererKind::Entry,
2395        &payload,
2396    )?;
2397    extension_text_component(&value)
2398}
2399
2400/// Project an assistant message's content into the provider-free
2401/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
2402/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
2403/// are rendered by their own components in the transcript.
2404/// Whether startup intentionally opened a session that already has history.
2405fn launch_restores_history(args: &Args) -> bool {
2406    args.continue_session
2407        || args.resume
2408        || args.session.is_some()
2409        || args.session_id.is_some()
2410        || args.fork.is_some()
2411}
2412
2413fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
2414    msg.content
2415        .iter()
2416        .filter_map(|c| match c {
2417            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
2418            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
2419            Content::Image(image) => base64::engine::general_purpose::STANDARD
2420                .decode(&image.data)
2421                .ok()
2422                .filter(|data| !data.is_empty())
2423                .map(AssistantBlock::Image),
2424            _ => None,
2425        })
2426        .collect()
2427}
2428
2429fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
2430    let content = custom
2431        .content
2432        .iter()
2433        .filter_map(|item| match item {
2434            Content::Text(text) => Some(text.text.as_str()),
2435            _ => None,
2436        })
2437        .collect::<Vec<_>>()
2438        .join("\n");
2439    if content.is_empty() {
2440        format!("{}: {}", custom.role, custom.data)
2441    } else {
2442        format!("{}: {}", custom.role, content)
2443    }
2444}
2445
2446/// The name displayed for a model id (last path segment / after the final
2447/// `:`), to keep the footer compact.
2448fn short_model_name(id: &str) -> String {
2449    id.rsplit([':', '/'])
2450        .next()
2451        .filter(|s| !s.is_empty())
2452        .unwrap_or(id)
2453        .to_string()
2454}
2455
2456// ===========================================================================
2457// Streaming run status
2458// ===========================================================================
2459
2460/// The live status of the agent run, fed to the footer + status slot.
2461#[derive(Clone, Copy, PartialEq, Eq)]
2462enum RunStatus {
2463    Idle,
2464    Working,
2465    Aborting,
2466}
2467
2468/// Which selector overlay (if any) is currently swapped into the editor slot.
2469#[derive(Clone, Copy, PartialEq, Eq)]
2470enum SelectorKind {
2471    /// `/model` — available models (live switch via `lane.set_model`).
2472    Model,
2473    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
2474    Thinking,
2475    /// `/tools` — toggle builtin tools on/off.
2476    Tools,
2477    /// `/images` — toggle inline image rendering.
2478    Images,
2479    /// `/session` — browse and switch saved JSONL sessions.
2480    Session,
2481    /// `/theme` — dark / light / monochrome presets applied live.
2482    Theme,
2483    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
2484    ScopedModels,
2485    /// `/settings` — interactive settings menu (and its sub-selectors).
2486    Settings,
2487    /// `/tree` — navigate to an existing entry in the current session.
2488    Tree,
2489    /// Extension-provided selector; uses the same keyboard contract.
2490    Extension,
2491}
2492
2493/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
2494/// and the render-tick task.
2495struct TuiState {
2496    /// The in-flight streaming assistant message (cleared on finalize).
2497    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
2498    /// Tool-execution components keyed by `tool_call_id`.
2499    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
2500    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
2501    /// generic tool map so bash output streams into a `BashExecutionComponent`
2502    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
2503    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
2504    /// The most recently created tool component (bash or generic). Ctrl+T
2505    /// toggles `expanded` on this — a pragmatic "expand last tool" since the
2506    /// key loop has no per-line focus. Updated on every tool/bash Start.
2507    last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
2508    /// Run status for the status indicator + interrupt routing.
2509    status: std::sync::Mutex<RunStatus>,
2510    /// The footer, updated live by the drain task.
2511    footer: Arc<FooterComponent>,
2512    /// The status-container (status slot in the dock) — cleared/filled with a
2513    /// loader while a run is active.
2514    status_container: Arc<Container>,
2515    /// The chat transcript container.
2516    chat_container: Arc<Container>,
2517    /// The active loader shown while `Working`.
2518    loader: Arc<Loader>,
2519    /// The last finalized assistant text (for `/copy`). Updated by the drain
2520    /// task on `MessageEnd` / `AgentEnd`.
2521    last_assistant_text: std::sync::Mutex<String>,
2522    /// The active selector overlay, swapped into the editor slot. `Some` while
2523    /// a selector is open; the key loop routes to it first and restores the
2524    /// editor on done/cancel.
2525    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
2526    /// Extension-provided editor currently occupying the input slot.
2527    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
2528    /// The autocomplete manager (slash + @file providers) consulted on every
2529    /// editor keystroke.
2530    autocomplete: AutocompleteManager,
2531    /// The container rendered above the editor holding the live autocomplete
2532    /// suggestion list (cleared when there are no suggestions).
2533    autocomplete_container: Arc<Container>,
2534    /// The owned theme manager — `/theme` applies presets here. The global
2535    /// `theme()` is read-only after OnceLock init, so per-instance state is the
2536    /// only way to apply a preset at runtime.
2537    theme_manager: Arc<ThemeManager>,
2538    /// The alt-screen handle, held so `set_status` can reflect run state in the
2539    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
2540    /// that never call `set_status` with a title.
2541    tui: Option<Arc<TuiAltScreen>>,
2542    /// The model id currently shown in the footer + used as the Ctrl+M
2543    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
2544    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
2545    current_model_id: std::sync::Mutex<String>,
2546    /// Whether inline image rendering is enabled (`/images` toggle). Stored
2547    /// even though image wiring is minimal this pass — the flag is consulted
2548    /// where images would be shown and echoed back by `/images`.
2549    show_images: std::sync::Mutex<bool>,
2550    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
2551    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
2552    history: std::sync::Mutex<Vec<String>>,
2553    /// Browse index while recalling history: -1 = not browsing, 0 = most
2554    /// recent, 1 = older, … Reset to -1 on every submit.
2555    history_index: std::sync::Mutex<isize>,
2556    /// The editor text captured when entering browse mode, restored when the
2557    /// user navigates back past the newest entry (TS `historyDraft`).
2558    history_draft: std::sync::Mutex<Option<String>>,
2559    /// The previous turn's input token count, used by the cache-miss notice:
2560    /// a large input that reads nothing from cache after an established prefix
2561    /// means the prefix was re-billed (simplified `maybeShowCacheMissNotice`).
2562    last_input_tokens: std::sync::Mutex<i64>,
2563    /// The in-progress scoped-models selection while the `/scoped-models`
2564    /// selector is open (toggle per item, Esc saves). `None` when not editing.
2565    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
2566    /// B5e: the live assistant-markdown transformer, built from the current
2567    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
2568    /// when no markdown transformers are registered (identity render path).
2569    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
2570    /// closure no-ops once its snapshot's `active` flag flips false) and
2571    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
2572    /// transform takes effect on the visible streaming message immediately.
2573    /// New assistant components pick up whatever closure is current at
2574    /// construction time via [`install_markdown_transformer`].
2575    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
2576    /// Live extension registry used by message/entry renderer dispatch.
2577    extension_session: crate::session::ExtensionSessionCell,
2578}
2579
2580/// How many submitted messages are kept for ↑ recall (mirrors the TS
2581/// editor's 100-entry cap).
2582const HISTORY_LIMIT: usize = 100;
2583
2584/// A turn with at least this many input tokens is worth a cache-miss notice
2585/// when nothing was read from cache (matches the TS 20k threshold).
2586const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
2587
2588/// Keep a few rows of overlap so page scrolling preserves visual context,
2589/// matching the upstream fullscreen viewport behavior.
2590const PAGE_SCROLL_OVERLAP: usize = 4;
2591
2592/// Native pi scrolls a small chunk for each wheel notch rather than moving the
2593/// transcript one physical row at a time. Three lines stays precise while
2594/// avoiding the sluggish feel of the previous implementation.
2595const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
2596
2597fn transcript_page_size(viewport_height: usize) -> i32 {
2598    viewport_height
2599        .saturating_sub(PAGE_SCROLL_OVERLAP)
2600        .max(1)
2601        .min(i32::MAX as usize) as i32
2602}
2603
2604fn should_dispatch_key(kind: KeyEventKind) -> bool {
2605    kind != KeyEventKind::Release
2606}
2607
2608/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
2609fn format_tokens(n: i64) -> String {
2610    if n >= 1_000_000 {
2611        format!("{:.1}M", n as f64 / 1_000_000.0)
2612    } else if n >= 1_000 {
2613        format!("{:.1}K", n as f64 / 1_000.0)
2614    } else {
2615        n.to_string()
2616    }
2617}
2618
2619/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
2620/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
2621/// resets the browse state so a fresh prompt never resumes mid-history.
2622fn push_history(state: &Arc<TuiState>, text: &str) {
2623    let trimmed = text.trim().to_string();
2624    if trimmed.is_empty() {
2625        return;
2626    }
2627    let mut history = state.history.lock().unwrap();
2628    if history.first() == Some(&trimmed) {
2629        return;
2630    }
2631    history.insert(0, trimmed);
2632    history.truncate(HISTORY_LIMIT);
2633    *state.history_index.lock().unwrap() = -1;
2634    *state.history_draft.lock().unwrap() = None;
2635}
2636
2637/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
2638/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
2639/// current editor text as the draft; navigating back past the newest entry
2640/// restores that draft.
2641fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
2642    let history = state.history.lock().unwrap();
2643    if history.is_empty() {
2644        return;
2645    }
2646    let mut index = state.history_index.lock().unwrap();
2647    let new_index = *index - direction as isize;
2648    if new_index < -1 || new_index >= history.len() as isize {
2649        return;
2650    }
2651    if *index == -1 && new_index >= 0 {
2652        // Entering browse mode: stash the current input.
2653        *state.history_draft.lock().unwrap() = Some(editor.get_text());
2654    }
2655    *index = new_index;
2656    if new_index == -1 {
2657        // Exited browse mode: restore the draft (or clear if there was none).
2658        let draft = state.history_draft.lock().unwrap().take();
2659        match draft {
2660            Some(d) => {
2661                let len = d.len();
2662                editor.set_text(&d);
2663                editor.set_cursor(0, len);
2664            }
2665            None => editor.set_text(""),
2666        }
2667    } else {
2668        let text = history[new_index as usize].clone();
2669        let len = text.len();
2670        editor.set_text(&text);
2671        editor.set_cursor(0, len);
2672    }
2673}
2674
2675impl TuiState {
2676    fn set_status(&self, status: RunStatus) {
2677        *self.status.lock().unwrap() = status;
2678        self.apply_status(status);
2679    }
2680
2681    /// Atomically reserve the single interactive run slot. The editor callback
2682    /// runs on a different thread from the async prompt loop, so checking and
2683    /// setting in separate steps would allow rapid Enter presses to queue more
2684    /// than one operation.
2685    fn try_start_working(&self) -> bool {
2686        let mut status = self.status.lock().unwrap();
2687        if *status != RunStatus::Idle {
2688            return false;
2689        }
2690        *status = RunStatus::Working;
2691        drop(status);
2692        self.apply_status(RunStatus::Working);
2693        true
2694    }
2695
2696    fn apply_status(&self, status: RunStatus) {
2697        match status {
2698            RunStatus::Working => {
2699                self.footer.set_status("Working…");
2700                // Reflect the in-flight turn in the terminal window/tab title
2701                // (OSC 2). No-op when `tui` is absent (unit tests).
2702                if let Some(tui) = &self.tui {
2703                    tui.set_title("rpi — working");
2704                }
2705                self.status_container.clear();
2706                self.loader.start();
2707                self.status_container.add_child(self.loader.clone());
2708            }
2709            RunStatus::Aborting => {
2710                self.footer.set_status("Aborting…");
2711                // Do not leave a frozen "Working" spinner on screen after the
2712                // render tick intentionally stops advancing in this state.
2713                self.loader.stop();
2714                self.status_container.clear();
2715            }
2716            RunStatus::Idle => {
2717                self.footer.set_status("");
2718                if let Some(tui) = &self.tui {
2719                    tui.set_title("rpi");
2720                }
2721                self.loader.stop();
2722                self.status_container.clear();
2723            }
2724        }
2725    }
2726
2727    /// The bash panel has its own `Running...` spinner. Keep the global
2728    /// `Working...` loader out of the status slot while any bash tool is active
2729    /// so the same operation is not presented as two simultaneous loaders.
2730    fn sync_working_loader_with_bash(&self) {
2731        if *self.status.lock().unwrap() != RunStatus::Working {
2732            return;
2733        }
2734
2735        self.status_container.clear();
2736        if self.bash_components.lock().unwrap().is_empty() {
2737            self.status_container.add_child(self.loader.clone());
2738        }
2739    }
2740
2741    /// Whether a selector overlay is currently open (routes keys to it first).
2742    fn selector_open(&self) -> bool {
2743        self.active_selector.lock().unwrap().is_some()
2744    }
2745
2746    fn extension_editor_open(&self) -> bool {
2747        self.active_extension_editor.lock().unwrap().is_some()
2748    }
2749
2750    /// Record a freshly created tool component as the "most recent" so Ctrl+T
2751    /// can toggle its expansion. Idempotent overwrites — only the latest lives.
2752    fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
2753        *self.last_tool_comp.lock().unwrap() = Some(comp);
2754    }
2755
2756    /// Toggle `expanded` on the most recent tool component (Ctrl+T). Returns
2757    /// `true` if a component was toggled. Limitation: the key loop tracks no
2758    /// per-line focus, so this always targets the *last* tool shown — not the
2759    /// one under the cursor. Documented in the plan; a focused expansion would
2760    /// need mouse/line hit-testing which is out of scope this pass.
2761    fn toggle_expand_last_tool(&self) -> bool {
2762        if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
2763            let cur = comp.is_expanded();
2764            comp.set_expanded(!cur);
2765            true
2766        } else {
2767            false
2768        }
2769    }
2770
2771    /// The model id currently tracked as active (footer + Ctrl+M anchor).
2772    fn current_model_id(&self) -> String {
2773        self.current_model_id.lock().unwrap().clone()
2774    }
2775
2776    /// Update the tracked model id + footer label after a switch (live or
2777    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
2778    fn set_current_model(&self, model: &rpi_ai::Model) {
2779        *self.current_model_id.lock().unwrap() = model.id.clone();
2780        self.footer.set_model(&short_model_name(&model.id));
2781    }
2782
2783    /// B5e: read a clone of the current assistant-markdown transformer (if any).
2784    /// New assistant components call this at construction so they render with
2785    /// whatever plugin `register_markdown_transformer` handlers are live.
2786    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
2787        self.markdown_transformer.lock().unwrap().clone()
2788    }
2789
2790    /// B5e: swap the live transformer. Used at startup (install the first
2791    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
2792    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
2793    /// transform should take effect on the VISIBLE streaming message too, so
2794    /// this re-installs on the in-flight `current_assistant` component — its
2795    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
2796    /// `None` clears the transform (identity), e.g. a reload that unregisters
2797    /// every markdown transformer.
2798    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
2799        *self.markdown_transformer.lock().unwrap() = transformer.clone();
2800        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
2801            comp.set_markdown_transformer(transformer);
2802        }
2803    }
2804}
2805
2806// ===========================================================================
2807// interactive_tui — the entry point
2808// ===========================================================================
2809
2810/// TUI-based interactive mode.
2811///
2812/// `event_rx` carries the live `AgentEvent` stream (installed by
2813/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
2814/// fn), it falls back to a blocking, await-final-text path.
2815///
2816/// `model_catalog` is the read-only catalog the `/model` selector displays.
2817///
2818/// This implementation mirrors the TypeScript `InteractiveMode` class:
2819/// build the layout root once, drain `AgentEvent`s into UI mutations that
2820/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
2821/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
2822/// autocomplete are layered on via the editor-container swap pattern.
2823pub async fn interactive_tui(
2824    harness: &AgentHarness,
2825    event_rx: Option<broadcast::Receiver<AgentEvent>>,
2826    args: &Args,
2827    model_catalog: Vec<rpi_ai::Model>,
2828    initial: Option<String>,
2829    extra_messages: &[String],
2830    theme: Option<&str>,
2831    reload_context: &crate::session::ReloadContext,
2832) -> i32 {
2833    let lane: Arc<dyn AgentLane> = harness.lane("main");
2834
2835    // Resolve the active model once, up front. The full id feeds the TuiState
2836    // tracking field + the selectors/key loop (which run on a blocking thread
2837    // and can't await `lane.get_model()`); the short name feeds the footer.
2838    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
2839    let model_name = short_model_name(&lane_model_id);
2840
2841    // Snapshot startup capabilities for the welcome screen. Both accessors
2842    // return defensive clones, so rendering this summary does not retain a
2843    // harness lock or trigger a second resource scan.
2844    let active_tool_names = lane.get_active_tools().await.unwrap_or_default();
2845    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
2846    let skill_names: Vec<String> = resources_snapshot
2847        .skills
2848        .as_deref()
2849        .unwrap_or(&[])
2850        .iter()
2851        .map(|skill| skill.name.clone())
2852        .collect();
2853
2854    // The cwd for @file autocomplete + session discovery.
2855    let cwd = std::env::current_dir()
2856        .map(|p| p.to_path_buf())
2857        .unwrap_or_else(|_| std::path::PathBuf::from("."));
2858
2859    // Channel between the key/callback threads and the main async loop.
2860    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
2861
2862    // Apply the saved theme before constructing transcript components. Some
2863    // components keep styled text, so doing this after the welcome banner left
2864    // the first screen in the dark palette until it was rebuilt.
2865    if let Some(preset) = match theme {
2866        Some("light") => Some(ThemePreset::Light),
2867        Some("monochrome") => Some(ThemePreset::Monochrome),
2868        Some("dark") => Some(ThemePreset::Dark),
2869        _ => None,
2870    } {
2871        apply_theme_preset(preset);
2872    }
2873
2874    // ---- TUI + containers ----
2875    let terminal = Box::new(ProcessTerminal::new());
2876    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
2877
2878    let chat_container = Arc::new(Container::new());
2879    add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
2880
2881    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
2882    // banner + the earendil announcement once, then write the sentinel. The TS
2883    // original is a multi-step dialog (theme picker + analytics opt-in); this
2884    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
2885    // analytics deferred — no telemetry wiring). See `extras.rs`.
2886    crate::extras::maybe_first_time_setup(&chat_container);
2887
2888    // A --continue/--resume/--session launch opens on an existing JSONL
2889    // session — render its prior user/assistant transcript so the user sees
2890    // where they left off (tool executions are skipped: their live display
2891    // belongs to the current run, and replaying old results would be noise).
2892    let initial_transformer = build_markdown_transformer(
2893        reload_context
2894            .extension_session
2895            .lock()
2896            .unwrap()
2897            .snapshot_arc(),
2898    );
2899    // A normal launch creates a fresh session and must not replay records from
2900    // another/project harness. Only explicit restore/fork modes render prior
2901    // conversation history. This fixes stale prompts appearing every startup.
2902    if launch_restores_history(args) {
2903        render_session_history(
2904            &harness,
2905            &chat_container,
2906            initial_transformer.clone(),
2907            Some(reload_context.extension_session.clone()),
2908        )
2909        .await;
2910    }
2911
2912    // `document_container` wraps the welcome header + chat so the scrollview
2913    // follows the whole transcript (mirrors TS `documentContainer`).
2914    let document_container = Arc::new(Container::new());
2915    document_container.add_child(chat_container.clone());
2916
2917    let scroll_view = Arc::new(ScrollView::new(
2918        document_container.clone(),
2919        ScrollViewOptions {
2920            follow: FollowMode::End,
2921            primary: true,
2922            overscroll: OverscrollMode::Chain,
2923            // Native pi keeps transcript chrome out of the way. Our Auto mode
2924            // has no hide timer yet and therefore became effectively permanent
2925            // after the first wheel event, unlike the upstream experience.
2926            scrollbar: ScrollbarMode::Hidden,
2927            ..Default::default()
2928        },
2929    ));
2930
2931    // ---- Editor ----
2932    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
2933    // editor renders full-width `─` top/bottom borders with padding-only lines
2934    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
2935    let editor = Arc::new(Editor::new(
2936        EditorOptions {
2937            padding_x: 1,
2938            ..Default::default()
2939        },
2940        EditorStyle::default(),
2941        Arc::new(rpi_tui::Keybindings::new()),
2942    ));
2943
2944    // ---- Footer + status ----
2945    let footer = Arc::new(FooterComponent::new());
2946    footer.set_model(&model_name);
2947    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");
2948
2949    let status_container = Arc::new(Container::new());
2950    let loader = Arc::new(Loader::with_text("Working…"));
2951
2952    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
2953    // Prompt templates discovered at session build (Part A2) are surfaced as
2954    // `/`-prefixed entries alongside the built-in slash commands: typing
2955    // `/<name>` in the editor expands the template (mirrors pi
2956    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
2957    // the template's frontmatter description (or a fallback) so the autocomplete
2958    // popover shows what each template does.
2959    //
2960    // We snapshot the full resources once (skills + prompt-templates): the
2961    // autocomplete builder consumes the templates, and the `/context` command
2962    // (fired from the blocking submit handler, which can't `.await`) reads the
2963    // snapshot to render the discovered-resources panel without touching the
2964    // harness async accessor.
2965    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
2966        .prompt_templates
2967        .clone()
2968        .unwrap_or_default()
2969        .iter()
2970        .map(|t| SlashCommandEntry {
2971            name: format!("/{}", t.name),
2972            description: t
2973                .description
2974                .clone()
2975                .unwrap_or_else(|| "Expand prompt template".to_string()),
2976        })
2977        .collect();
2978    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
2979        Arc::new(resources_snapshot);
2980    // Build the built-in command registry once — the single source of truth for
2981    // both dispatch and the built-in autocomplete entries. The discovered
2982    // prompt-template commands are merged into the autocomplete list separately
2983    // (they dispatch via template expansion, not the registry); built-ins come
2984    // first so they win on a fuzzy tie.
2985    let mut command_registry = build_builtin_registry();
2986    register_extension_commands(
2987        &mut command_registry,
2988        reload_context.extension_session.clone(),
2989    );
2990    let registry = Arc::new(command_registry);
2991    let mut all_slash_commands = registry.visible_entries();
2992    all_slash_commands.extend(template_slash_commands);
2993    let autocomplete = AutocompleteManager::new();
2994    {
2995        let mut combined = CombinedAutocompleteProvider::new();
2996        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2997            all_slash_commands,
2998        )));
2999        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
3000            cwd.clone(),
3001        )));
3002        autocomplete.set_provider(Arc::new(combined));
3003    }
3004    let autocomplete_container = Arc::new(Container::new());
3005
3006    let state = Arc::new(TuiState {
3007        current_assistant: std::sync::Mutex::new(None),
3008        tool_components: std::sync::Mutex::new(HashMap::new()),
3009        bash_components: std::sync::Mutex::new(HashMap::new()),
3010        last_tool_comp: std::sync::Mutex::new(None),
3011        status: std::sync::Mutex::new(RunStatus::Idle),
3012        footer: footer.clone(),
3013        status_container: status_container.clone(),
3014        chat_container: chat_container.clone(),
3015        loader: loader.clone(),
3016        last_assistant_text: std::sync::Mutex::new(String::new()),
3017        active_selector: std::sync::Mutex::new(None),
3018        active_extension_editor: std::sync::Mutex::new(None),
3019        autocomplete,
3020        autocomplete_container: autocomplete_container.clone(),
3021        theme_manager: Arc::new(ThemeManager::new()),
3022        tui: Some(tui.clone()),
3023        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
3024        show_images: std::sync::Mutex::new(true),
3025        history: std::sync::Mutex::new(Vec::new()),
3026        history_index: std::sync::Mutex::new(-1),
3027        history_draft: std::sync::Mutex::new(None),
3028        last_input_tokens: std::sync::Mutex::new(0),
3029        scoped_edit: std::sync::Mutex::new(None),
3030        markdown_transformer: std::sync::Mutex::new(initial_transformer),
3031        extension_session: reload_context.extension_session.clone(),
3032    });
3033
3034    // Capture the model catalog + cwd for the selector builders + the key loop
3035    // (the callbacks fire on blocking threads and need owned data).
3036    let model_catalog_arc = Arc::new(model_catalog.clone());
3037    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
3038
3039    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
3040    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
3041    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
3042    //
3043    // The scrollview gets `basis(0)` so the constrained stack allocator starts
3044    // it at zero height and grows it to fill the space the dock does not need
3045    // — this keeps the dock (editor borders + footer) pinned to the bottom and
3046    // never shrinks it below the editor's 3 rows (top + content + bottom). The
3047    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
3048    // never clip the input panel below its minimum.
3049    let editor_container = Arc::new(Container::new());
3050    editor_container.add_child(editor.clone());
3051
3052    let dock = Arc::new(VStack::from_children(vec![
3053        StackChild::Entry(StackEntry::new(status_container.clone())),
3054        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
3055        StackChild::Entry(
3056            StackEntry::new(editor_container.clone())
3057                .shrink(0)
3058                .min_size(3),
3059        ),
3060        StackChild::Entry(StackEntry::new(footer.clone())),
3061    ]));
3062
3063    let root = VStack::from_children(vec![
3064        StackChild::Entry(
3065            StackEntry::new(scroll_view.clone())
3066                .basis(0)
3067                .grow(1)
3068                .shrink(1)
3069                .min_size(1),
3070        ),
3071        StackChild::Entry(StackEntry::new(dock).shrink(1)),
3072    ]);
3073
3074    tui.set_layout_root(Some(Arc::new(root)));
3075    tui.set_focus(Some(editor.clone()));
3076    editor.set_focused(true);
3077
3078    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
3079    //
3080    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
3081    // the old version made individually) + the registry, then routes `/`-text
3082    // through `dispatch_slash` and sends plain text directly. Each command's
3083    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
3084    // chat mutation) — the handler itself stays a thin router.
3085    //
3086    // One `CommandContext` is built and cloned for both the submit handler and
3087    // the key loop (Ctrl+L routes `/model` through the same registry); all
3088    // fields are `Arc`/cheap, so the clones are free.
3089    let ctx = CommandContext {
3090        chat: chat_container.clone(),
3091        tui: tui.clone(),
3092        tx: tx.clone(),
3093        state: state.clone(),
3094        editor: editor.clone(),
3095        editor_container: editor_container.clone(),
3096        lane: lane.clone(),
3097        model_catalog: model_catalog_arc.clone(),
3098        lane_model_id: lane_model_id.clone(),
3099        cwd: cwd.clone(),
3100        resources: resources_arc.clone(),
3101        reload_context: Arc::new(reload_context.clone()),
3102    };
3103    let ctx_for_cb = ctx.clone();
3104    let registry_for_cb = registry.clone();
3105    editor.on_submit(Arc::new(move |text: &str| {
3106        let text = text.trim();
3107        if text.is_empty() {
3108            return;
3109        }
3110
3111        if text.starts_with('/') {
3112            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
3113            return;
3114        }
3115
3116        let run_status = *ctx_for_cb.state.status.lock().unwrap();
3117        if run_status != RunStatus::Idle {
3118            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
3119            let aborting = run_status == RunStatus::Aborting;
3120            let lane = ctx_for_cb.lane.clone();
3121            let chat = ctx_for_cb.chat.clone();
3122            let tui = ctx_for_cb.tui.clone();
3123            tokio::spawn(async move {
3124                // Queue immediately while the agent loop is still running.
3125                // Routing this through the TUI's main channel delayed it until
3126                // `prompt_text()` returned, after the loop's drain points had
3127                // passed, so the queued message appeared to disappear.
3128                let result = if aborting {
3129                    lane.next_run(message).await
3130                } else {
3131                    lane.steer(message).await
3132                };
3133                if let Err(error) = result {
3134                    add_error_message(&chat, &format!("Could not queue message: {error}"));
3135                    tui.request_render(false);
3136                }
3137            });
3138            add_note_message(
3139                &ctx_for_cb.chat,
3140                &format!("Queued steering message: {text}"),
3141            );
3142            ctx_for_cb.tui.request_render(false);
3143            return;
3144        }
3145
3146        if !ctx_for_cb.state.try_start_working() {
3147            return;
3148        }
3149
3150        add_user_message(&ctx_for_cb.chat, text);
3151        // A new prompt starts a fresh interaction at the tail even when the
3152        // user had scrolled up to inspect older output.
3153        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
3154            scroll.scroll_to_end();
3155        }
3156        ctx_for_cb.tui.request_render(false);
3157        // Remember the message for ↑ recall (slash commands are not part of
3158        // the replayable message history).
3159        push_history(&ctx_for_cb.state, text);
3160        if ctx_for_cb
3161            .tx
3162            .send(TuiMessage::UserInput(text.to_string()))
3163            .is_err()
3164        {
3165            ctx_for_cb.state.set_status(RunStatus::Idle);
3166        }
3167    }));
3168
3169    tui.start_readerless();
3170
3171    // ---- Streaming drain task ----
3172    let drain_handle = if let Some(rx) = event_rx {
3173        let tui_drain = tui.clone();
3174        let state_drain = state.clone();
3175        let chat_drain = chat_container.clone();
3176        Some(tokio::spawn(async move {
3177            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
3178        }))
3179    } else {
3180        None
3181    };
3182
3183    // ---- B5d: plugin→TUI reload bridge ----
3184    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
3185    // (its cdylib would be unmapped while the call frame is still on the stack).
3186    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
3187    // (an `UnboundedSender<()>`); this task drains those signals and forwards
3188    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
3189    // `reload_extension_resources` routine asynchronously. The mailbox is the
3190    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
3191    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
3192    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
3193    reload_context.mailbox.install(reload_sig_tx);
3194    let reload_tx = tx.clone();
3195    let reload_bridge_handle = tokio::spawn(async move {
3196        while reload_sig_rx.recv().await.is_some() {
3197            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
3198                break; // main loop gone — stop forwarding
3199            }
3200        }
3201    });
3202
3203    // ---- Render-tick task (advances the loader spinner while Working) ----
3204    //
3205    // The `Loader` only advances its frame on render; without a periodic
3206    // `request_render` the spinner visibly freezes between events.
3207    let tui_tick = tui.clone();
3208    let state_tick = state.clone();
3209    let tick_handle = tokio::spawn(async move {
3210        // 80ms — pi's loader DEFAULT_INTERVAL_MS (the spinner would visibly
3211        // stutter at the old 120ms).
3212        let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
3213        interval.tick().await; // discard immediate
3214        loop {
3215            interval.tick().await;
3216            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
3217            if working {
3218                if state_tick.bash_components.lock().unwrap().is_empty() {
3219                    // Only the dock loader animates. Keep the already-rendered
3220                    // transcript instead of rebuilding a long history at 12.5
3221                    // frames per second.
3222                    tui_tick.request_render_reusing_scroll_content();
3223                } else {
3224                    // A running bash panel owns a loader inside the transcript.
3225                    tui_tick.request_render(false);
3226                }
3227            }
3228        }
3229    });
3230
3231    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
3232    let running = Arc::new(std::sync::Mutex::new(true));
3233    let running_key = running.clone();
3234    let tx_for_key = tx.clone();
3235    let tui_for_key = tui.clone();
3236    let editor_for_key = editor.clone();
3237    let scroll_for_key = scroll_view.clone();
3238    let lane_for_key = lane.clone();
3239    let state_for_key = state.clone();
3240    // Ctrl+L routes through the same registry as `/model` (one path, not two),
3241    // so the key loop needs the same `CommandContext` + registry the submit
3242    // handler uses. All fields are `Arc`/cheap, so this clone is free.
3243    let ctx_for_key = ctx.clone();
3244    let registry_for_key = registry.clone();
3245
3246    let key_handle = tokio::task::spawn_blocking(move || {
3247        loop {
3248            if !*running_key.lock().unwrap() {
3249                break;
3250            }
3251            // `event::read()` blocks indefinitely. Poll first so shutdown can
3252            // stop and join this worker even when no further key arrives.
3253            match crossterm::event::poll(std::time::Duration::from_millis(50)) {
3254                Ok(true) => {}
3255                Ok(false) => continue,
3256                Err(_) => {
3257                    let _ = tx_for_key.send(TuiMessage::Exit);
3258                    break;
3259                }
3260            }
3261            let Ok(ev) = crossterm::event::read() else {
3262                let _ = tx_for_key.send(TuiMessage::Exit);
3263                break;
3264            };
3265            // `Event::Resize` is delivered as its own event (not a Key). With
3266            // `start_readerless` there is no competing terminal-reader thread to
3267            // handle it, so refresh the cached terminal size here and force a
3268            // full redraw so the constrained layout re-fits the new dimensions.
3269            if let Event::Resize(_cols, _rows) = ev {
3270                tui_for_key.refresh_size();
3271                continue;
3272            }
3273            // Mouse wheel scrolls the transcript (pi supports wheel
3274            // scrolling). Previously every non-Key event was dropped, so a
3275            // wheel had zero effect — "滚动还是不行".
3276            if let Event::Mouse(m) = ev {
3277                use crossterm::event::MouseEventKind;
3278                match m.kind {
3279                    MouseEventKind::ScrollUp => {
3280                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
3281                        if scroll_for_key.scroll_by(delta) != delta {
3282                            tui_for_key.request_render_reusing_scroll_content();
3283                        }
3284                    }
3285                    MouseEventKind::ScrollDown => {
3286                        let delta = MOUSE_WHEEL_SCROLL_LINES;
3287                        if scroll_for_key.scroll_by(delta) != delta {
3288                            tui_for_key.request_render_reusing_scroll_content();
3289                        }
3290                    }
3291                    _ => {}
3292                }
3293                continue;
3294            }
3295            let Event::Key(key) = ev else {
3296                continue;
3297            };
3298            // Drop releases but preserve Repeat so holding arrows, Backspace,
3299            // PageUp, etc. behaves naturally. Windows emits Press + Release
3300            // for a tap; terminals with keyboard enhancement may additionally
3301            // emit Repeat while a key is held.
3302            if !should_dispatch_key(key.kind) {
3303                continue;
3304            }
3305
3306            if state_for_key.extension_editor_open()
3307                && key.modifiers == KeyModifiers::CONTROL
3308                && key.code == KeyCode::Char('c')
3309            {
3310                close_extension_editor(
3311                    &state_for_key,
3312                    &ctx_for_key.editor_container,
3313                    &editor_for_key,
3314                );
3315                tui_for_key.request_render_reusing_scroll_content();
3316                continue;
3317            }
3318
3319            // 0. Ctrl+C: copy the selection when the editor has one (pi
3320            //    `tui.input.copy`); otherwise it's the escape hatch — even
3321            //    with a selector open (a stuck run or a mis-open selector must
3322            //    never trap the user): abort an active run, else exit.
3323            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
3324                if !state_for_key.selector_open() && editor_for_key.has_selection() {
3325                    editor_for_key.copy_selection();
3326                    continue;
3327                }
3328                let status = *state_for_key.status.lock().unwrap();
3329                match status {
3330                    RunStatus::Working => {
3331                        state_for_key.set_status(RunStatus::Aborting);
3332                        let lane = lane_for_key.clone();
3333                        tokio::spawn(async move {
3334                            let _ = lane.abort().await;
3335                        });
3336                    }
3337                    // A held Ctrl+C can emit Repeat immediately after Press.
3338                    // Keep waiting for the in-flight cancellation instead of
3339                    // treating that repeat as a request to exit the process.
3340                    RunStatus::Aborting => {}
3341                    RunStatus::Idle => {
3342                        let _ = tx_for_key.send(TuiMessage::Exit);
3343                    }
3344                }
3345                continue;
3346            }
3347
3348            // 1. A selector overlay is open → route to it first. Only Esc
3349            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
3350            //    escape to the selector; on done/cancel the selector callbacks
3351            //    restore the editor and clear `active_selector`.
3352            if state_for_key.selector_open() {
3353                // Esc always cancels the selector (even with modifiers off).
3354                // Route through `SelectList::handle_key(Esc)` so the list's
3355                // `on_cancel` fires (the `/scoped-models` toggle selector saves
3356                // its edits there) — the old shortcut called `close_selector`
3357                // directly and skipped the callback.
3358                if key.code == KeyCode::Esc {
3359                    let (selector, _kind) = state_for_key
3360                        .active_selector
3361                        .lock()
3362                        .unwrap()
3363                        .clone()
3364                        .expect("selector_open guaranteed Some");
3365                    selector.handle_key(key);
3366                    continue;
3367                }
3368                let (selector, _kind) = state_for_key
3369                    .active_selector
3370                    .lock()
3371                    .unwrap()
3372                    .clone()
3373                    .expect("selector_open guaranteed Some");
3374                selector.handle_key(key);
3375                tui_for_key.request_render_reusing_scroll_content();
3376                continue;
3377            }
3378
3379            // Extension editor occupies the same input slot as the native
3380            // editor. Esc cancels it; every other key is delivered to the
3381            // extension-owned editor instance.
3382            if state_for_key.extension_editor_open() {
3383                let extension_editor = state_for_key
3384                    .active_extension_editor
3385                    .lock()
3386                    .unwrap()
3387                    .clone()
3388                    .expect("extension_editor_open guaranteed Some");
3389                if key.code == KeyCode::Esc {
3390                    close_extension_editor(
3391                        &state_for_key,
3392                        &ctx_for_key.editor_container,
3393                        &editor_for_key,
3394                    );
3395                } else {
3396                    extension_editor.handle_key(key);
3397                }
3398                tui_for_key.request_render_reusing_scroll_content();
3399                continue;
3400            }
3401
3402            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
3403            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
3404            //     editor. With a run active, abort it first (same as Ctrl+C)
3405            //     so the key is never a no-op while a stuck command runs.
3406            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
3407                let status = *state_for_key.status.lock().unwrap();
3408                match status {
3409                    RunStatus::Working => {
3410                        state_for_key.set_status(RunStatus::Aborting);
3411                        let lane = lane_for_key.clone();
3412                        tokio::spawn(async move {
3413                            let _ = lane.abort().await;
3414                        });
3415                        continue;
3416                    }
3417                    RunStatus::Aborting => continue,
3418                    RunStatus::Idle => {}
3419                }
3420                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
3421                    // Editor holds text — delete the char forward (pi parity).
3422                    editor_for_key.handle_key(key);
3423                    refresh_autocomplete(&state_for_key, &editor_for_key);
3424                    tui_for_key.request_render_reusing_scroll_content();
3425                    continue;
3426                }
3427                let _ = tx_for_key.send(TuiMessage::Exit);
3428                continue;
3429            }
3430
3431            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
3432            //     selector is open Esc already cancelled it above; when idle,
3433            //     Esc falls through to the editor (no-op-ish). Only fire while
3434            //     Working so an idle Esc doesn't abort a non-existent run.
3435            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
3436                let status = *state_for_key.status.lock().unwrap();
3437                if status == RunStatus::Working {
3438                    state_for_key.set_status(RunStatus::Aborting);
3439                    let lane = lane_for_key.clone();
3440                    tokio::spawn(async move {
3441                        let _ = lane.abort().await;
3442                    });
3443                    continue;
3444                }
3445            }
3446
3447            // 2c. Ctrl+T: toggle expansion on the most recent tool component.
3448            //     The key loop tracks no per-line focus, so this is an "expand
3449            //     last tool" affordance rather than a cursor-targeted toggle
3450            //     (documented limitation; see `toggle_expand_last_tool`).
3451            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
3452                state_for_key.toggle_expand_last_tool();
3453                tui_for_key.request_render(false);
3454                continue;
3455            }
3456
3457            // 2d. Ctrl+M: cycle to the next model in the catalog after the one
3458            //     currently tracked in `current_model_id`, apply it live via
3459            //     `lane.set_model` (takes effect on the next user message — the
3460            //     in-flight run's config is already snapshotted), and update the
3461            //     footer. `set_model` is async so it runs on a spawned task.
3462            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
3463                let current = state_for_key.current_model_id();
3464                // Cycle within the `/scoped-models` set (settings.json) when
3465                // configured; otherwise the full catalog.
3466                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
3467                if let Some(next) = cycle_next_model(&scope, &current) {
3468                    state_for_key.set_current_model(&next);
3469                    let lane = lane_for_key.clone();
3470                    tokio::spawn(async move {
3471                        let _ = lane.set_model(next).await;
3472                    });
3473                    tui_for_key.request_render_reusing_scroll_content();
3474                }
3475                continue;
3476            }
3477
3478            // 3. Ctrl+L: open the model selector. Routed through the `/model`
3479            //    command so the hotkey and the slash command share one path
3480            //    (TS binds Ctrl+L to model-select).
3481            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
3482                if let Some(cmd) = registry_for_key.find("/model") {
3483                    cmd.execute(&ctx_for_key, "");
3484                }
3485                continue;
3486            }
3487
3488            // 4. Tab: accept the top autocomplete suggestion (if any).
3489            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
3490                if accept_top_suggestion(&state_for_key, &editor_for_key) {
3491                    tui_for_key.request_render_reusing_scroll_content();
3492                }
3493                continue;
3494            }
3495
3496            // 5. Global transcript scroll. PageUp/PageDown use the actual
3497            // viewport height with four rows of overlap (upstream behavior),
3498            // while Home/End jump to the transcript boundaries.
3499            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
3500                let delta = -transcript_page_size(scroll_for_key.viewport_height());
3501                if scroll_for_key.scroll_by(delta) != delta {
3502                    tui_for_key.request_render_reusing_scroll_content();
3503                }
3504                continue;
3505            }
3506            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
3507                let delta = transcript_page_size(scroll_for_key.viewport_height());
3508                if scroll_for_key.scroll_by(delta) != delta {
3509                    tui_for_key.request_render_reusing_scroll_content();
3510                }
3511                continue;
3512            }
3513            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
3514                scroll_for_key.scroll_to_start();
3515                tui_for_key.request_render_reusing_scroll_content();
3516                continue;
3517            }
3518            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
3519                scroll_for_key.scroll_to_end();
3520                tui_for_key.request_render_reusing_scroll_content();
3521                continue;
3522            }
3523
3524            // 5b. ↑/↓ browse submitted-message history when the editor is
3525            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
3526            //     without the surprise of replacing typed text. When the
3527            //     editor holds content, ↑/↓ fall through to cursor movement
3528            //     (typing "hello", pressing ↑ at the start, must never swap
3529            //     the draft for a history entry — reported as "text
3530            //     disappeared"). Once browsing, ↓ walks back and restores the
3531            //     draft.
3532            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
3533                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3534                if editor_for_key.get_text().is_empty() || browsing {
3535                    navigate_history(&state_for_key, &editor_for_key, -1);
3536                    tui_for_key.request_render_reusing_scroll_content();
3537                    continue;
3538                }
3539            }
3540            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
3541                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3542                if editor_for_key.get_text().is_empty() || browsing {
3543                    navigate_history(&state_for_key, &editor_for_key, 1);
3544                    tui_for_key.request_render_reusing_scroll_content();
3545                    continue;
3546                }
3547            }
3548
3549            // Alt+Enter queues a follow-up while a run is active. It is
3550            // handled here because Editor treats only a bare Enter as submit;
3551            // idle Alt+Enter keeps the normal prompt behavior.
3552            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
3553                let prompt = editor_for_key.get_text().trim().to_string();
3554                if prompt.is_empty() {
3555                    continue;
3556                }
3557                editor_for_key.clear();
3558                let status = *state_for_key.status.lock().unwrap();
3559                if status == RunStatus::Idle {
3560                    if state_for_key.try_start_working() {
3561                        add_user_message(&state_for_key.chat_container, &prompt);
3562                        push_history(&state_for_key, &prompt);
3563                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
3564                    }
3565                } else {
3566                    add_note_message(
3567                        &state_for_key.chat_container,
3568                        &format!("Queued follow-up message: {prompt}"),
3569                    );
3570                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
3571                    let lane = lane_for_key.clone();
3572                    let chat = state_for_key.chat_container.clone();
3573                    let tui = tui_for_key.clone();
3574                    tokio::spawn(async move {
3575                        if let Err(error) = lane.follow_up(message).await {
3576                            add_error_message(&chat, &format!("Could not queue message: {error}"));
3577                            tui.request_render(false);
3578                        }
3579                    });
3580                }
3581                tui_for_key.request_render(false);
3582                continue;
3583            }
3584
3585            // 6. Otherwise forward to the editor + refresh autocomplete.
3586            editor_for_key.handle_key(key);
3587            refresh_autocomplete(&state_for_key, &editor_for_key);
3588            tui_for_key.request_render_reusing_scroll_content();
3589        }
3590    });
3591
3592    // ---- Initial prompts (run before reading from the channel) ----
3593    let mut prompts: Vec<String> = Vec::new();
3594    if let Some(init) = initial {
3595        prompts.push(init);
3596    }
3597    for m in extra_messages {
3598        prompts.push(m.clone());
3599    }
3600    for prompt in prompts {
3601        if !*running.lock().unwrap() {
3602            break;
3603        }
3604        add_user_message(&chat_container, &prompt);
3605        tui.request_render(false);
3606        run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3607    }
3608
3609    // ---- Main loop: process submitted input + lifecycle messages ----
3610    loop {
3611        if !*running.lock().unwrap() {
3612            break;
3613        }
3614        match rx.recv().await {
3615            Some(TuiMessage::UserInput(prompt)) => {
3616                // Clear the editor so the next prompt starts fresh (the submit
3617                // handler runs on the blocking key thread and can't mutate the
3618                // editor state safely there; clearing here, on the async loop,
3619                // keeps it on one thread).
3620                editor.clear();
3621                run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3622            }
3623            Some(TuiMessage::OpenTree) => {
3624                if *state.status.lock().unwrap() != RunStatus::Idle {
3625                    add_note_message(
3626                        &chat_container,
3627                        "Wait for the current run to finish before opening the tree.",
3628                    );
3629                    tui.request_render(false);
3630                } else {
3631                    open_tree_selector(
3632                        &harness,
3633                        &state,
3634                        &editor_container,
3635                        &editor,
3636                        &tui,
3637                        &chat_container,
3638                        &tx,
3639                    )
3640                    .await;
3641                }
3642            }
3643            Some(TuiMessage::NavigateTree(entry_id)) => {
3644                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
3645                    Ok(result) => match result.outcome {
3646                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
3647                            chat_container.clear();
3648                            add_welcome_message(&chat_container);
3649                            render_session_history(
3650                                &harness,
3651                                &chat_container,
3652                                state.markdown_transformer(),
3653                                Some(state.extension_session.clone()),
3654                            )
3655                            .await;
3656                            add_note_message(
3657                                &chat_container,
3658                                "Moved to the selected session entry.",
3659                            );
3660                        }
3661                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
3662                            add_error_message(&chat_container, &error.message);
3663                        }
3664                        _ => add_note_message(
3665                            &chat_container,
3666                            "The selected entry could not be opened.",
3667                        ),
3668                    },
3669                    Err(error) => add_error_message(
3670                        &chat_container,
3671                        &format!("Could not navigate session tree: {error}"),
3672                    ),
3673                }
3674                tui.request_render(false);
3675            }
3676            Some(TuiMessage::ClearChat) => {
3677                chat_container.clear();
3678                add_welcome_message(&chat_container);
3679                tui.request_render(false);
3680            }
3681            Some(TuiMessage::Compact) => {
3682                run_compact(&lane, &tui, &state).await;
3683            }
3684            Some(TuiMessage::Copy) => {
3685                copy_last_assistant(&state, &chat_container);
3686                tui.request_render(false);
3687            }
3688            Some(TuiMessage::Exit) => {
3689                *running.lock().unwrap() = false;
3690                break;
3691            }
3692            Some(TuiMessage::SwitchSession(id)) => {
3693                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
3694                tui.request_render(false);
3695            }
3696            Some(TuiMessage::ImportSession(path)) => {
3697                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
3698                tui.request_render(false);
3699            }
3700            Some(TuiMessage::ShareSession) => {
3701                share_session(&harness, &chat_container).await;
3702                tui.request_render(false);
3703            }
3704            Some(TuiMessage::SetSessionName(name)) => {
3705                let outcome = harness.session().set_name(Some(&name)).await;
3706                match outcome {
3707                    Ok(_) => add_note_message(
3708                        &chat_container,
3709                        &format!("Session renamed to \"{name}\"."),
3710                    ),
3711                    Err(e) => add_error_message(
3712                        &chat_container,
3713                        &format!("Could not rename session: {e}"),
3714                    ),
3715                }
3716                tui.request_render(false);
3717            }
3718            Some(TuiMessage::ExportSession) => {
3719                export_session(&harness, &chat_container).await;
3720                tui.request_render(false);
3721            }
3722            Some(TuiMessage::ForkSession) => {
3723                fork_session(&harness, &cwd, &chat_container, &state).await;
3724                tui.request_render(false);
3725            }
3726            Some(TuiMessage::ReloadExtensions) => {
3727                // B5d: drive the shared reload routine on the async runtime,
3728                // then surface the outcome. `reload_context` was passed into
3729                // `interactive_tui` and is the same `Arc<ReloadContext>` the
3730                // `ReloadCommand` + the plugin mailbox both route through —
3731                // clone the `Arc` out so the borrow of `harness` (the main
3732                // loop's `&AgentHarness`) lives across the await.
3733                let reload_ctx = ctx.reload_context.clone();
3734                add_note_message(&chat_container, "Reloading extensions + resources…");
3735                tui.request_render(false);
3736                let outcome =
3737                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
3738                // B5e: the reload swapped a fresh `ExtensionSession` into the
3739                // context's cell. Rebuild the markdown transformer from that
3740                // fresh snapshot and install it on the in-flight streaming
3741                // component (so a reloaded plugin's transformer takes effect on
3742                // the visible message immediately) + future components (they
3743                // read `state.markdown_transformer()` at construction). The old
3744                // closure no-ops once its snapshot's `active` flag flips false
3745                // (reload already did that before the swap).
3746                let fresh_transformer = build_markdown_transformer(
3747                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3748                );
3749                state.set_markdown_transformer_with_reinstall(fresh_transformer);
3750                if outcome.had_warnings {
3751                    add_error_message(
3752                        &chat_container,
3753                        &format!(
3754                            "{} (with warnings — see stderr for details).",
3755                            outcome.summary
3756                        ),
3757                    );
3758                } else {
3759                    add_note_message(&chat_container, &outcome.summary);
3760                }
3761                tui.request_render(false);
3762            }
3763            None => break,
3764        }
3765    }
3766
3767    // ---- Shutdown ----
3768    *running.lock().unwrap() = false;
3769    // The input worker checks `running` at least every 50ms. Join it before
3770    // restoring cooked mode so no late event read races terminal cleanup.
3771    let _ = key_handle.await;
3772    tick_handle.abort();
3773    if let Some(handle) = drain_handle {
3774        handle.abort();
3775    }
3776    // Drop the reload bridge: clearing the mailbox closes the signal channel,
3777    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
3778    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
3779    reload_context.mailbox.clear();
3780    reload_bridge_handle.abort();
3781    tui.stop(Default::default());
3782    println!("\nGoodbye!");
3783    let _ = args;
3784
3785    0
3786}
3787
3788// ===========================================================================
3789// Run a single prompt (streaming or blocking)
3790// ===========================================================================
3791
3792/// Drive a single prompt through the lane. When `streaming` is true, the
3793/// `AgentEvent` drain task renders the response live and this function only
3794/// awaits completion (to surface hard errors). When false (no `event_rx`),
3795/// it falls back to the blocking await-final-text path.
3796async fn run_prompt_streaming(
3797    lane: &Arc<dyn AgentLane>,
3798    prompt: &str,
3799    tui: &Arc<TuiAltScreen>,
3800    state: &Arc<TuiState>,
3801    streaming: bool,
3802) {
3803    // Ensure the run starts in a clean streaming state.
3804    state.set_status(RunStatus::Working);
3805    tui.request_render(false);
3806
3807    let outcome = lane.prompt_text(prompt, Vec::new()).await;
3808
3809    // The drain task finalized the assistant message via MessageEnd/AgentEnd,
3810    // but guard against runs that ended without a terminal event (e.g. a hard
3811    // provider rejection before any streaming) by clearing streaming state.
3812    {
3813        let mut cur = state.current_assistant.lock().unwrap();
3814        if let Some(comp) = cur.take() {
3815            comp.set_streaming(false);
3816        }
3817    }
3818
3819    state.set_status(RunStatus::Idle);
3820
3821    match outcome {
3822        Ok(result) => match &result.outcome {
3823            HarnessRunOutcome::Failed {
3824                error,
3825                final_message,
3826                ..
3827            } => {
3828                // Only add an error line if the stream did NOT already render
3829                // an assistant message for it (drain task leaves
3830                // current_assistant Some only on an abrupt end).
3831                let already_rendered = final_message.is_some();
3832                if !already_rendered {
3833                    let msg = final_message
3834                        .as_ref()
3835                        .and_then(|m| m.error_message.clone())
3836                        .unwrap_or_else(|| format!("{error:?}"));
3837                    add_error_message(&state.chat_container, &msg);
3838                }
3839            }
3840            HarnessRunOutcome::Suspended { .. } => {
3841                add_error_message(
3842                    &state.chat_container,
3843                    "Run suspended (deferred) — resume is not supported in v1.",
3844                );
3845            }
3846            HarnessRunOutcome::Aborted { final_message, .. } => {
3847                // Aborted runs render their own partial/final message via the
3848                // stream; only add a note on the blocking fallback path.
3849                if !streaming {
3850                    add_error_message(&state.chat_container, "Request aborted.");
3851                    let _ = final_message; // (rendered by the stream in streaming mode)
3852                }
3853            }
3854            HarnessRunOutcome::Completed { final_message, .. } => {
3855                if !streaming {
3856                    let text = assistant_text(final_message);
3857                    if !text.is_empty() {
3858                        add_assistant_message_blocking(
3859                            &state.chat_container,
3860                            &text,
3861                            state.markdown_transformer(),
3862                        );
3863                        *state.last_assistant_text.lock().unwrap() = text;
3864                    }
3865                }
3866            }
3867        },
3868        Err(e) => {
3869            add_error_message(&state.chat_container, &e.to_string());
3870        }
3871    }
3872
3873    tui.request_render(false);
3874}
3875
3876/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
3877/// Reports the outcome as a transcript note; v1's compaction summarizes the
3878/// session in place, so no streaming display is wired (compaction emits no
3879/// `AgentEvent`s — only the harness bus `RunEnd`).
3880async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
3881    state.set_status(RunStatus::Working);
3882    tui.request_render(false);
3883    match lane.compact(None).await {
3884        Ok(_) => {
3885            add_note_message(&state.chat_container, "Conversation compacted.");
3886        }
3887        Err(e) => {
3888            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
3889        }
3890    }
3891    state.set_status(RunStatus::Idle);
3892    tui.request_render(false);
3893}
3894
3895/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
3896/// when no clipboard is available (or the `clipboard` feature is off), prints a
3897/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
3898fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
3899    let text = state.last_assistant_text.lock().unwrap().clone();
3900    if text.is_empty() {
3901        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
3902        return;
3903    }
3904    if copy_to_clipboard(&text) {
3905        add_note_message(chat, "Copied last reply to the clipboard.");
3906    } else {
3907        // Clipboard unavailable — print the text to the transcript so the user
3908        // can select/copy it manually (degrades gracefully in headless envs).
3909        let preview: String = text.chars().take(200).collect();
3910        add_note_message(
3911            chat,
3912            &format!(
3913                "Clipboard unavailable. Last reply: {preview}{}",
3914                if text.chars().count() > 200 {
3915                    "…"
3916                } else {
3917                    ""
3918                }
3919            ),
3920        );
3921    }
3922}
3923
3924/// Best-effort clipboard write. Enabled only with the `clipboard` feature
3925/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
3926#[cfg(feature = "clipboard")]
3927fn copy_to_clipboard(text: &str) -> bool {
3928    match arboard::Clipboard::new() {
3929        Ok(mut cb) => cb.set_text(text).is_ok(),
3930        Err(_) => false,
3931    }
3932}
3933
3934#[cfg(not(feature = "clipboard"))]
3935fn copy_to_clipboard(_text: &str) -> bool {
3936    false
3937}
3938
3939/// Blocking fallback (no `event_rx`): render the final assistant text as a
3940/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
3941/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
3942/// the identity path. The blocking path only fires when `event_rx` is absent,
3943/// so it shares the same transformer the streaming path installs on its
3944/// components.
3945fn add_assistant_message_blocking(
3946    container: &Arc<Container>,
3947    text: &str,
3948    transformer: Option<MarkdownTransformer>,
3949) {
3950    if text.is_empty() {
3951        return;
3952    }
3953    let msg = Arc::new(AssistantMessageComponent::new(
3954        AssistantMessageOptions::default(),
3955    ));
3956    if let Some(t) = &transformer {
3957        msg.set_markdown_transformer(Some(t.clone()));
3958    }
3959    msg.update_text(text);
3960    container.add_child(msg);
3961    container.add_child(Arc::new(Spacer::new(1)));
3962}
3963
3964// ===========================================================================
3965// AgentEvent drain task — the streaming core
3966// ===========================================================================
3967
3968/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
3969/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
3970/// lifetime of the TUI.
3971async fn drain_agent_events(
3972    mut rx: broadcast::Receiver<AgentEvent>,
3973    tui: Arc<TuiAltScreen>,
3974    state: Arc<TuiState>,
3975    chat: Arc<Container>,
3976) {
3977    loop {
3978        match rx.recv().await {
3979            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
3980            Err(broadcast::error::RecvError::Lagged(_)) => {
3981                // We dropped some intermediate deltas; the next MessageUpdate/
3982                // MessageEnd carries a full partial snapshot so the UI re-syncs.
3983                continue;
3984            }
3985            Err(broadcast::error::RecvError::Closed) => break,
3986        }
3987    }
3988}
3989
3990/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
3991/// (`interactive-mode.ts:3068-3396`).
3992async fn handle_agent_event(
3993    event: AgentEvent,
3994    tui: &Arc<TuiAltScreen>,
3995    state: &Arc<TuiState>,
3996    chat: &Arc<Container>,
3997) {
3998    match event {
3999        AgentEvent::AgentStart => {
4000            state.set_status(RunStatus::Working);
4001            tui.request_render(false);
4002        }
4003
4004        AgentEvent::AgentEnd { .. } => {
4005            // Finalize any still-streaming assistant message.
4006            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4007                comp.set_streaming(false);
4008            }
4009            state.set_status(RunStatus::Idle);
4010            tui.request_render(false);
4011        }
4012
4013        AgentEvent::TurnStart => {
4014            // A new turn: reset the streaming-assistant guard so the next
4015            // MessageStart creates a fresh component.
4016            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4017                comp.set_streaming(false);
4018            }
4019        }
4020
4021        AgentEvent::TurnEnd {
4022            message,
4023            tool_results,
4024        } => {
4025            // Finalize the assistant message for this turn.
4026            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4027                if let AgentMessage::Assistant(a) = &message {
4028                    comp.update_blocks(&assistant_blocks(a));
4029                }
4030                comp.set_streaming(false);
4031            }
4032            // Any tool results whose components were never ended by a
4033            // ToolExecutionEnd get a static rendering here (best-effort). The
4034            // normal path removes the component via ToolExecutionEnd; this is
4035            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
4036            let tools = state.tool_components.lock().unwrap();
4037            for tr in &tool_results {
4038                if tools.contains_key(&tr.tool_call_id) {
4039                    // Will be removed below via ToolExecutionEnd in the normal
4040                    // path; leave as-is if still present.
4041                    let _ = tr;
4042                }
4043            }
4044            drop(tools);
4045            tui.request_render(false);
4046        }
4047
4048        AgentEvent::MessageStart { message } => match message {
4049            AgentMessage::Assistant(a) => {
4050                let comp = Arc::new(AssistantMessageComponent::new(
4051                    AssistantMessageOptions::default(),
4052                ));
4053                // B5e: install the live markdown transformer so the plugin's
4054                // `register_markdown_transformer` handlers apply from the very
4055                // first streamed delta. `set_streaming` before the transform
4056                // install is fine (transform fires on `update_blocks`, below).
4057                if let Some(t) = state.markdown_transformer() {
4058                    comp.set_markdown_transformer(Some(t));
4059                }
4060                comp.set_streaming(true);
4061                // Render text AND thinking blocks in order (the old path fed
4062                // only the concatenated text, so thinking blocks never showed).
4063                comp.update_blocks(&assistant_blocks(&a));
4064                chat.add_child(comp.clone());
4065                // Spacer(1) separates this assistant turn from the next entry;
4066                // the component itself adds no leading spacer.
4067                chat.add_child(Arc::new(Spacer::new(1)));
4068                *state.current_assistant.lock().unwrap() = Some(comp);
4069                tui.request_render(false);
4070            }
4071            AgentMessage::Custom(custom) => {
4072                let payload = serde_json::json!({
4073                    "customType": custom.role,
4074                    "content": custom.content,
4075                    "details": custom.data,
4076                    "expanded": false,
4077                    "outputPad": 1,
4078                });
4079                if let Some(component) = extension_message_component(
4080                    &state.extension_session,
4081                    &custom.role,
4082                    &payload,
4083                    state.markdown_transformer(),
4084                ) {
4085                    chat.add_child(component);
4086                    chat.add_child(Arc::new(Spacer::new(1)));
4087                    tui.request_render(false);
4088                } else {
4089                    add_note_message(chat, &custom_message_fallback(&custom));
4090                    tui.request_render(false);
4091                }
4092            }
4093            // User / ToolResult / Custom starts are echoed at submit time or
4094            // via the tool-execution components; ignore user/tool dupes.
4095            _ => {}
4096        },
4097
4098        AgentEvent::MessageUpdate {
4099            message,
4100            assistant_message_event,
4101        } => {
4102            if let AgentMessage::Assistant(a) = &message {
4103                let text = assistant_text(a);
4104                let mut saw_bash_tool_call = false;
4105                // Scan content for finalized tool calls → proactively create
4106                // tool components (TS shows the tool as soon as the assistant
4107                // emits the ToolCall; ToolExecutionStart coalesces if it
4108                // already exists).
4109                for c in &a.content {
4110                    if let Content::ToolCall(tc) = c {
4111                        if tc.name == "bash" {
4112                            saw_bash_tool_call = true;
4113                            // Bash has a dedicated component. Create it here as
4114                            // well as on ToolExecutionStart because the tool
4115                            // call can become visible in a MessageUpdate first.
4116                            // Keeping it in the bash map lets Start coalesce
4117                            // with this panel instead of appending a second one.
4118                            let command = tc
4119                                .arguments
4120                                .get("command")
4121                                .and_then(|v| v.as_str())
4122                                .unwrap_or("");
4123                            let mut bash = state.bash_components.lock().unwrap();
4124                            if !bash.contains_key(&tc.id) {
4125                                let comp = Arc::new(BashExecutionComponent::new(command));
4126                                chat.add_child(comp.clone());
4127                                bash.insert(tc.id.clone(), comp);
4128                            }
4129                        } else {
4130                            let mut tools = state.tool_components.lock().unwrap();
4131                            if !tools.contains_key(&tc.id) {
4132                                let comp = Arc::new(ToolExecutionComponent::new(
4133                                    &tc.name,
4134                                    &tc.arguments.to_string(),
4135                                ));
4136                                comp.set_running();
4137                                chat.add_child(comp.clone());
4138                                tools.insert(tc.id.clone(), comp);
4139                            }
4140                        }
4141                    }
4142                }
4143                // MessageUpdate can expose the finalized bash call before
4144                // ToolExecutionStart arrives. Hide the global `Working…`
4145                // loader immediately when creating that bash panel; otherwise
4146                // it briefly appears alongside the panel's `Running…` spinner.
4147                if saw_bash_tool_call {
4148                    state.sync_working_loader_with_bash();
4149                }
4150                let _ = assistant_message_event; // snapshot already applied via `a`
4151                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
4152                    // Stream the full block list (text + thinking) each update
4153                    // so thinking blocks render live as they arrive.
4154                    comp.update_blocks(&assistant_blocks(a));
4155                }
4156                *state.last_assistant_text.lock().unwrap() = text;
4157                tui.request_render(false);
4158            }
4159        }
4160
4161        AgentEvent::MessageEnd { message } => {
4162            if let AgentMessage::Assistant(a) = &message {
4163                let text = assistant_text(a);
4164                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4165                    comp.update_blocks(&assistant_blocks(a));
4166                    comp.set_streaming(false);
4167                }
4168                // Cache the finalized text for `/copy`.
4169                if !text.is_empty() {
4170                    *state.last_assistant_text.lock().unwrap() = text;
4171                }
4172                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
4173                // the previous turn's input established a cacheable prefix; a
4174                // large input this turn that read nothing from cache means the
4175                // prefix was re-billed. No cost display — v1 has no per-run
4176                // cost tracking here.
4177                let usage = &a.usage;
4178                let prev_input = *state.last_input_tokens.lock().unwrap();
4179                if prev_input > 0
4180                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
4181                    && usage.cache_read == 0
4182                {
4183                    add_note_message(
4184                        &state.chat_container,
4185                        &format!(
4186                            "Cache miss: {} tokens re-billed",
4187                            format_tokens(usage.input)
4188                        ),
4189                    );
4190                }
4191                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
4192                    add_note_message(chat, &text);
4193                }
4194                *state.last_input_tokens.lock().unwrap() = usage.input;
4195            }
4196            tui.request_render(false);
4197        }
4198
4199        AgentEvent::ToolExecutionStart {
4200            tool_call_id,
4201            tool_name,
4202            args,
4203        } => {
4204            if tool_name == "bash" {
4205                // Bash streams into a dedicated BashExecutionComponent (command
4206                // header + live preview + exit/truncation status) rather than a
4207                // generic ToolExecutionComponent. The command comes from the
4208                // `command` field of the bash tool args.
4209                let command = args
4210                    .get("command")
4211                    .and_then(|v| v.as_str())
4212                    .unwrap_or("")
4213                    .to_string();
4214                let mut bash_map = state.bash_components.lock().unwrap();
4215                if let Some(existing) = bash_map.get(&tool_call_id) {
4216                    // A ToolExecutionUpdate already created the panel (fast
4217                    // command — Update can arrive before Start); backfill the
4218                    // command header instead of adding a SECOND panel, which
4219                    // used to stack an empty "$ " box above the real one.
4220                    existing.set_command(&command);
4221                } else {
4222                    let comp = Arc::new(BashExecutionComponent::new(command));
4223                    chat.add_child(comp.clone());
4224                    bash_map.insert(tool_call_id.clone(), comp);
4225                }
4226            } else {
4227                let comp = {
4228                    let mut tools = state.tool_components.lock().unwrap();
4229                    if let Some(existing) = tools.get(&tool_call_id) {
4230                        existing.set_args(&args.to_string());
4231                        existing.clone()
4232                    } else {
4233                        let comp =
4234                            Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
4235                        comp.set_running();
4236                        chat.add_child(comp.clone());
4237                        tools.insert(tool_call_id.clone(), comp.clone());
4238                        comp
4239                    }
4240                };
4241                state.remember_tool(comp);
4242            }
4243            state.sync_working_loader_with_bash();
4244            tui.request_render(false);
4245        }
4246
4247        AgentEvent::ToolExecutionUpdate {
4248            tool_call_id,
4249            tool_name,
4250            partial_result,
4251            ..
4252        } => {
4253            if tool_name == "bash" {
4254                // Append the streamed chunk to the bash component's preview.
4255                // RAW text (no single-line collapsing) — the old
4256                // `summarize_tool_result` folded every newline into a `⏎`
4257                // glyph, cramming e.g. `ls -la`'s listing onto one line.
4258                let chunk = tool_result_text(&partial_result);
4259                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
4260                    bash.append_output(&chunk);
4261                } else {
4262                    // No component yet — create a running bash one so the
4263                    // partial shows (command unknown at Update time; leave blank).
4264                    let comp = Arc::new(BashExecutionComponent::new(""));
4265                    comp.append_output(&chunk);
4266                    chat.add_child(comp.clone());
4267                    state
4268                        .bash_components
4269                        .lock()
4270                        .unwrap()
4271                        .insert(tool_call_id.clone(), comp);
4272                }
4273            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
4274                // Raw multi-line text — read/ls-style tools must show their
4275                // full content, not the single-line ⏎-folded summary.
4276                comp.set_result(&tool_result_text(&partial_result), false);
4277                apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
4278                state.remember_tool(comp.clone());
4279            } else {
4280                // No component yet — create a running one so the partial shows.
4281                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4282                comp.set_running();
4283                comp.set_result(&tool_result_text(&partial_result), false);
4284                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
4285                chat.add_child(comp.clone());
4286                state
4287                    .tool_components
4288                    .lock()
4289                    .unwrap()
4290                    .insert(tool_call_id.clone(), comp.clone());
4291                state.remember_tool(comp);
4292            }
4293            state.sync_working_loader_with_bash();
4294            tui.request_render(false);
4295        }
4296
4297        AgentEvent::ToolExecutionEnd {
4298            tool_call_id,
4299            tool_name,
4300            result,
4301            is_error,
4302        } => {
4303            if tool_name == "bash" {
4304                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
4305                if let Some(bash) = bash {
4306                    finalize_bash(&bash, &result, is_error);
4307                } else {
4308                    // Bash ended without a Start/Update — render a finalized
4309                    // component directly from the result text.
4310                    let command = result
4311                        .details
4312                        .get("command")
4313                        .and_then(|v| v.as_str())
4314                        .unwrap_or("")
4315                        .to_string();
4316                    let comp = Arc::new(BashExecutionComponent::new(command));
4317                    comp.append_output(&tool_result_text(&result));
4318                    finalize_bash(&comp, &result, is_error);
4319                    chat.add_child(comp);
4320                }
4321            } else {
4322                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
4323                if let Some(comp) = comp {
4324                    comp.set_result(&tool_result_text(&result), is_error);
4325                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4326                } else {
4327                    // Tool ended without a Start/Update (e.g. a very fast tool):
4328                    // render a finalized component directly.
4329                    let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4330                    comp.set_result(&tool_result_text(&result), is_error);
4331                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4332                    chat.add_child(comp.clone());
4333                    state.remember_tool(comp);
4334                }
4335            }
4336            state.sync_working_loader_with_bash();
4337            tui.request_render(false);
4338        }
4339    }
4340}
4341
4342/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
4343/// tool result and mark the component complete. Mirrors the TS bash finalize
4344/// path; only the fields `BashExecutionComponent` needs are read.
4345fn finalize_bash(
4346    comp: &Arc<BashExecutionComponent>,
4347    result: &rpi_agent::AgentToolResult,
4348    is_error: bool,
4349) {
4350    // The exit code isn't in details directly (TS carries it elsewhere); use
4351    // `is_error` as the error signal and 0/1 as a best-effort exit code.
4352    let exit_code = if is_error { Some(1) } else { Some(0) };
4353    let truncated = result
4354        .details
4355        .get("truncation")
4356        .and_then(|t| t.get("truncated"))
4357        .and_then(|v| v.as_bool())
4358        .unwrap_or(false);
4359    let full_output_path = result
4360        .details
4361        .get("full_output_path")
4362        .and_then(|v| v.as_str())
4363        .map(|s| s.to_string());
4364    let truncation = BashTruncation {
4365        truncated,
4366        full_output_path,
4367    };
4368    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
4369    comp.set_complete(exit_code, cancelled, truncation);
4370}
4371
4372/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
4373/// display-diff string, render it with colors and attach to the component so
4374/// the changes show in the transcript. `write` has no diff (details: Null) and
4375/// stays a plain summary.
4376fn apply_edit_diff(
4377    comp: &Arc<ToolExecutionComponent>,
4378    tool_name: &str,
4379    details: &serde_json::Value,
4380    tui: &Arc<TuiAltScreen>,
4381) {
4382    if tool_name != "edit" {
4383        return;
4384    }
4385    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
4386        return;
4387    };
4388    if diff_text.is_empty() {
4389        return;
4390    }
4391    let width = tui.width();
4392    let lines = render_diff(diff_text, width);
4393    comp.set_diff(lines);
4394}
4395
4396/// Render an `AgentToolResult` as a single-line summary for the
4397/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
4398fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
4399    use rpi_agent::TextContentOrImage;
4400    let mut parts: Vec<String> = Vec::new();
4401    for c in &result.content {
4402        if let TextContentOrImage::Text(t) = c {
4403            parts.push(t.text.clone());
4404        }
4405    }
4406    let joined = parts.join("\n");
4407    // Keep the tool line compact: collapse to a single line, trim length.
4408    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
4409    if one_line.chars().count() > 200 {
4410        let truncated: String = one_line.chars().take(200).collect();
4411        format!("{truncated}…")
4412    } else {
4413        one_line
4414    }
4415}
4416
4417/// The raw multi-line text of a tool result (no single-line collapsing). The
4418/// bash panel needs the original line structure — the old path fed it through
4419/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
4420/// crammed e.g. `ls -la`'s whole listing onto one line.
4421fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
4422    use rpi_agent::TextContentOrImage;
4423    let mut parts: Vec<String> = Vec::new();
4424    for c in &result.content {
4425        if let TextContentOrImage::Text(t) = c {
4426            parts.push(t.text.clone());
4427        }
4428    }
4429    parts.join("\n")
4430}
4431
4432// ===========================================================================
4433// Selectors — editor-container swap (TS showSelector pattern)
4434// ===========================================================================
4435
4436/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
4437/// hiding the editor while the selector is open. Records the selector in
4438/// `state.active_selector` so the key loop routes to it.
4439fn open_selector(
4440    state: &Arc<TuiState>,
4441    editor_container: &Arc<Container>,
4442    editor: &Arc<Editor>,
4443    tui: &Arc<TuiAltScreen>,
4444    list: Arc<SelectList>,
4445    kind: SelectorKind,
4446) {
4447    // Unfocus the editor so its cursor marker doesn't render behind the list.
4448    editor.set_focused(false);
4449    // Swap: clear the container and add just the list.
4450    editor_container.clear();
4451    editor_container.add_child(list.clone());
4452    *state.active_selector.lock().unwrap() = Some((list, kind));
4453    tui.request_render(false);
4454}
4455
4456/// Restore the editor into the `editor_container` and clear the active
4457/// selector. Called by selector `on_cancel` and the Esc handler.
4458fn close_selector(
4459    state: &Arc<TuiState>,
4460    editor_container: &Arc<Container>,
4461    editor: &Arc<Editor>,
4462    tui: &Arc<TuiAltScreen>,
4463) {
4464    editor_container.clear();
4465    editor_container.add_child(editor.clone());
4466    editor.set_focused(true);
4467    *state.active_selector.lock().unwrap() = None;
4468    tui.request_render(false);
4469}
4470
4471/// Build + open the `/model` selector. Items are the resolved catalog (display
4472/// label = model name; description = id), with the current model marked.
4473/// Selecting applies the model **live** via `lane.set_model` (takes effect on
4474/// the next user message — the in-flight run's config is already snapshotted),
4475/// updates the footer, and notes the next-prompt effect.
4476fn open_model_selector(
4477    state: &Arc<TuiState>,
4478    editor_container: &Arc<Container>,
4479    editor: &Arc<Editor>,
4480    tui: &Arc<TuiAltScreen>,
4481    catalog: &[rpi_ai::Model],
4482    lane: &Arc<dyn AgentLane>,
4483    lane_model_id: &str,
4484    chat: &Arc<Container>,
4485) {
4486    let mut items: Vec<SelectItem> = Vec::new();
4487    for m in catalog {
4488        let label = if m.name.is_empty() {
4489            short_model_name(&m.id)
4490        } else {
4491            m.name.clone()
4492        };
4493        let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
4494            " (current)"
4495        } else {
4496            ""
4497        };
4498        items.push(
4499            SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
4500        );
4501    }
4502    if items.is_empty() {
4503        add_note_message(
4504            chat,
4505            "No models in the catalog. Use --model at startup to select one.",
4506        );
4507        tui.request_render(false);
4508        return;
4509    }
4510    let list = Arc::new(SelectList::new(items, 10));
4511
4512    // Capture the catalog + lane so the on_select closure can resolve the
4513    // chosen Model and apply it. `on_select` fires on the blocking key thread,
4514    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
4515    let catalog_arc = catalog.to_vec();
4516    let state_sel = state.clone();
4517    let ec_sel = editor_container.clone();
4518    let editor_sel = editor.clone();
4519    let tui_sel = tui.clone();
4520    let chat_sel = chat.clone();
4521    let lane_sel = lane.clone();
4522    list.on_select(Arc::new(move |item| {
4523        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
4524            add_note_message(
4525                &chat_sel,
4526                &format!("Model {} not found in catalog.", item.label),
4527            );
4528            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4529            return;
4530        };
4531        state_sel.set_current_model(&model);
4532        let lane = lane_sel.clone();
4533        tokio::spawn(async move {
4534            let _ = lane.set_model(model).await;
4535        });
4536        add_note_message(
4537            &chat_sel,
4538            &format!(
4539                "Model set to {} — applies to the next message.",
4540                short_model_name(&item.value)
4541            ),
4542        );
4543        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4544    }));
4545    let state_cancel = state.clone();
4546    let ec_cancel = editor_container.clone();
4547    let editor_cancel = editor.clone();
4548    let tui_cancel = tui.clone();
4549    list.on_cancel(Arc::new(move || {
4550        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4551    }));
4552
4553    open_selector(
4554        state,
4555        editor_container,
4556        editor,
4557        tui,
4558        list,
4559        SelectorKind::Model,
4560    );
4561}
4562
4563/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
4564/// Returns `None` only when the catalog is empty or the current id isn't
4565/// found (in which case the first entry is returned — a no-op if it IS the
4566/// current). Used by the Ctrl+M model-cycle hotkey.
4567fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
4568    if catalog.is_empty() {
4569        return None;
4570    }
4571    let idx = catalog
4572        .iter()
4573        .position(|m| m.id.eq_ignore_ascii_case(current_id));
4574    match idx {
4575        Some(i) => {
4576            let next = (i + 1) % catalog.len();
4577            Some(catalog[next].clone())
4578        }
4579        None => Some(catalog[0].clone()),
4580    }
4581}
4582
4583/// Build + open the `/session` selector. Lists JSONL session files under the
4584/// default session dir (`<cwd>/.pi/sessions`). Selecting reports "restore not
4585/// implemented in v1" (existing constraint) but shows the list for
4586/// discoverability.
4587fn open_session_selector(
4588    state: &Arc<TuiState>,
4589    editor_container: &Arc<Container>,
4590    editor: &Arc<Editor>,
4591    tui: &Arc<TuiAltScreen>,
4592    cwd: &std::path::Path,
4593    tx: &mpsc::UnboundedSender<TuiMessage>,
4594) {
4595    let dir = crate::session::default_session_dir(cwd);
4596    let mut items: Vec<SelectItem> = Vec::new();
4597    if let Ok(entries) = std::fs::read_dir(&dir) {
4598        for entry in entries.flatten() {
4599            let path = entry.path();
4600            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
4601                continue;
4602            }
4603            let stem = path
4604                .file_stem()
4605                .and_then(|s| s.to_str())
4606                .unwrap_or("(unnamed)")
4607                .to_string();
4608            let display = path
4609                .file_name()
4610                .and_then(|s| s.to_str())
4611                .unwrap_or(&stem)
4612                .to_string();
4613            items.push(SelectItem::new(&stem, &display));
4614        }
4615    }
4616    if items.is_empty() {
4617        add_note_message(
4618            &state.chat_container,
4619            "No saved sessions found. Sessions are created automatically in interactive mode.",
4620        );
4621        tui.request_render(false);
4622        return;
4623    }
4624    let list = Arc::new(SelectList::new(items, 10));
4625
4626    let state_sel = state.clone();
4627    let ec_sel = editor_container.clone();
4628    let editor_sel = editor.clone();
4629    let tui_sel = tui.clone();
4630    let tx_sel = tx.clone();
4631    list.on_select(Arc::new(move |item| {
4632        // Close the selector first, then ask the async loop to hot-switch:
4633        // opening the session file + swapping the harness backing is async
4634        // (repo list/open) and must not run on the blocking key thread.
4635        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4636        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
4637    }));
4638    let state_cancel = state.clone();
4639    let ec_cancel = editor_container.clone();
4640    let editor_cancel = editor.clone();
4641    let tui_cancel = tui.clone();
4642    list.on_cancel(Arc::new(move || {
4643        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4644    }));
4645
4646    open_selector(
4647        state,
4648        editor_container,
4649        editor,
4650        tui,
4651        list,
4652        SelectorKind::Session,
4653    );
4654}
4655
4656fn custom_entry_display_text(
4657    custom_type: &str,
4658    data: Option<&serde_json::Value>,
4659) -> Option<String> {
4660    let data = data?;
4661    let text = data
4662        .get("summary")
4663        .or_else(|| data.get("text"))
4664        .or_else(|| data.get("output"))
4665        .and_then(|value| value.as_str())
4666        .filter(|value| !value.trim().is_empty())?;
4667    let label = match custom_type {
4668        "compactionSummary" => "Compaction summary",
4669        "branchSummary" => "Branch summary",
4670        "bashExecution" => "Command output",
4671        other => other,
4672    };
4673    Some(format!("{label}: {text}"))
4674}
4675
4676/// Open a selector for the current session's persisted entry tree. Selecting a
4677/// message moves the main lane leaf to that entry, then the caller reloads the
4678/// visible branch from durable storage.
4679async fn open_tree_selector(
4680    harness: &AgentHarness,
4681    state: &Arc<TuiState>,
4682    editor_container: &Arc<Container>,
4683    editor: &Arc<Editor>,
4684    tui: &Arc<TuiAltScreen>,
4685    chat: &Arc<Container>,
4686    tx: &mpsc::UnboundedSender<TuiMessage>,
4687) {
4688    let entries = match harness
4689        .session()
4690        .view("main")
4691        .find_entries(&EntryQuery {
4692            order: Some(EntryOrder::OldestFirst),
4693            ..Default::default()
4694        })
4695        .await
4696    {
4697        Ok(entries) => entries,
4698        Err(error) => {
4699            add_error_message(chat, &format!("Could not read session tree: {error}"));
4700            tui.request_render(false);
4701            return;
4702        }
4703    };
4704    let current = harness.session().get_leaf_id().await.ok().flatten();
4705    let items: Vec<SelectItem> = entries
4706        .iter()
4707        .map(|entry| {
4708            let marker = if current.as_deref() == Some(entry.id()) {
4709                " (current)"
4710            } else {
4711                ""
4712            };
4713            SelectItem::new(
4714                entry.id(),
4715                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
4716            )
4717            .with_description(&entry.id()[..entry.id().len().min(12)])
4718        })
4719        .collect();
4720    if items.is_empty() {
4721        add_note_message(chat, "The current session has no entries to navigate.");
4722        tui.request_render(false);
4723        return;
4724    }
4725    let list = Arc::new(SelectList::new(items, 12));
4726    let state_sel = state.clone();
4727    let ec_sel = editor_container.clone();
4728    let editor_sel = editor.clone();
4729    let tui_sel = tui.clone();
4730    let tx_sel = tx.clone();
4731    list.on_select(Arc::new(move |item| {
4732        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
4733        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4734    }));
4735    let state_cancel = state.clone();
4736    let ec_cancel = editor_container.clone();
4737    let editor_cancel = editor.clone();
4738    let tui_cancel = tui.clone();
4739    list.on_cancel(Arc::new(move || {
4740        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4741    }));
4742    open_selector(
4743        state,
4744        editor_container,
4745        editor,
4746        tui,
4747        list,
4748        SelectorKind::Tree,
4749    );
4750}
4751
4752/// Build + open the `/theme` selector. Presets [dark, light, monochrome];
4753/// selecting applies it live via the owned `ThemeManager` + re-renders.
4754fn open_theme_selector(
4755    state: &Arc<TuiState>,
4756    editor_container: &Arc<Container>,
4757    editor: &Arc<Editor>,
4758    tui: &Arc<TuiAltScreen>,
4759) {
4760    let items = vec![
4761        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
4762        SelectItem::new("light", "Light").with_description("Light background"),
4763        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
4764    ];
4765    let list = Arc::new(SelectList::new(items, 10));
4766
4767    let state_sel = state.clone();
4768    let ec_sel = editor_container.clone();
4769    let editor_sel = editor.clone();
4770    let tui_sel = tui.clone();
4771    let chat_sel = state.chat_container.clone();
4772    list.on_select(Arc::new(move |item| {
4773        let preset = match item.value.as_str() {
4774            "light" => ThemePreset::Light,
4775            "monochrome" => ThemePreset::Monochrome,
4776            _ => ThemePreset::Dark,
4777        };
4778        apply_theme_preset(preset);
4779        // A quick accent note so the user sees the change registered even if
4780        // the terminal's own colors mask the preset difference.
4781        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
4782        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4783        tui_sel.render_now(true);
4784    }));
4785    let state_cancel = state.clone();
4786    let ec_cancel = editor_container.clone();
4787    let editor_cancel = editor.clone();
4788    let tui_cancel = tui.clone();
4789    list.on_cancel(Arc::new(move || {
4790        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4791    }));
4792
4793    open_selector(
4794        state,
4795        editor_container,
4796        editor,
4797        tui,
4798        list,
4799        SelectorKind::Theme,
4800    );
4801}
4802
4803// ===========================================================================
4804// Feasible selectors — /thinking, /tools, /images
4805// ===========================================================================
4806
4807/// One-line descriptions for each thinking level, ported from
4808/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
4809fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4810    use rpi_ai::types::ThinkingLevel::*;
4811    match level {
4812        Off => "Off — No reasoning",
4813        Minimal => "Minimal — Brief reasoning (~1k tokens)",
4814        Low => "Low — Light reasoning (~1k tokens)",
4815        Medium => "Medium — Moderate reasoning (~80% of max)",
4816        High => "High — Extensive reasoning (~95% of max)",
4817        Xhigh => "Xhigh — Near-maximal reasoning",
4818        Max => "Max — Maximum reasoning",
4819    }
4820}
4821
4822/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
4823/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
4824fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4825    use rpi_ai::types::ThinkingLevel::*;
4826    match level {
4827        Off => "off",
4828        Minimal => "minimal",
4829        Low => "low",
4830        Medium => "medium",
4831        High => "high",
4832        Xhigh => "xhigh",
4833        Max => "max",
4834    }
4835}
4836
4837/// Parse a thinking-level name back to the enum (case-insensitive). Returns
4838/// `None` for an unknown name; used by the `/thinking` selector callback.
4839fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
4840    use rpi_ai::types::ThinkingLevel::*;
4841    match name.to_ascii_lowercase().as_str() {
4842        "off" => Some(Off),
4843        "minimal" => Some(Minimal),
4844        "low" => Some(Low),
4845        "medium" => Some(Medium),
4846        "high" => Some(High),
4847        "xhigh" => Some(Xhigh),
4848        "max" => Some(Max),
4849        _ => None,
4850    }
4851}
4852
4853/// Build + open the `/thinking` selector. Items are the levels the current
4854/// model supports (`Model::supported_thinking_levels`), each with a
4855/// description; the current level (read beforehand via `lane.get_thinking_level`)
4856/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
4857///
4858/// `on_select` fires on the blocking key thread, so it can't await
4859/// `lane.get_thinking_level()` to know the current level — the opener resolves
4860/// it first (best-effort) and preselects; the toggle on_select just applies
4861/// whatever was picked.
4862fn open_thinking_selector(
4863    state: &Arc<TuiState>,
4864    editor_container: &Arc<Container>,
4865    editor: &Arc<Editor>,
4866    tui: &Arc<TuiAltScreen>,
4867    lane: &Arc<dyn AgentLane>,
4868    catalog: &[rpi_ai::Model],
4869    lane_model_id: &str,
4870    chat: &Arc<Container>,
4871) {
4872    // Find the current model in the catalog to read its supported levels. If
4873    // absent, fall back to all levels so the selector still opens.
4874    let model = catalog
4875        .iter()
4876        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
4877    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
4878        .map(|m| m.supported_thinking_levels())
4879        .unwrap_or_else(|| {
4880            use rpi_ai::types::ThinkingLevel::*;
4881            vec![Off, Minimal, Low, Medium, High]
4882        });
4883    let mut items: Vec<SelectItem> = Vec::new();
4884    for lvl in &levels {
4885        let name = thinking_level_name(*lvl);
4886        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
4887    }
4888    if items.is_empty() {
4889        add_note_message(chat, "This model has no supported thinking levels.");
4890        tui.request_render(false);
4891        return;
4892    }
4893    let list = Arc::new(SelectList::new(items, 10));
4894
4895    let state_sel = state.clone();
4896    let ec_sel = editor_container.clone();
4897    let editor_sel = editor.clone();
4898    let tui_sel = tui.clone();
4899    let chat_sel = chat.clone();
4900    let lane_sel = lane.clone();
4901    list.on_select(Arc::new(move |item| {
4902        let Some(level) = thinking_level_from_name(&item.value) else {
4903            add_note_message(
4904                &chat_sel,
4905                &format!("Unknown thinking level: {}.", item.label),
4906            );
4907            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4908            return;
4909        };
4910        let lane = lane_sel.clone();
4911        let footer_sel = state_sel.footer.clone();
4912        tokio::spawn(async move {
4913            let _ = lane.set_thinking_level(level).await;
4914        });
4915        // Reflect the chosen level in the footer's model suffix (pi parity:
4916        // `model • thinking off` / `model • medium`). The shown text for the
4917        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
4918        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
4919        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
4920        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4921    }));
4922    let state_cancel = state.clone();
4923    let ec_cancel = editor_container.clone();
4924    let editor_cancel = editor.clone();
4925    let tui_cancel = tui.clone();
4926    list.on_cancel(Arc::new(move || {
4927        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4928    }));
4929
4930    open_selector(
4931        state,
4932        editor_container,
4933        editor,
4934        tui,
4935        list,
4936        SelectorKind::Thinking,
4937    );
4938}
4939
4940/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
4941/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
4942/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
4943/// — the blocking key thread can't await) and selecting a tool **toggles** it
4944/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
4945fn open_tools_selector(
4946    state: &Arc<TuiState>,
4947    editor_container: &Arc<Container>,
4948    editor: &Arc<Editor>,
4949    tui: &Arc<TuiAltScreen>,
4950    lane: &Arc<dyn AgentLane>,
4951    chat: &Arc<Container>,
4952) {
4953    // Best-effort read of the current active set. The opener runs on the async
4954    // runtime (it's called from the main loop's channel dispatch or the submit
4955    // closure that lives on the blocking thread — but `handle.block_on` is safe
4956    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
4957    let active = match tokio::runtime::Handle::try_current() {
4958        Ok(h) => h
4959            .block_on(async { lane.get_active_tools().await })
4960            .unwrap_or_default(),
4961        Err(_) => Vec::new(),
4962    };
4963    let mut items: Vec<SelectItem> = Vec::new();
4964    for name in crate::session::BUILTIN_TOOL_NAMES {
4965        let on = active.iter().any(|a| a == name);
4966        let label = if on {
4967            format!("{name} (on)")
4968        } else {
4969            (*name).to_string()
4970        };
4971        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
4972    }
4973    let list = Arc::new(SelectList::new(items, 10));
4974
4975    // Capture the active set so on_select can toggle without re-reading.
4976    let active_captured = active.clone();
4977    let state_sel = state.clone();
4978    let ec_sel = editor_container.clone();
4979    let editor_sel = editor.clone();
4980    let tui_sel = tui.clone();
4981    let chat_sel = chat.clone();
4982    let lane_sel = lane.clone();
4983    list.on_select(Arc::new(move |item| {
4984        let mut next = active_captured.clone();
4985        if let Some(pos) = next.iter().position(|a| a == &item.value) {
4986            next.remove(pos);
4987        } else {
4988            next.push(item.value.clone());
4989        }
4990        let on = next.iter().any(|a| a == &item.value);
4991        let lane = lane_sel.clone();
4992        let next_clone = next.clone();
4993        tokio::spawn(async move {
4994            let _ = lane.set_active_tools(next_clone).await;
4995        });
4996        let list_str = if next.is_empty() {
4997            "(none)".to_string()
4998        } else {
4999            next.join(", ")
5000        };
5001        add_note_message(
5002            &chat_sel,
5003            &format!(
5004                "{} {} — active tools: {}",
5005                item.value,
5006                if on { "enabled" } else { "disabled" },
5007                list_str
5008            ),
5009        );
5010        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
5011    }));
5012    let state_cancel = state.clone();
5013    let ec_cancel = editor_container.clone();
5014    let editor_cancel = editor.clone();
5015    let tui_cancel = tui.clone();
5016    list.on_cancel(Arc::new(move || {
5017        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
5018    }));
5019
5020    open_selector(
5021        state,
5022        editor_container,
5023        editor,
5024        tui,
5025        list,
5026        SelectorKind::Tools,
5027    );
5028}
5029
5030/// Build + open the `/images` selector (Yes/No). Stores the choice in
5031/// `state.show_images` and notes it. Image wiring is minimal this pass — the
5032/// flag is consulted where images would be shown and echoed back here.
5033fn open_images_selector(
5034    state: &Arc<TuiState>,
5035    editor_container: &Arc<Container>,
5036    editor: &Arc<Editor>,
5037    tui: &Arc<TuiAltScreen>,
5038    chat: &Arc<Container>,
5039) {
5040    let current = *state.show_images.lock().unwrap();
5041    let items = vec![
5042        SelectItem::new("yes", "Yes").with_description(if current {
5043            "Inline images (current)"
5044        } else {
5045            "Inline images"
5046        }),
5047        SelectItem::new("no", "No").with_description(if current {
5048            "Placeholder only"
5049        } else {
5050            "Placeholder only (current)"
5051        }),
5052    ];
5053    let list = Arc::new(SelectList::new(items, 5));
5054
5055    let state_sel = state.clone();
5056    let ec_sel = editor_container.clone();
5057    let editor_sel = editor.clone();
5058    let tui_sel = tui.clone();
5059    let chat_sel = chat.clone();
5060    list.on_select(Arc::new(move |item| {
5061        let on = item.value == "yes";
5062        *state_sel.show_images.lock().unwrap() = on;
5063        add_note_message(
5064            &chat_sel,
5065            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
5066        );
5067        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
5068    }));
5069    let state_cancel = state.clone();
5070    let ec_cancel = editor_container.clone();
5071    let editor_cancel = editor.clone();
5072    let tui_cancel = tui.clone();
5073    list.on_cancel(Arc::new(move || {
5074        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
5075    }));
5076
5077    open_selector(
5078        state,
5079        editor_container,
5080        editor,
5081        tui,
5082        list,
5083        SelectorKind::Images,
5084    );
5085}
5086
5087// ===========================================================================
5088// Autocomplete
5089// ===========================================================================
5090
5091/// Refresh the autocomplete suggestion list from the current editor text +
5092/// cursor. Renders the suggestions into `autocomplete_container` (above the
5093/// editor) or clears it when there are none.
5094fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
5095    let text = editor.get_text();
5096    let (_row, col) = editor.cursor_position();
5097    // The editor's `cursor_col` is a byte offset into the current line; for
5098    // single-line input (the common case) that equals the byte offset into
5099    // `get_text()`, which is exactly what the autocomplete providers expect to
5100    // slice on. Clamp to the text length so a stale/multi-line col can't
5101    // overshoot. Providers snap to a char boundary internally as a safety net
5102    // (`autocomplete::snap_cursor`), so a byte col landing mid-character never
5103    // panics.
5104    let cursor = col.min(text.len());
5105    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
5106    render_autocomplete(state, suggestions);
5107}
5108
5109/// Render (or clear) the autocomplete suggestion list into the container.
5110fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
5111    state.autocomplete_container.clear();
5112    let Some(sugg) = suggestions else {
5113        return;
5114    };
5115    if sugg.items.is_empty() {
5116        return;
5117    }
5118    // Build a compact list: top item marked with `→`, rest with `  `.
5119    // Cap at 5 lines so the dock doesn't swallow the transcript.
5120    let accent = state.theme_manager.get().colors.accent;
5121    let muted = state.theme_manager.get().colors.muted;
5122    for (i, item) in sugg.items.iter().take(5).enumerate() {
5123        let prefix = if i == 0 { "→ " } else { "  " };
5124        let label = item.display_text();
5125        let line = if i == 0 {
5126            format!(
5127                "{prefix}{} {}",
5128                accent.fg(label),
5129                muted.fg(item.description.as_deref().unwrap_or(""))
5130            )
5131        } else {
5132            format!(
5133                "{prefix}{} {}",
5134                muted.fg(label),
5135                muted.fg(item.description.as_deref().unwrap_or(""))
5136            )
5137        };
5138        state
5139            .autocomplete_container
5140            .add_child(Arc::new(Text::new(line, 1, 0)));
5141    }
5142}
5143
5144/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
5145/// suggestion text, reposition the caret, and clear the suggestion list.
5146/// Returns `true` if a suggestion was accepted.
5147fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
5148    let text = editor.get_text();
5149    let (_row, col) = editor.cursor_position();
5150    let cursor = col.min(text.len());
5151    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
5152        return false;
5153    };
5154    let Some(top) = sugg.items.first() else {
5155        return false;
5156    };
5157    // Replace the [start, end) span with the suggestion text. `start`/`end`
5158    // are byte offsets emitted by the providers on char boundaries, so the
5159    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
5160    let start = sugg.start.min(text.len());
5161    let end = sugg.end.min(text.len());
5162    let mut replaced = String::with_capacity(text.len() + top.text.len());
5163    replaced.push_str(&text[..start]);
5164    replaced.push_str(&top.text);
5165    // Keep the text AFTER the replaced span (mid-line completion: replacing
5166    // `[start, end)` must not drop the rest of the line).
5167    replaced.push_str(&text[end..]);
5168    if top.insert_space && !replaced.ends_with('/') {
5169        replaced.push(' ');
5170    }
5171    // New caret position: after the inserted text (byte offset; the editor
5172    // snaps `set_cursor` to a char boundary as a safety net).
5173    let new_cursor = replaced.len().min(
5174        start
5175            + top.text.len()
5176            + if top.insert_space && !top.text.ends_with('/') {
5177                1
5178            } else {
5179                0
5180            },
5181    );
5182    editor.set_text(&replaced);
5183    editor.set_cursor(0, new_cursor);
5184    state.autocomplete_container.clear();
5185    true
5186}
5187
5188// ===========================================================================
5189// Transcript message helpers
5190// ===========================================================================
5191
5192/// Add the welcome header to the chat container.
5193fn add_welcome_message(container: &Arc<Container>) {
5194    add_welcome_message_with_capabilities(container, &[], &[]);
5195}
5196
5197/// Add the startup welcome header and a compact snapshot of active tools and
5198/// discovered skills. The snapshot reflects the harness configuration used by
5199/// the first turn, including tools contributed by extensions.
5200fn add_welcome_message_with_capabilities(
5201    container: &Arc<Container>,
5202    active_tools: &[String],
5203    skills: &[String],
5204) {
5205    let c = current_theme().colors;
5206    // Accent logotype + a dim tagline, separated from the rest by a thin
5207    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
5208    // to the body text, so the header didn't read as a header.
5209    let title = format!(
5210        "{} {}",
5211        c.accent.fg(&tui_bold("rpi")),
5212        c.muted.fg("interactive TUI")
5213    );
5214    container.add_child(Arc::new(Text::new(title, 1, 0)));
5215    container.add_child(Arc::new(Spacer::new(1)));
5216    container.add_child(Arc::new(Text::new(
5217        c.dim.fg("Type your message and press Enter to send."),
5218        1,
5219        0,
5220    )));
5221    let hint = c
5222        .dim
5223        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
5224    container.add_child(Arc::new(Text::new(hint, 1, 0)));
5225    container.add_child(Arc::new(Spacer::new(1)));
5226    container.add_child(Arc::new(Text::new(
5227        welcome_capability_line("Tools", active_tools),
5228        1,
5229        0,
5230    )));
5231    container.add_child(Arc::new(Text::new(
5232        welcome_capability_line("Skills", skills),
5233        1,
5234        0,
5235    )));
5236    container.add_child(Arc::new(DynamicBorder::new()));
5237}
5238
5239fn welcome_capability_line(label: &str, names: &[String]) -> String {
5240    let c = current_theme().colors;
5241    let value = if names.is_empty() {
5242        "none".to_string()
5243    } else {
5244        names.join(" · ")
5245    };
5246    format!(
5247        "{} {}",
5248        c.accent.fg(&format!("{label} ({})", names.len())),
5249        c.muted.fg(&value)
5250    )
5251}
5252
5253/// Add the `/help` command listing to the chat container.
5254fn add_help_message(container: &Arc<Container>) {
5255    let c = current_theme().colors;
5256    // Section header + a thin themed rule, then a two-column command table:
5257    // `cmd` in accent, `— desc` in muted. The old single-space layout made
5258    // the description column wander depending on command length.
5259    container.add_child(Arc::new(Text::new(
5260        c.md_heading.fg(&tui_bold("📚 Available Commands")),
5261        1,
5262        0,
5263    )));
5264    container.add_child(Arc::new(Spacer::new(1)));
5265
5266    let cmds: &[(&str, &str)] = &[
5267        ("/help, /?", "Show this help message"),
5268        ("/clear, /new", "Clear the conversation"),
5269        ("/exit, /quit, /q", "Exit the application"),
5270        ("/version, /v", "Show version information"),
5271        ("/model, /m", "Choose a model (live switch)"),
5272        ("/thinking, /think", "Set reasoning depth (selector)"),
5273        ("/tools", "Toggle built-in tools on/off"),
5274        ("/images", "Toggle inline image rendering"),
5275        ("/session", "List saved sessions"),
5276        ("/theme", "Choose a theme (selector)"),
5277        ("/compact", "Compact the conversation"),
5278        ("/copy", "Copy last reply to clipboard"),
5279        ("/hotkeys", "Show keyboard shortcuts"),
5280        ("/armin", "🐾 Easter egg"),
5281        ("/earendil", "Earendil announcement"),
5282    ];
5283    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5284    for (cmd, desc) in cmds {
5285        let row = format!(
5286            "  {:<cmd_w$}  {}  {}",
5287            c.accent.fg(cmd),
5288            c.dim.fg("—"),
5289            c.muted.fg(desc)
5290        );
5291        container.add_child(Arc::new(Text::new(row, 1, 0)));
5292    }
5293    container.add_child(Arc::new(Spacer::new(1)));
5294}
5295
5296/// Add the `/version` block to the chat container.
5297fn add_version_message(container: &Arc<Container>) {
5298    let c = current_theme().colors;
5299    container.add_child(Arc::new(Text::new(
5300        c.md_heading.fg(&tui_bold("📦 Version Information")),
5301        1,
5302        0,
5303    )));
5304    container.add_child(Arc::new(Spacer::new(1)));
5305    // Use the crate version (kept in sync via `version.workspace = true`)
5306    // instead of the stale hardcoded "v0.1.2".
5307    container.add_child(Arc::new(Text::new(
5308        format!(
5309            "  {} {}",
5310            c.muted.fg("rpi-cli"),
5311            c.text.fg(&format!("v{}", crate::VERSION))
5312        ),
5313        1,
5314        0,
5315    )));
5316    container.add_child(Arc::new(Text::new(
5317        format!(
5318            "  {}",
5319            c.dim.fg("Rust implementation of pi coding agent TUI")
5320        ),
5321        1,
5322        0,
5323    )));
5324    container.add_child(Arc::new(Spacer::new(1)));
5325}
5326
5327/// Add the `/hotkeys` block to the chat container.
5328fn add_hotkeys_message(container: &Arc<Container>) {
5329    let c = current_theme().colors;
5330    container.add_child(Arc::new(Text::new(
5331        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
5332        1,
5333        0,
5334    )));
5335    container.add_child(Arc::new(Spacer::new(1)));
5336    let keys: &[(&str, &str)] = &[
5337        ("Enter", "Send message"),
5338        ("Shift+Enter", "New line"),
5339        ("Tab", "Accept autocomplete suggestion"),
5340        ("Ctrl+A / Ctrl+E", "Line start / end"),
5341        (
5342            "Ctrl+K / Ctrl+U",
5343            "Kill to end / start of line (Ctrl+Y yanks)",
5344        ),
5345        ("Ctrl+- / Ctrl+R", "Undo / redo"),
5346        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
5347        ("Alt+Backspace", "Kill previous word"),
5348        ("Ctrl+C", "Abort a run, or exit when idle"),
5349        ("Esc", "Abort a running prompt"),
5350        ("Ctrl+L", "Open model selector"),
5351        ("Ctrl+M", "Cycle to the next model (live)"),
5352        ("Ctrl+T", "Expand/collapse last tool result"),
5353        ("PageUp/Down", "Scroll transcript by one page"),
5354        ("Home / End", "Jump to transcript start / latest output"),
5355    ];
5356    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5357    for (key, desc) in keys {
5358        let row = format!(
5359            "  {:<key_w$}  {}  {}",
5360            c.accent.fg(key),
5361            c.dim.fg("—"),
5362            c.muted.fg(desc)
5363        );
5364        container.add_child(Arc::new(Text::new(row, 1, 0)));
5365    }
5366    container.add_child(Arc::new(Spacer::new(1)));
5367}
5368
5369/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
5370/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
5371/// plain `> text` echo. A trailing Spacer(1) separates it from the next
5372// transcript entry (every entry contributes one trailing spacer so
5373// consecutive turns are separated by exactly one blank line).
5374fn add_user_message(container: &Arc<Container>, text: &str) {
5375    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
5376    container.add_child(Arc::new(Spacer::new(1)));
5377}
5378
5379/// Add an error message to the chat container.
5380fn add_error_message(container: &Arc<Container>, text: &str) {
5381    let c = current_theme().colors;
5382    container.add_child(Arc::new(Text::new(
5383        format!("  {} {}", c.error.fg("✗"), c.error.fg(text)),
5384        1,
5385        0,
5386    )));
5387    container.add_child(Arc::new(Spacer::new(1)));
5388}
5389
5390/// Add a neutral note (e.g. unsupported-command message) to the chat container.
5391fn add_note_message(container: &Arc<Container>, text: &str) {
5392    let c = current_theme().colors;
5393    container.add_child(Arc::new(Text::new(
5394        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
5395        1,
5396        0,
5397    )));
5398    container.add_child(Arc::new(Spacer::new(1)));
5399}
5400
5401/// Render the `/context` panel: a transcript message listing the discovered
5402/// context files, skills, and prompt templates loaded for this session
5403/// (Part A resource discovery). Reads the harness resources snapshot captured
5404/// at TUI startup (the blocking submit handler can't `await get_resources()`.
5405///
5406/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
5407/// via `/reload`); here it's a transcript note rather than an overlay since the
5408/// resource set is session-static between `/reload`s (deferred).
5409fn show_context_panel(
5410    chat: &Arc<Container>,
5411    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
5412) {
5413    let skills = resources.skills.as_deref().unwrap_or(&[]);
5414    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
5415    let mut lines: Vec<String> = Vec::new();
5416    lines.push("📂 Discovered resources for this session:".into());
5417
5418    if skills.is_empty() {
5419        lines.push(
5420            "  Skills: (none discovered — create .pi/skills/ or ~/.rpi/agent/skills/)".into(),
5421        );
5422    } else {
5423        lines.push(format!("  Skills ({}):", skills.len()));
5424        for s in skills {
5425            let marker = if s.disable_model_invocation == Some(true) {
5426                " [hidden]"
5427            } else {
5428                ""
5429            };
5430            let desc: String = s.description.chars().take(72).collect();
5431            lines.push(format!("    • {}{marker} — {desc}", s.name));
5432        }
5433    }
5434
5435    if templates.is_empty() {
5436        lines.push(
5437            "  Prompt templates: (none — create .pi/prompts/ or ~/.rpi/agent/prompts/)".into(),
5438        );
5439    } else {
5440        lines.push(format!("  Prompt templates ({}):", templates.len()));
5441        for t in templates {
5442            let desc = t
5443                .description
5444                .as_deref()
5445                .unwrap_or("(no description)")
5446                .chars()
5447                .take(72)
5448                .collect::<String>();
5449            lines.push(format!("    • /{} — {desc}", t.name));
5450        }
5451    }
5452    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
5453    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
5454    lines.push(
5455        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
5456            .into(),
5457    );
5458    let body = lines.join("\n");
5459    container_note_block(chat, &body);
5460}
5461
5462/// Append a multi-line neutral note (header line + body) to the chat container.
5463fn container_note_block(container: &Arc<Container>, body: &str) {
5464    for line in body.lines() {
5465        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
5466    }
5467    container.add_child(Arc::new(Spacer::new(1)));
5468}
5469
5470// ===========================================================================
5471// TUI support + entry detection
5472// ===========================================================================
5473
5474/// Check if the terminal supports TUI mode.
5475pub fn is_tui_supported() -> bool {
5476    std::io::stdout().is_terminal()
5477}
5478
5479// Keep the `Color` import used (theme accent rendering in autocomplete).
5480#[allow(unused_imports)]
5481use rpi_tui::Color as _Color;
5482
5483#[cfg(test)]
5484mod tests {
5485    use super::*;
5486    use rpi_tui::Component;
5487
5488    #[test]
5489    fn transcript_page_uses_viewport_with_overlap() {
5490        assert_eq!(transcript_page_size(24), 20);
5491        assert_eq!(transcript_page_size(4), 1);
5492        assert_eq!(transcript_page_size(0), 1);
5493    }
5494
5495    #[test]
5496    fn key_repeat_is_dispatched_but_release_is_not() {
5497        assert!(should_dispatch_key(KeyEventKind::Press));
5498        assert!(should_dispatch_key(KeyEventKind::Repeat));
5499        assert!(!should_dispatch_key(KeyEventKind::Release));
5500    }
5501
5502    #[test]
5503    fn test_layout_renders_welcome_message() {
5504        let chat = Arc::new(Container::new());
5505        add_welcome_message(&chat);
5506
5507        let scroll = Arc::new(ScrollView::new(
5508            chat.clone(),
5509            ScrollViewOptions {
5510                follow: FollowMode::End,
5511                primary: true,
5512                ..Default::default()
5513            },
5514        ));
5515
5516        let editor = Arc::new(Editor::new(
5517            EditorOptions {
5518                padding_x: 1,
5519                ..Default::default()
5520            },
5521            EditorStyle::default(),
5522            Arc::new(rpi_tui::Keybindings::new()),
5523        ));
5524        let dock = Arc::new(Container::new());
5525        dock.add_child(editor);
5526
5527        let footer = Arc::new(FooterComponent::new());
5528
5529        let root = VStack::from_children(vec![
5530            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
5531            StackChild::Entry(StackEntry::new(dock)),
5532            StackChild::Entry(StackEntry::new(footer)),
5533        ]);
5534
5535        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
5536
5537        let all: String = frame.lines.join("\n");
5538        assert!(
5539            all.contains("rpi"),
5540            "Welcome message not found. Rendered: {}",
5541            all
5542        );
5543        assert!(
5544            all.contains("Type your message"),
5545            "Help text not found. Rendered: {}",
5546            all
5547        );
5548    }
5549
5550    #[test]
5551    fn test_chat_container_has_welcome_content() {
5552        let chat = Arc::new(Container::new());
5553        add_welcome_message_with_capabilities(
5554            &chat,
5555            &["read".into(), "bash".into(), "web_fetch".into()],
5556            &["rust-review".into(), "release".into()],
5557        );
5558
5559        let lines = chat.render(80);
5560        let all: String = lines.join("\n");
5561        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
5562        // joined by an ANSI reset — strip ANSI before checking the substring.
5563        let plain = strip_ansi(&all);
5564        assert!(
5565            plain.contains("rpi"),
5566            "Welcome message not in chat container: {:?}",
5567            lines
5568        );
5569        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
5570        assert!(
5571            plain.contains("read · bash · web_fetch"),
5572            "Tool names missing: {plain}"
5573        );
5574        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
5575        assert!(
5576            plain.contains("rust-review · release"),
5577            "Skill names missing: {plain}"
5578        );
5579    }
5580
5581    #[test]
5582    fn welcome_capabilities_show_empty_state() {
5583        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
5584        assert_eq!(plain, "Skills (0) none");
5585    }
5586
5587    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
5588    /// replaces the editor text, the NEXT rendered frame must show the
5589    /// completed text (" /model " with the caret after it), not the old
5590    /// prefix. Mirrors the real dock layout (autocomplete_container above the
5591    /// bordered editor) and drives the same accept path the Tab handler uses.
5592    #[test]
5593    fn tab_accept_suggestion_reflects_in_next_render() {
5594        use rpi_tui::render_layout_frame;
5595
5596        let editor = Arc::new(Editor::new(
5597            EditorOptions {
5598                padding_x: 1,
5599                ..Default::default()
5600            },
5601            EditorStyle::default(),
5602            Arc::new(rpi_tui::Keybindings::new()),
5603        ));
5604        editor.set_focused(true);
5605        let editor_container = Arc::new(Container::new());
5606        editor_container.add_child(editor.clone());
5607        let autocomplete_container = Arc::new(Container::new());
5608        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
5609        let dock = Arc::new(VStack::from_children(vec![
5610            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5611            StackChild::Entry(
5612                StackEntry::new(editor_container.clone())
5613                    .shrink(0)
5614                    .min_size(3),
5615            ),
5616            StackChild::Entry(StackEntry::new(footer)),
5617        ]));
5618
5619        // Simulate the user typing "/mo" (the popup shows suggestions).
5620        let mut manager = AutocompleteManager::new();
5621        let mut combined = CombinedAutocompleteProvider::new();
5622        combined.add_provider(Arc::new(
5623            SlashCommandAutocompleteProvider::with_default_commands(),
5624        ));
5625        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
5626        manager.set_provider(Arc::new(combined));
5627        // Simulate typing "/mo" via the real insert path (advances the caret
5628        // by char length, like `handle_key` does).
5629        editor.insert("/mo");
5630        assert_eq!(editor.cursor_position(), (0, 3));
5631
5632        let frame_before = render_layout_frame(dock.clone(), 80, 10);
5633        assert!(
5634            frame_before.lines.iter().any(|l| l.contains("/mo")),
5635            "precondition: editor shows the typed prefix. Frame rows:\n{}",
5636            frame_before
5637                .lines
5638                .iter()
5639                .map(|l| format!("  [{l}]"))
5640                .collect::<Vec<_>>()
5641                .join("\n")
5642        );
5643
5644        // Tab: accept the top suggestion (the same code path as the key loop).
5645        let text = editor.get_text();
5646        let (_row, col) = editor.cursor_position();
5647        let cursor = col.min(text.len());
5648        let sugg = manager
5649            .get_suggestions(&text, cursor)
5650            .expect("slash suggestions for /mo");
5651        let top = sugg.items.first().expect("at least one suggestion");
5652        let start = sugg.start.min(text.len());
5653        let end = sugg.end.min(text.len());
5654        let mut replaced = String::new();
5655        replaced.push_str(&text[..start]);
5656        replaced.push_str(&top.text);
5657        replaced.push_str(&text[end..]);
5658        if top.insert_space && !replaced.ends_with('/') {
5659            replaced.push(' ');
5660        }
5661        editor.set_text(&replaced);
5662        editor.set_cursor(0, replaced.len().min(start + top.text.len()));
5663        autocomplete_container.clear();
5664        assert_eq!(editor.get_text(), "/model");
5665
5666        // The next render MUST display the completed text.
5667        let frame_after = render_layout_frame(dock, 80, 10);
5668        let all: String = frame_after.lines.join("\n");
5669        assert!(
5670            all.contains("/model"),
5671            "completed text missing from next render. Got:\n{all}"
5672        );
5673        // The caret must sit AFTER the completed command (the snap_boundary
5674        // regression put it one char early: "/mode|l" with the final char
5675        // dangling past the caret).
5676        let editor_line = frame_after
5677            .lines
5678            .iter()
5679            .find(|l| l.contains("/model"))
5680            .expect("editor row with completed text");
5681        assert!(
5682            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
5683            "caret must follow the full completed text. Got: {editor_line:?}"
5684        );
5685    }
5686
5687    #[test]
5688    fn test_slash_command_dispatch() {
5689        // The registry is the single source of truth for dispatch: `find(token)`
5690        // returns the command (by name or alias) whose `name()` is the canonical
5691        // form, or `None` for an unknown token. This replaces the old enum-based
5692        // `handle_slash_command` assertions with equivalent registry lookups.
5693        let registry = build_builtin_registry();
5694
5695        // Helper: a token resolves to the command with this canonical name.
5696        let resolves_to = |token: &str, canonical: &str| {
5697            let found = registry.find(token).expect("{token} should resolve");
5698            assert_eq!(
5699                found.name(),
5700                canonical,
5701                "{token} resolved to {} (expected {canonical})",
5702                found.name()
5703            );
5704        };
5705
5706        resolves_to("/help", "/help");
5707        resolves_to("/?", "/help"); // alias → canonical
5708        resolves_to("/clear", "/clear");
5709        resolves_to("/new", "/clear"); // alias
5710        resolves_to("/q", "/exit"); // alias
5711        resolves_to("/quit", "/exit"); // alias
5712        resolves_to("/version", "/version");
5713        resolves_to("/v", "/version"); // alias
5714        resolves_to("/hotkeys", "/hotkeys");
5715        resolves_to("/model", "/model");
5716        resolves_to("/m", "/model"); // alias
5717        resolves_to("/theme", "/theme");
5718        resolves_to("/session", "/session");
5719        resolves_to("/resume", "/session"); // alias
5720        resolves_to("/compact", "/compact");
5721        resolves_to("/copy", "/copy");
5722        resolves_to("/thinking", "/thinking");
5723        resolves_to("/think", "/thinking"); // alias
5724        resolves_to("/tools", "/tools");
5725        resolves_to("/images", "/images");
5726        resolves_to("/armin", "/armin");
5727        resolves_to("/earendil", "/earendil");
5728        resolves_to("/context", "/context");
5729        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
5730        resolves_to("/settings", "/settings");
5731        resolves_to("/name", "/name");
5732        resolves_to("/export", "/export");
5733
5734        // Unknown token → not found.
5735        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
5736    }
5737
5738    #[test]
5739
5740    fn test_registry_visible_entries_cover_dispatch() {
5741        // The autocomplete list is derived from the registry, so every visible
5742        // command the dispatcher recognizes must appear in it — by construction,
5743        // but this guards against a future command being registered with
5744        // `visible()` / a non-empty description that the builder drops.
5745        let registry = build_builtin_registry();
5746        let names: Vec<String> = registry
5747            .visible_entries()
5748            .iter()
5749            .map(|c| c.name.clone())
5750            .collect();
5751        for recognized in [
5752            "/help",
5753            "/clear",
5754            "/new",
5755            "/exit",
5756            "/quit",
5757            "/version",
5758            "/model",
5759            "/session",
5760            "/theme",
5761            "/compact",
5762            "/copy",
5763            "/hotkeys",
5764            "/tools",
5765            "/images",
5766            "/thinking",
5767            "/armin",
5768            "/earendil",
5769        ] {
5770            assert!(
5771                names.contains(&recognized.to_string()),
5772                "{recognized} missing from autocomplete list"
5773            );
5774        }
5775        // Hidden commands stay off the list.
5776        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
5777            assert!(
5778                !names.contains(&hidden.to_string()),
5779                "{hidden} should be hidden from autocomplete"
5780            );
5781        }
5782    }
5783
5784    #[test]
5785    fn test_agent_event_mapping_creates_assistant_and_tool() {
5786        // Synthetic AgentEvent sequence → UI mutations, exercised against the
5787        // real drain handler with a no-op TUI stand-in.
5788        use rpi_ai::types::{
5789            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
5790            ToolCall, ToolCallType, Usage,
5791        };
5792
5793        let state = Arc::new(TuiState {
5794            current_assistant: std::sync::Mutex::new(None),
5795            tool_components: std::sync::Mutex::new(HashMap::new()),
5796            bash_components: std::sync::Mutex::new(HashMap::new()),
5797            last_tool_comp: std::sync::Mutex::new(None),
5798            status: std::sync::Mutex::new(RunStatus::Idle),
5799            footer: Arc::new(FooterComponent::new()),
5800            status_container: Arc::new(Container::new()),
5801            chat_container: Arc::new(Container::new()),
5802            loader: Arc::new(Loader::new()),
5803            last_assistant_text: std::sync::Mutex::new(String::new()),
5804            active_selector: std::sync::Mutex::new(None),
5805            active_extension_editor: std::sync::Mutex::new(None),
5806            autocomplete: AutocompleteManager::new(),
5807            autocomplete_container: Arc::new(Container::new()),
5808            theme_manager: Arc::new(ThemeManager::new()),
5809            tui: None,
5810            current_model_id: std::sync::Mutex::new(String::new()),
5811            show_images: std::sync::Mutex::new(true),
5812            history: std::sync::Mutex::new(Vec::new()),
5813            history_index: std::sync::Mutex::new(-1),
5814            history_draft: std::sync::Mutex::new(None),
5815            last_input_tokens: std::sync::Mutex::new(0),
5816            scoped_edit: std::sync::Mutex::new(None),
5817            markdown_transformer: std::sync::Mutex::new(None),
5818            extension_session: Arc::new(std::sync::Mutex::new(
5819                rpi_extensions::ExtensionSession::none(),
5820            )),
5821        });
5822
5823        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
5824        // terminal; instead, exercise the *mutation* half directly against a
5825        // captured chat container via a synthetic message-start event's data.
5826        let assistant = AssistantMessage {
5827            role: rpi_ai::types::AssistantRole,
5828            content: vec![
5829                Content::Thinking(ThinkingContent {
5830                    kind: ThinkingContentType,
5831                    thinking: "Reasoning about the reply.".into(),
5832                    thinking_signature: None,
5833                    redacted: false,
5834                }),
5835                Content::Text(TextContent {
5836                    kind: TextContentType,
5837                    text: "Hello.".into(),
5838                    text_signature: None,
5839                }),
5840                Content::ToolCall(ToolCall {
5841                    kind: ToolCallType,
5842                    id: "tc1".into(),
5843                    name: "bash".into(),
5844                    arguments: serde_json::json!({"command": "echo hi"}),
5845                    thought_signature: None,
5846                    namespace: None,
5847                }),
5848            ],
5849            api: rpi_ai::Api::AnthropicMessages,
5850            provider: "anthropic".into(),
5851            model: "claude-sonnet-5".into(),
5852            response_model: None,
5853            response_id: None,
5854            usage: Usage::zero(),
5855            stop_reason: StopReason::Stop,
5856            deferred: None,
5857            error_message: None,
5858            raw_stop_reason: None,
5859            end_turn: None,
5860            timestamp: 0,
5861        };
5862
5863        // Manually apply the MessageStart assistant branch logic (mirrors the
5864        // drain handler, without needing a TuiAltScreen).
5865        let comp = Arc::new(AssistantMessageComponent::new(
5866            AssistantMessageOptions::default(),
5867        ));
5868        comp.set_streaming(true);
5869        comp.update_blocks(&assistant_blocks(&assistant));
5870        let chat = Arc::new(Container::new());
5871        chat.add_child(comp.clone());
5872        *state.current_assistant.lock().unwrap() = Some(comp);
5873
5874        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
5875        for c in &assistant.content {
5876            if let Content::ToolCall(tc) = c {
5877                let mut tools = state.tool_components.lock().unwrap();
5878                if !tools.contains_key(&tc.id) {
5879                    let tc_comp = Arc::new(ToolExecutionComponent::new(
5880                        &tc.name,
5881                        &tc.arguments.to_string(),
5882                    ));
5883                    tc_comp.set_running();
5884                    chat.add_child(tc_comp.clone());
5885                    tools.insert(tc.id.clone(), tc_comp);
5886                }
5887            }
5888        }
5889
5890        // Assert: the assistant component rendered the text + the thinking
5891        // block (the update_blocks path keeps thinking visible), and a tool
5892        // component was registered.
5893        let rendered = chat.render(80);
5894        let joined: String = rendered.join("\n");
5895        assert!(
5896            joined.contains("Hello."),
5897            "assistant text not rendered: {joined}"
5898        );
5899        assert!(
5900            joined.contains("Reasoning about the reply."),
5901            "thinking block not rendered: {joined}"
5902        );
5903        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
5904        assert!(state.current_assistant.lock().unwrap().is_some());
5905
5906        // Manually apply ToolExecutionEnd (mirrors drain).
5907        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
5908        ended.set_result("hi", false);
5909        assert!(state.tool_components.lock().unwrap().is_empty());
5910
5911        // A running bash panel owns the visible spinner. The global loader is
5912        // hidden until the last concurrent bash tool completes, then restored
5913        // while the agent remains in the Working state.
5914        assert!(state.try_start_working());
5915        assert!(
5916            !state.try_start_working(),
5917            "a second submit must be rejected"
5918        );
5919        state.set_status(RunStatus::Idle);
5920        state.set_status(RunStatus::Working);
5921        assert_eq!(state.status_container.child_count(), 1);
5922        {
5923            let mut bash = state.bash_components.lock().unwrap();
5924            bash.insert(
5925                "bash-1".into(),
5926                Arc::new(BashExecutionComponent::new("one")),
5927            );
5928            bash.insert(
5929                "bash-2".into(),
5930                Arc::new(BashExecutionComponent::new("two")),
5931            );
5932        }
5933        state.sync_working_loader_with_bash();
5934        assert_eq!(state.status_container.child_count(), 0);
5935        state.bash_components.lock().unwrap().remove("bash-1");
5936        state.sync_working_loader_with_bash();
5937        assert_eq!(state.status_container.child_count(), 0);
5938        state.bash_components.lock().unwrap().remove("bash-2");
5939        state.sync_working_loader_with_bash();
5940        assert_eq!(state.status_container.child_count(), 1);
5941
5942        state.set_status(RunStatus::Aborting);
5943        assert_eq!(state.status_container.child_count(), 0);
5944        assert!(!state.loader.is_running());
5945    }
5946
5947    #[test]
5948    fn fresh_launch_does_not_restore_old_history() {
5949        let fresh = Args::default();
5950        assert!(!launch_restores_history(&fresh));
5951
5952        let continued = Args {
5953            continue_session: true,
5954            ..Args::default()
5955        };
5956        assert!(launch_restores_history(&continued));
5957
5958        let selected = Args {
5959            session: Some("session-id".into()),
5960            ..Args::default()
5961        };
5962        assert!(launch_restores_history(&selected));
5963    }
5964
5965    #[test]
5966    fn test_short_model_name() {
5967        assert_eq!(
5968            short_model_name("anthropic:claude-sonnet-5"),
5969            "claude-sonnet-5"
5970        );
5971        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
5972    }
5973
5974    #[test]
5975    fn test_cycle_next_model_wraps_around() {
5976        use rpi_ai::{Api, Model};
5977        let mk = |id: &str| {
5978            Model::new(
5979                id,
5980                id,
5981                Api::AnthropicMessages,
5982                "anthropic",
5983                "https://api.anthropic.com",
5984            )
5985        };
5986        let catalog = [mk("a"), mk("b"), mk("c")];
5987        // Next after "a" is "b"; after "c" wraps to "a".
5988        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
5989        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
5990        // An unknown current id falls back to the first model.
5991        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
5992        // Empty catalog yields None.
5993        let empty: Vec<Model> = vec![];
5994        assert!(cycle_next_model(&empty, "a").is_none());
5995    }
5996
5997    #[test]
5998    fn test_autocomplete_slash_suggestions_render() {
5999        // The autocomplete container should render at least one suggestion
6000        // line when the editor holds a `/` prefix, and clear when it doesn't.
6001        let state = Arc::new(TuiState {
6002            current_assistant: std::sync::Mutex::new(None),
6003            tool_components: std::sync::Mutex::new(HashMap::new()),
6004            bash_components: std::sync::Mutex::new(HashMap::new()),
6005            last_tool_comp: std::sync::Mutex::new(None),
6006            status: std::sync::Mutex::new(RunStatus::Idle),
6007            footer: Arc::new(FooterComponent::new()),
6008            status_container: Arc::new(Container::new()),
6009            chat_container: Arc::new(Container::new()),
6010            loader: Arc::new(Loader::new()),
6011            last_assistant_text: std::sync::Mutex::new(String::new()),
6012            active_selector: std::sync::Mutex::new(None),
6013            active_extension_editor: std::sync::Mutex::new(None),
6014            autocomplete: AutocompleteManager::new(),
6015            autocomplete_container: Arc::new(Container::new()),
6016            theme_manager: Arc::new(ThemeManager::new()),
6017            tui: None,
6018            current_model_id: std::sync::Mutex::new(String::new()),
6019            show_images: std::sync::Mutex::new(true),
6020            history: std::sync::Mutex::new(Vec::new()),
6021            history_index: std::sync::Mutex::new(-1),
6022            history_draft: std::sync::Mutex::new(None),
6023            last_input_tokens: std::sync::Mutex::new(0),
6024            scoped_edit: std::sync::Mutex::new(None),
6025            markdown_transformer: std::sync::Mutex::new(None),
6026            extension_session: Arc::new(std::sync::Mutex::new(
6027                rpi_extensions::ExtensionSession::none(),
6028            )),
6029        });
6030        {
6031            let mut combined = CombinedAutocompleteProvider::new();
6032            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6033                build_builtin_registry().visible_entries(),
6034            )));
6035            state.autocomplete.set_provider(Arc::new(combined));
6036        }
6037
6038        let editor = Arc::new(Editor::simple());
6039        editor.set_text("/he");
6040        editor.set_cursor(0, 3);
6041        refresh_autocomplete(&state, &editor);
6042        let lines = state.autocomplete_container.render(80);
6043        let joined: String = lines.join("\n");
6044        assert!(
6045            joined.contains("/help"),
6046            "slash suggestions not rendered: {joined}"
6047        );
6048
6049        // Clear: no suggestions for plain text.
6050        editor.set_text("hello");
6051        editor.set_cursor(0, 5);
6052        refresh_autocomplete(&state, &editor);
6053        assert!(state.autocomplete_container.render(80).is_empty());
6054    }
6055
6056    #[test]
6057    fn test_select_list_swap_restores_editor() {
6058        // The editor-container swap: opening a selector replaces the editor
6059        // child; closing restores it. Verify the container child count + the
6060        // active_selector flag round-trip.
6061        let state = Arc::new(TuiState {
6062            current_assistant: std::sync::Mutex::new(None),
6063            tool_components: std::sync::Mutex::new(HashMap::new()),
6064            bash_components: std::sync::Mutex::new(HashMap::new()),
6065            last_tool_comp: std::sync::Mutex::new(None),
6066            status: std::sync::Mutex::new(RunStatus::Idle),
6067            footer: Arc::new(FooterComponent::new()),
6068            status_container: Arc::new(Container::new()),
6069            chat_container: Arc::new(Container::new()),
6070            loader: Arc::new(Loader::new()),
6071            last_assistant_text: std::sync::Mutex::new(String::new()),
6072            active_selector: std::sync::Mutex::new(None),
6073            active_extension_editor: std::sync::Mutex::new(None),
6074            autocomplete: AutocompleteManager::new(),
6075            autocomplete_container: Arc::new(Container::new()),
6076            theme_manager: Arc::new(ThemeManager::new()),
6077            tui: None,
6078            current_model_id: std::sync::Mutex::new(String::new()),
6079            show_images: std::sync::Mutex::new(true),
6080            history: std::sync::Mutex::new(Vec::new()),
6081            history_index: std::sync::Mutex::new(-1),
6082            history_draft: std::sync::Mutex::new(None),
6083            last_input_tokens: std::sync::Mutex::new(0),
6084            scoped_edit: std::sync::Mutex::new(None),
6085            markdown_transformer: std::sync::Mutex::new(None),
6086            extension_session: Arc::new(std::sync::Mutex::new(
6087                rpi_extensions::ExtensionSession::none(),
6088            )),
6089        });
6090        let editor_container = Arc::new(Container::new());
6091        let editor = Arc::new(Editor::simple());
6092        editor_container.add_child(editor.clone());
6093        assert!(!state.selector_open());
6094
6095        let tui_terminal = Box::new(ProcessTerminal::new());
6096        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
6097        let list = Arc::new(SelectList::new(
6098            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
6099            5,
6100        ));
6101        open_selector(
6102            &state,
6103            &editor_container,
6104            &editor,
6105            &tui,
6106            list,
6107            SelectorKind::Theme,
6108        );
6109        assert!(state.selector_open());
6110        // list only (editor swapped out).
6111        assert_eq!(editor_container.child_count(), 1);
6112
6113        close_selector(&state, &editor_container, &editor, &tui);
6114        assert!(!state.selector_open());
6115        // editor restored.
6116        assert_eq!(editor_container.child_count(), 1);
6117    }
6118
6119    #[test]
6120    fn test_message_history_browse_restores_draft() {
6121        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
6122        // messages, browse older → newer → back past the newest restores the
6123        // draft the user was typing.
6124        let state = Arc::new(TuiState {
6125            current_assistant: std::sync::Mutex::new(None),
6126            tool_components: std::sync::Mutex::new(HashMap::new()),
6127            bash_components: std::sync::Mutex::new(HashMap::new()),
6128            last_tool_comp: std::sync::Mutex::new(None),
6129            status: std::sync::Mutex::new(RunStatus::Idle),
6130            footer: Arc::new(FooterComponent::new()),
6131            status_container: Arc::new(Container::new()),
6132            chat_container: Arc::new(Container::new()),
6133            loader: Arc::new(Loader::new()),
6134            last_assistant_text: std::sync::Mutex::new(String::new()),
6135            active_selector: std::sync::Mutex::new(None),
6136            active_extension_editor: std::sync::Mutex::new(None),
6137            autocomplete: AutocompleteManager::new(),
6138            autocomplete_container: Arc::new(Container::new()),
6139            theme_manager: Arc::new(ThemeManager::new()),
6140            tui: None,
6141            current_model_id: std::sync::Mutex::new(String::new()),
6142            show_images: std::sync::Mutex::new(true),
6143            history: std::sync::Mutex::new(Vec::new()),
6144            history_index: std::sync::Mutex::new(-1),
6145            history_draft: std::sync::Mutex::new(None),
6146            last_input_tokens: std::sync::Mutex::new(0),
6147            scoped_edit: std::sync::Mutex::new(None),
6148            markdown_transformer: std::sync::Mutex::new(None),
6149            extension_session: Arc::new(std::sync::Mutex::new(
6150                rpi_extensions::ExtensionSession::none(),
6151            )),
6152        });
6153        let editor = Arc::new(Editor::simple());
6154
6155        push_history(&state, "first message");
6156        push_history(&state, "second message");
6157        // Consecutive duplicate is skipped.
6158        push_history(&state, "second message");
6159        push_history(&state, "   "); // empty → skipped
6160        assert_eq!(state.history.lock().unwrap().len(), 2);
6161        assert_eq!(state.history.lock().unwrap()[0], "second message");
6162
6163        // User starts typing a fresh prompt.
6164        editor.set_text("half-typed");
6165        editor.set_cursor(0, 11);
6166
6167        // ↑ → most recent.
6168        navigate_history(&state, &editor, -1);
6169        assert_eq!(editor.get_text(), "second message");
6170        assert_eq!(*state.history_index.lock().unwrap(), 0);
6171        // ↑ → older.
6172        navigate_history(&state, &editor, -1);
6173        assert_eq!(editor.get_text(), "first message");
6174        assert_eq!(*state.history_index.lock().unwrap(), 1);
6175        // ↑ past the oldest → stays (no wrap).
6176        navigate_history(&state, &editor, -1);
6177        assert_eq!(editor.get_text(), "first message");
6178        // ↓ → newer.
6179        navigate_history(&state, &editor, 1);
6180        assert_eq!(editor.get_text(), "second message");
6181        // ↓ past the newest → restores the draft.
6182        navigate_history(&state, &editor, 1);
6183        assert_eq!(editor.get_text(), "half-typed");
6184        assert_eq!(*state.history_index.lock().unwrap(), -1);
6185    }
6186
6187    #[test]
6188    fn test_accept_top_suggestion_replaces_prefix() {
6189        // `/he` + Tab → `/help ` (slash command provider inserts a space).
6190        let state = Arc::new(TuiState {
6191            current_assistant: std::sync::Mutex::new(None),
6192            tool_components: std::sync::Mutex::new(HashMap::new()),
6193            bash_components: std::sync::Mutex::new(HashMap::new()),
6194            last_tool_comp: std::sync::Mutex::new(None),
6195            status: std::sync::Mutex::new(RunStatus::Idle),
6196            footer: Arc::new(FooterComponent::new()),
6197            status_container: Arc::new(Container::new()),
6198            chat_container: Arc::new(Container::new()),
6199            loader: Arc::new(Loader::new()),
6200            last_assistant_text: std::sync::Mutex::new(String::new()),
6201            active_selector: std::sync::Mutex::new(None),
6202            active_extension_editor: std::sync::Mutex::new(None),
6203            autocomplete: AutocompleteManager::new(),
6204            autocomplete_container: Arc::new(Container::new()),
6205            theme_manager: Arc::new(ThemeManager::new()),
6206            tui: None,
6207            current_model_id: std::sync::Mutex::new(String::new()),
6208            show_images: std::sync::Mutex::new(true),
6209            history: std::sync::Mutex::new(Vec::new()),
6210            history_index: std::sync::Mutex::new(-1),
6211            history_draft: std::sync::Mutex::new(None),
6212            last_input_tokens: std::sync::Mutex::new(0),
6213            scoped_edit: std::sync::Mutex::new(None),
6214            markdown_transformer: std::sync::Mutex::new(None),
6215            extension_session: Arc::new(std::sync::Mutex::new(
6216                rpi_extensions::ExtensionSession::none(),
6217            )),
6218        });
6219        {
6220            let mut combined = CombinedAutocompleteProvider::new();
6221            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6222                build_builtin_registry().visible_entries(),
6223            )));
6224            state.autocomplete.set_provider(Arc::new(combined));
6225        }
6226        let editor = Arc::new(Editor::simple());
6227        editor.set_text("/he");
6228        editor.set_cursor(0, 3);
6229        refresh_autocomplete(&state, &editor);
6230        let accepted = accept_top_suggestion(&state, &editor);
6231        assert!(accepted, "should accept the top suggestion");
6232        let text = editor.get_text();
6233        assert!(
6234            text.starts_with("/help"),
6235            "editor text should start with /help, got {text}"
6236        );
6237    }
6238}