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            order: None,
1851            limit: None,
1852            cursor: None,
1853        })
1854        .await
1855    {
1856        Ok(e) => e,
1857        Err(e) => {
1858            add_error_message(chat, &format!("Could not read session: {e}"));
1859            return;
1860        }
1861    };
1862    let mut md = String::from("# Session\n\n");
1863    for e in entries {
1864        let Entry::Message(me) = e else { continue };
1865        match &me.message {
1866            AgentMessage::User(u) => {
1867                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1868            }
1869            AgentMessage::Assistant(a) => {
1870                let text = assistant_text(a);
1871                if !text.is_empty() {
1872                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1873                }
1874            }
1875            _ => {}
1876        }
1877    }
1878
1879    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
1880    let gh = std::process::Command::new("gh")
1881        .arg("gist")
1882        .arg("create")
1883        .arg("--filename")
1884        .arg("session.md")
1885        .arg("-")
1886        .stdin(Stdio::piped())
1887        .stdout(Stdio::piped())
1888        .stderr(Stdio::null())
1889        .spawn();
1890    if let Ok(mut child) = gh {
1891        use std::io::Write;
1892        if let Some(mut stdin) = child.stdin.take() {
1893            let _ = stdin.write_all(md.as_bytes());
1894            let _ = stdin.flush();
1895        }
1896        let out = child.wait_with_output().ok();
1897        if let Some(out) = out {
1898            if out.status.success() {
1899                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
1900                add_note_message(chat, &format!("Shared session: {url}"));
1901                return;
1902            }
1903        }
1904        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
1905    } else {
1906        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
1907    }
1908    // Clipboard fallback (or transcript echo when the clipboard feature is off).
1909    if copy_to_clipboard(&md) {
1910        add_note_message(chat, "Session transcript copied to the clipboard.");
1911    } else {
1912        add_note_message(
1913            chat,
1914            "Clipboard unavailable — use /export to write the transcript to a file.",
1915        );
1916    }
1917}
1918
1919/// Export the current session to a markdown transcript file. Writes
1920/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
1921/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
1922/// Best-effort: failures surface as a chat note.
1923/// Export the current session to a markdown transcript file. Writes
1924/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
1925/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
1926/// Best-effort: failures surface as a chat note.
1927async fn export_session(harness: &AgentHarness, chat: &Arc<Container>) {
1928    let tree = harness.session().view("main");
1929    let entries = match tree
1930        .find_entries(&EntryQuery {
1931            entry_type: None,
1932            custom_type: None,
1933            order: None,
1934            limit: None,
1935            cursor: None,
1936        })
1937        .await
1938    {
1939        Ok(e) => e,
1940        Err(e) => {
1941            add_error_message(chat, &format!("Could not read session: {e}"));
1942            return;
1943        }
1944    };
1945    let name = tree.get_name().await.ok().flatten().unwrap_or_default();
1946    let id = tree
1947        .get_leaf_id()
1948        .await
1949        .ok()
1950        .flatten()
1951        .unwrap_or_else(|| "session".to_string());
1952    let mut md = String::from("# Session\n\n");
1953    for e in entries {
1954        let Entry::Message(me) = e else { continue };
1955        match &me.message {
1956            AgentMessage::User(u) => {
1957                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
1958            }
1959            AgentMessage::Assistant(a) => {
1960                let text = assistant_text(a);
1961                if !text.is_empty() {
1962                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
1963                }
1964            }
1965            _ => {}
1966        }
1967    }
1968    let file_name = if name.is_empty() {
1969        format!("{id}.md")
1970    } else {
1971        format!("{name}.md")
1972    };
1973    let path = std::env::current_dir()
1974        .unwrap_or_else(|_| std::path::PathBuf::from("."))
1975        .join(&file_name);
1976    match std::fs::write(&path, md) {
1977        Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
1978        Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
1979    }
1980}
1981
1982/// Fork the current session into a new JSONL session and switch to it (TS
1983/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
1984/// session the user continues in). Uses the repo's `fork_typed`, then swaps
1985/// the harness backing and renders the (empty-ish) fork transcript.
1986/// Hot-switch the harness to another saved session: abort any in-flight run,
1987/// open the target session file, swap the durable backing, and re-render the
1988/// transcript from the new history (mirrors pi's `/session` resume-in-place).
1989/// Shared by the `/session` selector, `/import`, and `/fork`. The current
1990/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
1991async fn switch_to_session(
1992    harness: &AgentHarness,
1993    lane: &Arc<dyn AgentLane>,
1994    id: &str,
1995    cwd: &std::path::Path,
1996    chat: &Arc<Container>,
1997    state: &Arc<TuiState>,
1998) -> bool {
1999    if *state.status.lock().unwrap() == RunStatus::Working {
2000        state.set_status(RunStatus::Aborting);
2001        let _ = lane.abort().await;
2002    }
2003    let cwd_str = cwd.to_string_lossy().to_string();
2004    match crate::session::open_session_by_id(id, &cwd_str).await {
2005        Ok(new_session) => {
2006            let _ = harness.set_session(new_session).await;
2007            chat.clear();
2008            add_welcome_message(chat);
2009            render_session_history(
2010                harness,
2011                chat,
2012                state.markdown_transformer(),
2013                Some(state.extension_session.clone()),
2014            )
2015            .await;
2016            state.set_status(RunStatus::Idle);
2017            add_note_message(chat, &format!("Switched to session {id}."));
2018            true
2019        }
2020        Err(e) => {
2021            state.set_status(RunStatus::Idle);
2022            add_error_message(chat, &format!("Could not open session {id}: {e}"));
2023            false
2024        }
2025    }
2026}
2027
2028/// `/import <path>`: copy a JSONL session file into the default session dir,
2029/// then hot-switch to it (the file name becomes its id — matching the
2030/// selector/`open_session_by_id` containment rules).
2031async fn import_session(
2032    harness: &AgentHarness,
2033    lane: &Arc<dyn AgentLane>,
2034    path: &str,
2035    cwd: &std::path::Path,
2036    chat: &Arc<Container>,
2037    state: &Arc<TuiState>,
2038) {
2039    use std::path::Path as FsPath;
2040
2041    let src = FsPath::new(path);
2042    if !src.is_file() {
2043        add_error_message(chat, &format!("Import source not found: {path}"));
2044        return;
2045    }
2046    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
2047        add_error_message(chat, "Import source has no file name.");
2048        return;
2049    };
2050    if !fname.ends_with(".jsonl") {
2051        add_error_message(chat, "Import source must be a .jsonl session file.");
2052        return;
2053    }
2054    let dir = crate::session::default_session_dir(cwd);
2055    if let Err(e) = std::fs::create_dir_all(&dir) {
2056        add_error_message(chat, &format!("Could not create session dir: {e}"));
2057        return;
2058    }
2059    let dest = dir.join(fname);
2060    match std::fs::copy(src, &dest) {
2061        Ok(_) => {
2062            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
2063            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
2064                add_note_message(chat, &format!("Imported session from {path}"));
2065            }
2066        }
2067        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
2068    }
2069}
2070
2071async fn fork_session(
2072    harness: &AgentHarness,
2073    cwd: &std::path::Path,
2074    chat: &Arc<Container>,
2075    state: &Arc<TuiState>,
2076) {
2077    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2078    use rpi_tools::FileSystem;
2079
2080    let cwd_str = cwd.to_string_lossy().to_string();
2081    let dir = crate::session::default_session_dir(cwd);
2082    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
2083    let fs: Arc<dyn FileSystem> = env.clone();
2084    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2085        fs,
2086        sessions_root: dir.to_string_lossy().into_owned(),
2087        clock: Arc::new(rpi_harness::session::memory::SystemClock),
2088        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
2089    });
2090    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2091    // it from the session list by the current session's id.
2092    let id = harness.session().storage().metadata().id.clone();
2093    let metas = match crate::session::list_session_metadata(&cwd_str).await {
2094        Ok(m) => m,
2095        Err(e) => {
2096            add_error_message(chat, &format!("Could not list sessions: {e}"));
2097            return;
2098        }
2099    };
2100    let Some(source) = metas.iter().find(|m| m.id == id) else {
2101        add_error_message(chat, &format!("Current session {id} not found on disk."));
2102        return;
2103    };
2104    let fork_storage = match repo
2105        .fork_typed(
2106            source,
2107            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2108                id: None,
2109                parent_session_id: Some(source.id.clone()),
2110                cwd: cwd_str.clone(),
2111                metadata: None,
2112            },
2113            &rpi_harness::session::types::ForkOptions::default(),
2114        )
2115        .await
2116    {
2117        Ok(s) => s,
2118        Err(e) => {
2119            add_error_message(chat, &format!("Could not fork session: {e}"));
2120            return;
2121        }
2122    };
2123    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
2124    let _ = harness.set_session(new_session).await;
2125    chat.clear();
2126    add_welcome_message(chat);
2127    render_session_history(
2128        harness,
2129        chat,
2130        state.markdown_transformer(),
2131        Some(state.extension_session.clone()),
2132    )
2133    .await;
2134    state.set_status(RunStatus::Idle);
2135    add_note_message(chat, "Forked into a new session.");
2136}
2137
2138/// Render the restored session's prior transcript (user + assistant messages)
2139/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
2140/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
2141/// any session read failure just starts with an empty transcript.
2142///
2143/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
2144/// the identity path. Each restored assistant component installs it so replayed
2145/// history renders through the same `register_markdown_transformer` handlers
2146/// the live stream does.
2147async fn render_session_history(
2148    harness: &AgentHarness,
2149    chat: &Arc<Container>,
2150    transformer: Option<MarkdownTransformer>,
2151    extension_session: Option<crate::session::ExtensionSessionCell>,
2152) {
2153    let tree = harness.session().view("main");
2154    let entries = match tree
2155        .find_entries(&EntryQuery {
2156            entry_type: None,
2157            custom_type: None,
2158            order: None,
2159            limit: None,
2160            cursor: None,
2161        })
2162        .await
2163    {
2164        Ok(e) => e,
2165        Err(_) => return,
2166    };
2167    let mut rendered_any = false;
2168    for e in entries {
2169        match e {
2170            Entry::Message(me) => match &me.message {
2171                AgentMessage::User(u) => {
2172                    add_user_message(chat, &user_message_text(u));
2173                    rendered_any = true;
2174                }
2175                AgentMessage::Assistant(a) => {
2176                    let comp = Arc::new(AssistantMessageComponent::new(
2177                        AssistantMessageOptions::default(),
2178                    ));
2179                    if let Some(t) = &transformer {
2180                        comp.set_markdown_transformer(Some(t.clone()));
2181                    }
2182                    comp.update_blocks(&assistant_blocks(a));
2183                    chat.add_child(comp);
2184                    // Single trailing spacer: the next transcript entry (user or
2185                    // assistant) follows one blank line below.
2186                    chat.add_child(Arc::new(Spacer::new(1)));
2187                    rendered_any = true;
2188                }
2189                AgentMessage::Custom(custom) => {
2190                    if let Some(session) = &extension_session {
2191                        if let Some(component) = extension_message_component(
2192                            session,
2193                            &custom.role,
2194                            &serde_json::json!({
2195                                "customType": custom.role,
2196                                "content": custom.content,
2197                                "details": custom.data,
2198                            }),
2199                            transformer.clone(),
2200                        ) {
2201                            chat.add_child(component);
2202                            chat.add_child(Arc::new(Spacer::new(1)));
2203                            rendered_any = true;
2204                            continue;
2205                        }
2206                    }
2207                    add_note_message(chat, &custom_message_fallback(&custom));
2208                    rendered_any = true;
2209                }
2210                _ => {}
2211            },
2212            Entry::Compaction(compaction) => {
2213                add_note_message(
2214                    chat,
2215                    &format!(
2216                        "Compacted {} tokens: {}",
2217                        compaction.tokens_before, compaction.summary
2218                    ),
2219                );
2220                rendered_any = true;
2221            }
2222            Entry::BranchSummary(summary) => {
2223                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
2224                rendered_any = true;
2225            }
2226            Entry::Custom(custom) => {
2227                let rendered = extension_session.as_ref().and_then(|session| {
2228                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
2229                });
2230                if let Some(component) = rendered {
2231                    chat.add_child(component);
2232                    chat.add_child(Arc::new(Spacer::new(1)));
2233                    rendered_any = true;
2234                } else if let Some(text) =
2235                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
2236                {
2237                    add_note_message(chat, &text);
2238                    rendered_any = true;
2239                }
2240            }
2241            Entry::ModelChange(change) => {
2242                add_note_message(
2243                    chat,
2244                    &format!("Model changed to {}:{}", change.provider, change.model_id),
2245                );
2246                rendered_any = true;
2247            }
2248            Entry::ThinkingLevel(change) => {
2249                add_note_message(
2250                    chat,
2251                    &format!("Thinking level: {:?}", change.thinking_level),
2252                );
2253                rendered_any = true;
2254            }
2255            Entry::ActiveTools(change) => {
2256                add_note_message(
2257                    chat,
2258                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
2259                );
2260                rendered_any = true;
2261            }
2262        }
2263    }
2264    if rendered_any {
2265        // No trailing spacer here — each entry already adds its own trailing
2266        // Spacer(1), so an extra would double the bottom gap.
2267    }
2268}
2269
2270fn invoke_extension_renderer(
2271    session: &crate::session::ExtensionSessionCell,
2272    kind: rpi_extensions::RegisteredRendererKind,
2273    payload: &serde_json::Value,
2274) -> Option<serde_json::Value> {
2275    let snapshot = session.lock().ok()?.snapshot_arc()?;
2276    let input = serde_json::to_string(payload).ok()?;
2277    for renderer in snapshot.renderers_of(kind) {
2278        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2279            let mut out = rpi_plugin_sdk::StbString::empty();
2280            let rc = (renderer.render_fn)(
2281                rpi_plugin_sdk::StbStringRef::from_str(&input),
2282                &mut out as *mut rpi_plugin_sdk::StbString,
2283                renderer.user_data,
2284            );
2285            let text = if rc == 0 {
2286                Some(out.to_string_lossy())
2287            } else {
2288                None
2289            };
2290            out.free_with(Some(renderer.plugin_free_string));
2291            text
2292        }))
2293        .ok()
2294        .flatten();
2295        let Some(text) = outcome else { continue };
2296        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
2297            return Some(value);
2298        }
2299    }
2300    None
2301}
2302
2303fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
2304    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
2305        let text = lines
2306            .iter()
2307            .filter_map(|line| line.as_str())
2308            .collect::<Vec<_>>()
2309            .join("\n");
2310        return Some(Arc::new(Text::new(text, 0, 0)));
2311    }
2312    let text = value.get("text").and_then(|v| v.as_str())?;
2313    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2314        let component = Arc::new(AssistantMessageComponent::new(
2315            AssistantMessageOptions::default(),
2316        ));
2317        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2318        Some(component)
2319    } else {
2320        Some(Arc::new(Text::new(text, 0, 0)))
2321    }
2322}
2323
2324fn extension_message_component(
2325    session: &crate::session::ExtensionSessionCell,
2326    custom_type: &str,
2327    payload: &serde_json::Value,
2328    transformer: Option<MarkdownTransformer>,
2329) -> Option<Arc<dyn rpi_tui::Component>> {
2330    let value = invoke_extension_renderer(
2331        session,
2332        rpi_extensions::RegisteredRendererKind::Message,
2333        payload,
2334    )?;
2335    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
2336        let text = value.get("text").and_then(|v| v.as_str())?;
2337        let component = Arc::new(AssistantMessageComponent::new(
2338            AssistantMessageOptions::default(),
2339        ));
2340        if let Some(transformer) = transformer {
2341            component.set_markdown_transformer(Some(transformer));
2342        }
2343        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
2344        return Some(component);
2345    }
2346    extension_text_component(&value)
2347        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
2348}
2349
2350fn extension_entry_component(
2351    session: &crate::session::ExtensionSessionCell,
2352    custom_type: &str,
2353    data: Option<serde_json::Value>,
2354) -> Option<Arc<dyn rpi_tui::Component>> {
2355    let payload = serde_json::json!({
2356        "customType": custom_type,
2357        "data": data,
2358    });
2359    let value = invoke_extension_renderer(
2360        session,
2361        rpi_extensions::RegisteredRendererKind::Entry,
2362        &payload,
2363    )?;
2364    extension_text_component(&value)
2365}
2366
2367/// Project an assistant message's content into the provider-free
2368/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
2369/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
2370/// are rendered by their own components in the transcript.
2371/// Whether startup intentionally opened a session that already has history.
2372fn launch_restores_history(args: &Args) -> bool {
2373    args.continue_session
2374        || args.resume
2375        || args.session.is_some()
2376        || args.session_id.is_some()
2377        || args.fork.is_some()
2378}
2379
2380fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
2381    msg.content
2382        .iter()
2383        .filter_map(|c| match c {
2384            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
2385            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
2386            Content::Image(image) => base64::engine::general_purpose::STANDARD
2387                .decode(&image.data)
2388                .ok()
2389                .filter(|data| !data.is_empty())
2390                .map(AssistantBlock::Image),
2391            _ => None,
2392        })
2393        .collect()
2394}
2395
2396fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
2397    let content = custom
2398        .content
2399        .iter()
2400        .filter_map(|item| match item {
2401            Content::Text(text) => Some(text.text.as_str()),
2402            _ => None,
2403        })
2404        .collect::<Vec<_>>()
2405        .join("\n");
2406    if content.is_empty() {
2407        format!("{}: {}", custom.role, custom.data)
2408    } else {
2409        format!("{}: {}", custom.role, content)
2410    }
2411}
2412
2413/// The name displayed for a model id (last path segment / after the final
2414/// `:`), to keep the footer compact.
2415fn short_model_name(id: &str) -> String {
2416    id.rsplit([':', '/'])
2417        .next()
2418        .filter(|s| !s.is_empty())
2419        .unwrap_or(id)
2420        .to_string()
2421}
2422
2423// ===========================================================================
2424// Streaming run status
2425// ===========================================================================
2426
2427/// The live status of the agent run, fed to the footer + status slot.
2428#[derive(Clone, Copy, PartialEq, Eq)]
2429enum RunStatus {
2430    Idle,
2431    Working,
2432    Aborting,
2433}
2434
2435/// Which selector overlay (if any) is currently swapped into the editor slot.
2436#[derive(Clone, Copy, PartialEq, Eq)]
2437enum SelectorKind {
2438    /// `/model` — available models (live switch via `lane.set_model`).
2439    Model,
2440    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
2441    Thinking,
2442    /// `/tools` — toggle builtin tools on/off.
2443    Tools,
2444    /// `/images` — toggle inline image rendering.
2445    Images,
2446    /// `/session` — browse and switch saved JSONL sessions.
2447    Session,
2448    /// `/theme` — dark / light / monochrome presets applied live.
2449    Theme,
2450    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
2451    ScopedModels,
2452    /// `/settings` — interactive settings menu (and its sub-selectors).
2453    Settings,
2454    /// `/tree` — navigate to an existing entry in the current session.
2455    Tree,
2456    /// Extension-provided selector; uses the same keyboard contract.
2457    Extension,
2458}
2459
2460/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
2461/// and the render-tick task.
2462struct TuiState {
2463    /// The in-flight streaming assistant message (cleared on finalize).
2464    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
2465    /// Tool-execution components keyed by `tool_call_id`.
2466    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
2467    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
2468    /// generic tool map so bash output streams into a `BashExecutionComponent`
2469    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
2470    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
2471    /// The most recently created tool component (bash or generic). Ctrl+T
2472    /// toggles `expanded` on this — a pragmatic "expand last tool" since the
2473    /// key loop has no per-line focus. Updated on every tool/bash Start.
2474    last_tool_comp: std::sync::Mutex<Option<Arc<ToolExecutionComponent>>>,
2475    /// Run status for the status indicator + interrupt routing.
2476    status: std::sync::Mutex<RunStatus>,
2477    /// The footer, updated live by the drain task.
2478    footer: Arc<FooterComponent>,
2479    /// The status-container (status slot in the dock) — cleared/filled with a
2480    /// loader while a run is active.
2481    status_container: Arc<Container>,
2482    /// The chat transcript container.
2483    chat_container: Arc<Container>,
2484    /// The active loader shown while `Working`.
2485    loader: Arc<Loader>,
2486    /// The last finalized assistant text (for `/copy`). Updated by the drain
2487    /// task on `MessageEnd` / `AgentEnd`.
2488    last_assistant_text: std::sync::Mutex<String>,
2489    /// The active selector overlay, swapped into the editor slot. `Some` while
2490    /// a selector is open; the key loop routes to it first and restores the
2491    /// editor on done/cancel.
2492    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
2493    /// Extension-provided editor currently occupying the input slot.
2494    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
2495    /// The autocomplete manager (slash + @file providers) consulted on every
2496    /// editor keystroke.
2497    autocomplete: AutocompleteManager,
2498    /// The container rendered above the editor holding the live autocomplete
2499    /// suggestion list (cleared when there are no suggestions).
2500    autocomplete_container: Arc<Container>,
2501    /// The owned theme manager — `/theme` applies presets here. The global
2502    /// `theme()` is read-only after OnceLock init, so per-instance state is the
2503    /// only way to apply a preset at runtime.
2504    theme_manager: Arc<ThemeManager>,
2505    /// The alt-screen handle, held so `set_status` can reflect run state in the
2506    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
2507    /// that never call `set_status` with a title.
2508    tui: Option<Arc<TuiAltScreen>>,
2509    /// The model id currently shown in the footer + used as the Ctrl+M
2510    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
2511    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
2512    current_model_id: std::sync::Mutex<String>,
2513    /// Whether inline image rendering is enabled (`/images` toggle). Stored
2514    /// even though image wiring is minimal this pass — the flag is consulted
2515    /// where images would be shown and echoed back by `/images`.
2516    show_images: std::sync::Mutex<bool>,
2517    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
2518    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
2519    history: std::sync::Mutex<Vec<String>>,
2520    /// Browse index while recalling history: -1 = not browsing, 0 = most
2521    /// recent, 1 = older, … Reset to -1 on every submit.
2522    history_index: std::sync::Mutex<isize>,
2523    /// The editor text captured when entering browse mode, restored when the
2524    /// user navigates back past the newest entry (TS `historyDraft`).
2525    history_draft: std::sync::Mutex<Option<String>>,
2526    /// The previous turn's input token count, used by the cache-miss notice:
2527    /// a large input that reads nothing from cache after an established prefix
2528    /// means the prefix was re-billed (simplified `maybeShowCacheMissNotice`).
2529    last_input_tokens: std::sync::Mutex<i64>,
2530    /// The in-progress scoped-models selection while the `/scoped-models`
2531    /// selector is open (toggle per item, Esc saves). `None` when not editing.
2532    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
2533    /// B5e: the live assistant-markdown transformer, built from the current
2534    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
2535    /// when no markdown transformers are registered (identity render path).
2536    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
2537    /// closure no-ops once its snapshot's `active` flag flips false) and
2538    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
2539    /// transform takes effect on the visible streaming message immediately.
2540    /// New assistant components pick up whatever closure is current at
2541    /// construction time via [`install_markdown_transformer`].
2542    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
2543    /// Live extension registry used by message/entry renderer dispatch.
2544    extension_session: crate::session::ExtensionSessionCell,
2545}
2546
2547/// How many submitted messages are kept for ↑ recall (mirrors the TS
2548/// editor's 100-entry cap).
2549const HISTORY_LIMIT: usize = 100;
2550
2551/// A turn with at least this many input tokens is worth a cache-miss notice
2552/// when nothing was read from cache (matches the TS 20k threshold).
2553const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
2554
2555/// Keep a few rows of overlap so page scrolling preserves visual context,
2556/// matching the upstream fullscreen viewport behavior.
2557const PAGE_SCROLL_OVERLAP: usize = 4;
2558
2559/// Native pi scrolls a small chunk for each wheel notch rather than moving the
2560/// transcript one physical row at a time. Three lines stays precise while
2561/// avoiding the sluggish feel of the previous implementation.
2562const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
2563
2564fn transcript_page_size(viewport_height: usize) -> i32 {
2565    viewport_height
2566        .saturating_sub(PAGE_SCROLL_OVERLAP)
2567        .max(1)
2568        .min(i32::MAX as usize) as i32
2569}
2570
2571fn should_dispatch_key(kind: KeyEventKind) -> bool {
2572    kind != KeyEventKind::Release
2573}
2574
2575/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
2576fn format_tokens(n: i64) -> String {
2577    if n >= 1_000_000 {
2578        format!("{:.1}M", n as f64 / 1_000_000.0)
2579    } else if n >= 1_000 {
2580        format!("{:.1}K", n as f64 / 1_000.0)
2581    } else {
2582        n.to_string()
2583    }
2584}
2585
2586/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
2587/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
2588/// resets the browse state so a fresh prompt never resumes mid-history.
2589fn push_history(state: &Arc<TuiState>, text: &str) {
2590    let trimmed = text.trim().to_string();
2591    if trimmed.is_empty() {
2592        return;
2593    }
2594    let mut history = state.history.lock().unwrap();
2595    if history.first() == Some(&trimmed) {
2596        return;
2597    }
2598    history.insert(0, trimmed);
2599    history.truncate(HISTORY_LIMIT);
2600    *state.history_index.lock().unwrap() = -1;
2601    *state.history_draft.lock().unwrap() = None;
2602}
2603
2604/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
2605/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
2606/// current editor text as the draft; navigating back past the newest entry
2607/// restores that draft.
2608fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
2609    let history = state.history.lock().unwrap();
2610    if history.is_empty() {
2611        return;
2612    }
2613    let mut index = state.history_index.lock().unwrap();
2614    let new_index = *index - direction as isize;
2615    if new_index < -1 || new_index >= history.len() as isize {
2616        return;
2617    }
2618    if *index == -1 && new_index >= 0 {
2619        // Entering browse mode: stash the current input.
2620        *state.history_draft.lock().unwrap() = Some(editor.get_text());
2621    }
2622    *index = new_index;
2623    if new_index == -1 {
2624        // Exited browse mode: restore the draft (or clear if there was none).
2625        let draft = state.history_draft.lock().unwrap().take();
2626        match draft {
2627            Some(d) => {
2628                let len = d.len();
2629                editor.set_text(&d);
2630                editor.set_cursor(0, len);
2631            }
2632            None => editor.set_text(""),
2633        }
2634    } else {
2635        let text = history[new_index as usize].clone();
2636        let len = text.len();
2637        editor.set_text(&text);
2638        editor.set_cursor(0, len);
2639    }
2640}
2641
2642impl TuiState {
2643    fn set_status(&self, status: RunStatus) {
2644        *self.status.lock().unwrap() = status;
2645        self.apply_status(status);
2646    }
2647
2648    /// Atomically reserve the single interactive run slot. The editor callback
2649    /// runs on a different thread from the async prompt loop, so checking and
2650    /// setting in separate steps would allow rapid Enter presses to queue more
2651    /// than one operation.
2652    fn try_start_working(&self) -> bool {
2653        let mut status = self.status.lock().unwrap();
2654        if *status != RunStatus::Idle {
2655            return false;
2656        }
2657        *status = RunStatus::Working;
2658        drop(status);
2659        self.apply_status(RunStatus::Working);
2660        true
2661    }
2662
2663    fn apply_status(&self, status: RunStatus) {
2664        match status {
2665            RunStatus::Working => {
2666                self.footer.set_status("Working…");
2667                // Reflect the in-flight turn in the terminal window/tab title
2668                // (OSC 2). No-op when `tui` is absent (unit tests).
2669                if let Some(tui) = &self.tui {
2670                    tui.set_title("rpi — working");
2671                }
2672                self.status_container.clear();
2673                self.loader.start();
2674                self.status_container.add_child(self.loader.clone());
2675            }
2676            RunStatus::Aborting => {
2677                self.footer.set_status("Aborting…");
2678                // Do not leave a frozen "Working" spinner on screen after the
2679                // render tick intentionally stops advancing in this state.
2680                self.loader.stop();
2681                self.status_container.clear();
2682            }
2683            RunStatus::Idle => {
2684                self.footer.set_status("");
2685                if let Some(tui) = &self.tui {
2686                    tui.set_title("rpi");
2687                }
2688                self.loader.stop();
2689                self.status_container.clear();
2690            }
2691        }
2692    }
2693
2694    /// The bash panel has its own `Running...` spinner. Keep the global
2695    /// `Working...` loader out of the status slot while any bash tool is active
2696    /// so the same operation is not presented as two simultaneous loaders.
2697    fn sync_working_loader_with_bash(&self) {
2698        if *self.status.lock().unwrap() != RunStatus::Working {
2699            return;
2700        }
2701
2702        self.status_container.clear();
2703        if self.bash_components.lock().unwrap().is_empty() {
2704            self.status_container.add_child(self.loader.clone());
2705        }
2706    }
2707
2708    /// Whether a selector overlay is currently open (routes keys to it first).
2709    fn selector_open(&self) -> bool {
2710        self.active_selector.lock().unwrap().is_some()
2711    }
2712
2713    fn extension_editor_open(&self) -> bool {
2714        self.active_extension_editor.lock().unwrap().is_some()
2715    }
2716
2717    /// Record a freshly created tool component as the "most recent" so Ctrl+T
2718    /// can toggle its expansion. Idempotent overwrites — only the latest lives.
2719    fn remember_tool(&self, comp: Arc<ToolExecutionComponent>) {
2720        *self.last_tool_comp.lock().unwrap() = Some(comp);
2721    }
2722
2723    /// Toggle `expanded` on the most recent tool component (Ctrl+T). Returns
2724    /// `true` if a component was toggled. Limitation: the key loop tracks no
2725    /// per-line focus, so this always targets the *last* tool shown — not the
2726    /// one under the cursor. Documented in the plan; a focused expansion would
2727    /// need mouse/line hit-testing which is out of scope this pass.
2728    fn toggle_expand_last_tool(&self) -> bool {
2729        if let Some(comp) = self.last_tool_comp.lock().unwrap().as_ref() {
2730            let cur = comp.is_expanded();
2731            comp.set_expanded(!cur);
2732            true
2733        } else {
2734            false
2735        }
2736    }
2737
2738    /// The model id currently tracked as active (footer + Ctrl+M anchor).
2739    fn current_model_id(&self) -> String {
2740        self.current_model_id.lock().unwrap().clone()
2741    }
2742
2743    /// Update the tracked model id + footer label after a switch (live or
2744    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
2745    fn set_current_model(&self, model: &rpi_ai::Model) {
2746        *self.current_model_id.lock().unwrap() = model.id.clone();
2747        self.footer.set_model(&short_model_name(&model.id));
2748    }
2749
2750    /// B5e: read a clone of the current assistant-markdown transformer (if any).
2751    /// New assistant components call this at construction so they render with
2752    /// whatever plugin `register_markdown_transformer` handlers are live.
2753    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
2754        self.markdown_transformer.lock().unwrap().clone()
2755    }
2756
2757    /// B5e: swap the live transformer. Used at startup (install the first
2758    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
2759    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
2760    /// transform should take effect on the VISIBLE streaming message too, so
2761    /// this re-installs on the in-flight `current_assistant` component — its
2762    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
2763    /// `None` clears the transform (identity), e.g. a reload that unregisters
2764    /// every markdown transformer.
2765    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
2766        *self.markdown_transformer.lock().unwrap() = transformer.clone();
2767        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
2768            comp.set_markdown_transformer(transformer);
2769        }
2770    }
2771}
2772
2773// ===========================================================================
2774// interactive_tui — the entry point
2775// ===========================================================================
2776
2777/// TUI-based interactive mode.
2778///
2779/// `event_rx` carries the live `AgentEvent` stream (installed by
2780/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
2781/// fn), it falls back to a blocking, await-final-text path.
2782///
2783/// `model_catalog` is the read-only catalog the `/model` selector displays.
2784///
2785/// This implementation mirrors the TypeScript `InteractiveMode` class:
2786/// build the layout root once, drain `AgentEvent`s into UI mutations that
2787/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
2788/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
2789/// autocomplete are layered on via the editor-container swap pattern.
2790pub async fn interactive_tui(
2791    harness: &AgentHarness,
2792    event_rx: Option<broadcast::Receiver<AgentEvent>>,
2793    args: &Args,
2794    model_catalog: Vec<rpi_ai::Model>,
2795    initial: Option<String>,
2796    extra_messages: &[String],
2797    theme: Option<&str>,
2798    reload_context: &crate::session::ReloadContext,
2799) -> i32 {
2800    let lane: Arc<dyn AgentLane> = harness.lane("main");
2801
2802    // Resolve the active model once, up front. The full id feeds the TuiState
2803    // tracking field + the selectors/key loop (which run on a blocking thread
2804    // and can't await `lane.get_model()`); the short name feeds the footer.
2805    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
2806    let model_name = short_model_name(&lane_model_id);
2807
2808    // The cwd for @file autocomplete + session discovery.
2809    let cwd = std::env::current_dir()
2810        .map(|p| p.to_path_buf())
2811        .unwrap_or_else(|_| std::path::PathBuf::from("."));
2812
2813    // Channel between the key/callback threads and the main async loop.
2814    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
2815
2816    // Apply the saved theme before constructing transcript components. Some
2817    // components keep styled text, so doing this after the welcome banner left
2818    // the first screen in the dark palette until it was rebuilt.
2819    if let Some(preset) = match theme {
2820        Some("light") => Some(ThemePreset::Light),
2821        Some("monochrome") => Some(ThemePreset::Monochrome),
2822        Some("dark") => Some(ThemePreset::Dark),
2823        _ => None,
2824    } {
2825        apply_theme_preset(preset);
2826    }
2827
2828    // ---- TUI + containers ----
2829    let terminal = Box::new(ProcessTerminal::new());
2830    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
2831
2832    let chat_container = Arc::new(Container::new());
2833    add_welcome_message(&chat_container);
2834
2835    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
2836    // banner + the earendil announcement once, then write the sentinel. The TS
2837    // original is a multi-step dialog (theme picker + analytics opt-in); this
2838    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
2839    // analytics deferred — no telemetry wiring). See `extras.rs`.
2840    crate::extras::maybe_first_time_setup(&chat_container);
2841
2842    // A --continue/--resume/--session launch opens on an existing JSONL
2843    // session — render its prior user/assistant transcript so the user sees
2844    // where they left off (tool executions are skipped: their live display
2845    // belongs to the current run, and replaying old results would be noise).
2846    let initial_transformer = build_markdown_transformer(
2847        reload_context
2848            .extension_session
2849            .lock()
2850            .unwrap()
2851            .snapshot_arc(),
2852    );
2853    // A normal launch creates a fresh session and must not replay records from
2854    // another/project harness. Only explicit restore/fork modes render prior
2855    // conversation history. This fixes stale prompts appearing every startup.
2856    if launch_restores_history(args) {
2857        render_session_history(
2858            &harness,
2859            &chat_container,
2860            initial_transformer.clone(),
2861            Some(reload_context.extension_session.clone()),
2862        )
2863        .await;
2864    }
2865
2866    // `document_container` wraps the welcome header + chat so the scrollview
2867    // follows the whole transcript (mirrors TS `documentContainer`).
2868    let document_container = Arc::new(Container::new());
2869    document_container.add_child(chat_container.clone());
2870
2871    let scroll_view = Arc::new(ScrollView::new(
2872        document_container.clone(),
2873        ScrollViewOptions {
2874            follow: FollowMode::End,
2875            primary: true,
2876            overscroll: OverscrollMode::Chain,
2877            // Native pi keeps transcript chrome out of the way. Our Auto mode
2878            // has no hide timer yet and therefore became effectively permanent
2879            // after the first wheel event, unlike the upstream experience.
2880            scrollbar: ScrollbarMode::Hidden,
2881            ..Default::default()
2882        },
2883    ));
2884
2885    // ---- Editor ----
2886    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
2887    // editor renders full-width `─` top/bottom borders with padding-only lines
2888    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
2889    let editor = Arc::new(Editor::new(
2890        EditorOptions {
2891            padding_x: 1,
2892            ..Default::default()
2893        },
2894        EditorStyle::default(),
2895        Arc::new(rpi_tui::Keybindings::new()),
2896    ));
2897
2898    // ---- Footer + status ----
2899    let footer = Arc::new(FooterComponent::new());
2900    footer.set_model(&model_name);
2901    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");
2902
2903    let status_container = Arc::new(Container::new());
2904    let loader = Arc::new(Loader::with_text("Working…"));
2905
2906    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
2907    // Prompt templates discovered at session build (Part A2) are surfaced as
2908    // `/`-prefixed entries alongside the built-in slash commands: typing
2909    // `/<name>` in the editor expands the template (mirrors pi
2910    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
2911    // the template's frontmatter description (or a fallback) so the autocomplete
2912    // popover shows what each template does.
2913    //
2914    // We snapshot the full resources once (skills + prompt-templates): the
2915    // autocomplete builder consumes the templates, and the `/context` command
2916    // (fired from the blocking submit handler, which can't `.await`) reads the
2917    // snapshot to render the discovered-resources panel without touching the
2918    // harness async accessor.
2919    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
2920    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
2921        .prompt_templates
2922        .clone()
2923        .unwrap_or_default()
2924        .iter()
2925        .map(|t| SlashCommandEntry {
2926            name: format!("/{}", t.name),
2927            description: t
2928                .description
2929                .clone()
2930                .unwrap_or_else(|| "Expand prompt template".to_string()),
2931        })
2932        .collect();
2933    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
2934        Arc::new(resources_snapshot);
2935    // Build the built-in command registry once — the single source of truth for
2936    // both dispatch and the built-in autocomplete entries. The discovered
2937    // prompt-template commands are merged into the autocomplete list separately
2938    // (they dispatch via template expansion, not the registry); built-ins come
2939    // first so they win on a fuzzy tie.
2940    let mut command_registry = build_builtin_registry();
2941    register_extension_commands(
2942        &mut command_registry,
2943        reload_context.extension_session.clone(),
2944    );
2945    let registry = Arc::new(command_registry);
2946    let mut all_slash_commands = registry.visible_entries();
2947    all_slash_commands.extend(template_slash_commands);
2948    let autocomplete = AutocompleteManager::new();
2949    {
2950        let mut combined = CombinedAutocompleteProvider::new();
2951        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
2952            all_slash_commands,
2953        )));
2954        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
2955            cwd.clone(),
2956        )));
2957        autocomplete.set_provider(Arc::new(combined));
2958    }
2959    let autocomplete_container = Arc::new(Container::new());
2960
2961    let state = Arc::new(TuiState {
2962        current_assistant: std::sync::Mutex::new(None),
2963        tool_components: std::sync::Mutex::new(HashMap::new()),
2964        bash_components: std::sync::Mutex::new(HashMap::new()),
2965        last_tool_comp: std::sync::Mutex::new(None),
2966        status: std::sync::Mutex::new(RunStatus::Idle),
2967        footer: footer.clone(),
2968        status_container: status_container.clone(),
2969        chat_container: chat_container.clone(),
2970        loader: loader.clone(),
2971        last_assistant_text: std::sync::Mutex::new(String::new()),
2972        active_selector: std::sync::Mutex::new(None),
2973        active_extension_editor: std::sync::Mutex::new(None),
2974        autocomplete,
2975        autocomplete_container: autocomplete_container.clone(),
2976        theme_manager: Arc::new(ThemeManager::new()),
2977        tui: Some(tui.clone()),
2978        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
2979        show_images: std::sync::Mutex::new(true),
2980        history: std::sync::Mutex::new(Vec::new()),
2981        history_index: std::sync::Mutex::new(-1),
2982        history_draft: std::sync::Mutex::new(None),
2983        last_input_tokens: std::sync::Mutex::new(0),
2984        scoped_edit: std::sync::Mutex::new(None),
2985        markdown_transformer: std::sync::Mutex::new(initial_transformer),
2986        extension_session: reload_context.extension_session.clone(),
2987    });
2988
2989    // Capture the model catalog + cwd for the selector builders + the key loop
2990    // (the callbacks fire on blocking threads and need owned data).
2991    let model_catalog_arc = Arc::new(model_catalog.clone());
2992    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
2993
2994    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
2995    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
2996    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
2997    //
2998    // The scrollview gets `basis(0)` so the constrained stack allocator starts
2999    // it at zero height and grows it to fill the space the dock does not need
3000    // — this keeps the dock (editor borders + footer) pinned to the bottom and
3001    // never shrinks it below the editor's 3 rows (top + content + bottom). The
3002    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
3003    // never clip the input panel below its minimum.
3004    let editor_container = Arc::new(Container::new());
3005    editor_container.add_child(editor.clone());
3006
3007    let dock = Arc::new(VStack::from_children(vec![
3008        StackChild::Entry(StackEntry::new(status_container.clone())),
3009        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
3010        StackChild::Entry(
3011            StackEntry::new(editor_container.clone())
3012                .shrink(0)
3013                .min_size(3),
3014        ),
3015        StackChild::Entry(StackEntry::new(footer.clone())),
3016    ]));
3017
3018    let root = VStack::from_children(vec![
3019        StackChild::Entry(
3020            StackEntry::new(scroll_view.clone())
3021                .basis(0)
3022                .grow(1)
3023                .shrink(1)
3024                .min_size(1),
3025        ),
3026        StackChild::Entry(StackEntry::new(dock).shrink(1)),
3027    ]);
3028
3029    tui.set_layout_root(Some(Arc::new(root)));
3030    tui.set_focus(Some(editor.clone()));
3031    editor.set_focused(true);
3032
3033    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
3034    //
3035    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
3036    // the old version made individually) + the registry, then routes `/`-text
3037    // through `dispatch_slash` and sends plain text directly. Each command's
3038    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
3039    // chat mutation) — the handler itself stays a thin router.
3040    //
3041    // One `CommandContext` is built and cloned for both the submit handler and
3042    // the key loop (Ctrl+L routes `/model` through the same registry); all
3043    // fields are `Arc`/cheap, so the clones are free.
3044    let ctx = CommandContext {
3045        chat: chat_container.clone(),
3046        tui: tui.clone(),
3047        tx: tx.clone(),
3048        state: state.clone(),
3049        editor: editor.clone(),
3050        editor_container: editor_container.clone(),
3051        lane: lane.clone(),
3052        model_catalog: model_catalog_arc.clone(),
3053        lane_model_id: lane_model_id.clone(),
3054        cwd: cwd.clone(),
3055        resources: resources_arc.clone(),
3056        reload_context: Arc::new(reload_context.clone()),
3057    };
3058    let ctx_for_cb = ctx.clone();
3059    let registry_for_cb = registry.clone();
3060    editor.on_submit(Arc::new(move |text: &str| {
3061        let text = text.trim();
3062        if text.is_empty() {
3063            return;
3064        }
3065
3066        if text.starts_with('/') {
3067            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
3068            return;
3069        }
3070
3071        let run_status = *ctx_for_cb.state.status.lock().unwrap();
3072        if run_status != RunStatus::Idle {
3073            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
3074            let aborting = run_status == RunStatus::Aborting;
3075            let lane = ctx_for_cb.lane.clone();
3076            let chat = ctx_for_cb.chat.clone();
3077            let tui = ctx_for_cb.tui.clone();
3078            tokio::spawn(async move {
3079                // Queue immediately while the agent loop is still running.
3080                // Routing this through the TUI's main channel delayed it until
3081                // `prompt_text()` returned, after the loop's drain points had
3082                // passed, so the queued message appeared to disappear.
3083                let result = if aborting {
3084                    lane.next_run(message).await
3085                } else {
3086                    lane.steer(message).await
3087                };
3088                if let Err(error) = result {
3089                    add_error_message(&chat, &format!("Could not queue message: {error}"));
3090                    tui.request_render(false);
3091                }
3092            });
3093            add_note_message(
3094                &ctx_for_cb.chat,
3095                &format!("Queued steering message: {text}"),
3096            );
3097            ctx_for_cb.tui.request_render(false);
3098            return;
3099        }
3100
3101        if !ctx_for_cb.state.try_start_working() {
3102            return;
3103        }
3104
3105        add_user_message(&ctx_for_cb.chat, text);
3106        // A new prompt starts a fresh interaction at the tail even when the
3107        // user had scrolled up to inspect older output.
3108        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
3109            scroll.scroll_to_end();
3110        }
3111        ctx_for_cb.tui.request_render(false);
3112        // Remember the message for ↑ recall (slash commands are not part of
3113        // the replayable message history).
3114        push_history(&ctx_for_cb.state, text);
3115        if ctx_for_cb
3116            .tx
3117            .send(TuiMessage::UserInput(text.to_string()))
3118            .is_err()
3119        {
3120            ctx_for_cb.state.set_status(RunStatus::Idle);
3121        }
3122    }));
3123
3124    tui.start_readerless();
3125
3126    // ---- Streaming drain task ----
3127    let drain_handle = if let Some(rx) = event_rx {
3128        let tui_drain = tui.clone();
3129        let state_drain = state.clone();
3130        let chat_drain = chat_container.clone();
3131        Some(tokio::spawn(async move {
3132            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
3133        }))
3134    } else {
3135        None
3136    };
3137
3138    // ---- B5d: plugin→TUI reload bridge ----
3139    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
3140    // (its cdylib would be unmapped while the call frame is still on the stack).
3141    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
3142    // (an `UnboundedSender<()>`); this task drains those signals and forwards
3143    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
3144    // `reload_extension_resources` routine asynchronously. The mailbox is the
3145    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
3146    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
3147    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
3148    reload_context.mailbox.install(reload_sig_tx);
3149    let reload_tx = tx.clone();
3150    let reload_bridge_handle = tokio::spawn(async move {
3151        while reload_sig_rx.recv().await.is_some() {
3152            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
3153                break; // main loop gone — stop forwarding
3154            }
3155        }
3156    });
3157
3158    // ---- Render-tick task (advances the loader spinner while Working) ----
3159    //
3160    // The `Loader` only advances its frame on render; without a periodic
3161    // `request_render` the spinner visibly freezes between events.
3162    let tui_tick = tui.clone();
3163    let state_tick = state.clone();
3164    let tick_handle = tokio::spawn(async move {
3165        // 80ms — pi's loader DEFAULT_INTERVAL_MS (the spinner would visibly
3166        // stutter at the old 120ms).
3167        let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
3168        interval.tick().await; // discard immediate
3169        loop {
3170            interval.tick().await;
3171            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
3172            if working {
3173                if state_tick.bash_components.lock().unwrap().is_empty() {
3174                    // Only the dock loader animates. Keep the already-rendered
3175                    // transcript instead of rebuilding a long history at 12.5
3176                    // frames per second.
3177                    tui_tick.request_render_reusing_scroll_content();
3178                } else {
3179                    // A running bash panel owns a loader inside the transcript.
3180                    tui_tick.request_render(false);
3181                }
3182            }
3183        }
3184    });
3185
3186    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
3187    let running = Arc::new(std::sync::Mutex::new(true));
3188    let running_key = running.clone();
3189    let tx_for_key = tx.clone();
3190    let tui_for_key = tui.clone();
3191    let editor_for_key = editor.clone();
3192    let scroll_for_key = scroll_view.clone();
3193    let lane_for_key = lane.clone();
3194    let state_for_key = state.clone();
3195    // Ctrl+L routes through the same registry as `/model` (one path, not two),
3196    // so the key loop needs the same `CommandContext` + registry the submit
3197    // handler uses. All fields are `Arc`/cheap, so this clone is free.
3198    let ctx_for_key = ctx.clone();
3199    let registry_for_key = registry.clone();
3200
3201    let key_handle = tokio::task::spawn_blocking(move || {
3202        loop {
3203            if !*running_key.lock().unwrap() {
3204                break;
3205            }
3206            // `event::read()` blocks indefinitely. Poll first so shutdown can
3207            // stop and join this worker even when no further key arrives.
3208            match crossterm::event::poll(std::time::Duration::from_millis(50)) {
3209                Ok(true) => {}
3210                Ok(false) => continue,
3211                Err(_) => {
3212                    let _ = tx_for_key.send(TuiMessage::Exit);
3213                    break;
3214                }
3215            }
3216            let Ok(ev) = crossterm::event::read() else {
3217                let _ = tx_for_key.send(TuiMessage::Exit);
3218                break;
3219            };
3220            // `Event::Resize` is delivered as its own event (not a Key). With
3221            // `start_readerless` there is no competing terminal-reader thread to
3222            // handle it, so refresh the cached terminal size here and force a
3223            // full redraw so the constrained layout re-fits the new dimensions.
3224            if let Event::Resize(_cols, _rows) = ev {
3225                tui_for_key.refresh_size();
3226                continue;
3227            }
3228            // Mouse wheel scrolls the transcript (pi supports wheel
3229            // scrolling). Previously every non-Key event was dropped, so a
3230            // wheel had zero effect — "滚动还是不行".
3231            if let Event::Mouse(m) = ev {
3232                use crossterm::event::MouseEventKind;
3233                match m.kind {
3234                    MouseEventKind::ScrollUp => {
3235                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
3236                        if scroll_for_key.scroll_by(delta) != delta {
3237                            tui_for_key.request_render_reusing_scroll_content();
3238                        }
3239                    }
3240                    MouseEventKind::ScrollDown => {
3241                        let delta = MOUSE_WHEEL_SCROLL_LINES;
3242                        if scroll_for_key.scroll_by(delta) != delta {
3243                            tui_for_key.request_render_reusing_scroll_content();
3244                        }
3245                    }
3246                    _ => {}
3247                }
3248                continue;
3249            }
3250            let Event::Key(key) = ev else {
3251                continue;
3252            };
3253            // Drop releases but preserve Repeat so holding arrows, Backspace,
3254            // PageUp, etc. behaves naturally. Windows emits Press + Release
3255            // for a tap; terminals with keyboard enhancement may additionally
3256            // emit Repeat while a key is held.
3257            if !should_dispatch_key(key.kind) {
3258                continue;
3259            }
3260
3261            if state_for_key.extension_editor_open()
3262                && key.modifiers == KeyModifiers::CONTROL
3263                && key.code == KeyCode::Char('c')
3264            {
3265                close_extension_editor(
3266                    &state_for_key,
3267                    &ctx_for_key.editor_container,
3268                    &editor_for_key,
3269                );
3270                tui_for_key.request_render_reusing_scroll_content();
3271                continue;
3272            }
3273
3274            // 0. Ctrl+C: copy the selection when the editor has one (pi
3275            //    `tui.input.copy`); otherwise it's the escape hatch — even
3276            //    with a selector open (a stuck run or a mis-open selector must
3277            //    never trap the user): abort an active run, else exit.
3278            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
3279                if !state_for_key.selector_open() && editor_for_key.has_selection() {
3280                    editor_for_key.copy_selection();
3281                    continue;
3282                }
3283                let status = *state_for_key.status.lock().unwrap();
3284                match status {
3285                    RunStatus::Working => {
3286                        state_for_key.set_status(RunStatus::Aborting);
3287                        let lane = lane_for_key.clone();
3288                        tokio::spawn(async move {
3289                            let _ = lane.abort().await;
3290                        });
3291                    }
3292                    // A held Ctrl+C can emit Repeat immediately after Press.
3293                    // Keep waiting for the in-flight cancellation instead of
3294                    // treating that repeat as a request to exit the process.
3295                    RunStatus::Aborting => {}
3296                    RunStatus::Idle => {
3297                        let _ = tx_for_key.send(TuiMessage::Exit);
3298                    }
3299                }
3300                continue;
3301            }
3302
3303            // 1. A selector overlay is open → route to it first. Only Esc
3304            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
3305            //    escape to the selector; on done/cancel the selector callbacks
3306            //    restore the editor and clear `active_selector`.
3307            if state_for_key.selector_open() {
3308                // Esc always cancels the selector (even with modifiers off).
3309                // Route through `SelectList::handle_key(Esc)` so the list's
3310                // `on_cancel` fires (the `/scoped-models` toggle selector saves
3311                // its edits there) — the old shortcut called `close_selector`
3312                // directly and skipped the callback.
3313                if key.code == KeyCode::Esc {
3314                    let (selector, _kind) = state_for_key
3315                        .active_selector
3316                        .lock()
3317                        .unwrap()
3318                        .clone()
3319                        .expect("selector_open guaranteed Some");
3320                    selector.handle_key(key);
3321                    continue;
3322                }
3323                let (selector, _kind) = state_for_key
3324                    .active_selector
3325                    .lock()
3326                    .unwrap()
3327                    .clone()
3328                    .expect("selector_open guaranteed Some");
3329                selector.handle_key(key);
3330                tui_for_key.request_render_reusing_scroll_content();
3331                continue;
3332            }
3333
3334            // Extension editor occupies the same input slot as the native
3335            // editor. Esc cancels it; every other key is delivered to the
3336            // extension-owned editor instance.
3337            if state_for_key.extension_editor_open() {
3338                let extension_editor = state_for_key
3339                    .active_extension_editor
3340                    .lock()
3341                    .unwrap()
3342                    .clone()
3343                    .expect("extension_editor_open guaranteed Some");
3344                if key.code == KeyCode::Esc {
3345                    close_extension_editor(
3346                        &state_for_key,
3347                        &ctx_for_key.editor_container,
3348                        &editor_for_key,
3349                    );
3350                } else {
3351                    extension_editor.handle_key(key);
3352                }
3353                tui_for_key.request_render_reusing_scroll_content();
3354                continue;
3355            }
3356
3357            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
3358            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
3359            //     editor. With a run active, abort it first (same as Ctrl+C)
3360            //     so the key is never a no-op while a stuck command runs.
3361            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('d') {
3362                let status = *state_for_key.status.lock().unwrap();
3363                match status {
3364                    RunStatus::Working => {
3365                        state_for_key.set_status(RunStatus::Aborting);
3366                        let lane = lane_for_key.clone();
3367                        tokio::spawn(async move {
3368                            let _ = lane.abort().await;
3369                        });
3370                        continue;
3371                    }
3372                    RunStatus::Aborting => continue,
3373                    RunStatus::Idle => {}
3374                }
3375                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
3376                    // Editor holds text — delete the char forward (pi parity).
3377                    editor_for_key.handle_key(key);
3378                    refresh_autocomplete(&state_for_key, &editor_for_key);
3379                    tui_for_key.request_render_reusing_scroll_content();
3380                    continue;
3381                }
3382                let _ = tx_for_key.send(TuiMessage::Exit);
3383                continue;
3384            }
3385
3386            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
3387            //     selector is open Esc already cancelled it above; when idle,
3388            //     Esc falls through to the editor (no-op-ish). Only fire while
3389            //     Working so an idle Esc doesn't abort a non-existent run.
3390            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc {
3391                let status = *state_for_key.status.lock().unwrap();
3392                if status == RunStatus::Working {
3393                    state_for_key.set_status(RunStatus::Aborting);
3394                    let lane = lane_for_key.clone();
3395                    tokio::spawn(async move {
3396                        let _ = lane.abort().await;
3397                    });
3398                    continue;
3399                }
3400            }
3401
3402            // 2c. Ctrl+T: toggle expansion on the most recent tool component.
3403            //     The key loop tracks no per-line focus, so this is an "expand
3404            //     last tool" affordance rather than a cursor-targeted toggle
3405            //     (documented limitation; see `toggle_expand_last_tool`).
3406            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('t') {
3407                state_for_key.toggle_expand_last_tool();
3408                tui_for_key.request_render(false);
3409                continue;
3410            }
3411
3412            // 2d. Ctrl+M: cycle to the next model in the catalog after the one
3413            //     currently tracked in `current_model_id`, apply it live via
3414            //     `lane.set_model` (takes effect on the next user message — the
3415            //     in-flight run's config is already snapshotted), and update the
3416            //     footer. `set_model` is async so it runs on a spawned task.
3417            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('m') {
3418                let current = state_for_key.current_model_id();
3419                // Cycle within the `/scoped-models` set (settings.json) when
3420                // configured; otherwise the full catalog.
3421                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
3422                if let Some(next) = cycle_next_model(&scope, &current) {
3423                    state_for_key.set_current_model(&next);
3424                    let lane = lane_for_key.clone();
3425                    tokio::spawn(async move {
3426                        let _ = lane.set_model(next).await;
3427                    });
3428                    tui_for_key.request_render_reusing_scroll_content();
3429                }
3430                continue;
3431            }
3432
3433            // 3. Ctrl+L: open the model selector. Routed through the `/model`
3434            //    command so the hotkey and the slash command share one path
3435            //    (TS binds Ctrl+L to model-select).
3436            if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('l') {
3437                if let Some(cmd) = registry_for_key.find("/model") {
3438                    cmd.execute(&ctx_for_key, "");
3439                }
3440                continue;
3441            }
3442
3443            // 4. Tab: accept the top autocomplete suggestion (if any).
3444            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
3445                if accept_top_suggestion(&state_for_key, &editor_for_key) {
3446                    tui_for_key.request_render_reusing_scroll_content();
3447                }
3448                continue;
3449            }
3450
3451            // 5. Global transcript scroll. PageUp/PageDown use the actual
3452            // viewport height with four rows of overlap (upstream behavior),
3453            // while Home/End jump to the transcript boundaries.
3454            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
3455                let delta = -transcript_page_size(scroll_for_key.viewport_height());
3456                if scroll_for_key.scroll_by(delta) != delta {
3457                    tui_for_key.request_render_reusing_scroll_content();
3458                }
3459                continue;
3460            }
3461            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
3462                let delta = transcript_page_size(scroll_for_key.viewport_height());
3463                if scroll_for_key.scroll_by(delta) != delta {
3464                    tui_for_key.request_render_reusing_scroll_content();
3465                }
3466                continue;
3467            }
3468            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
3469                scroll_for_key.scroll_to_start();
3470                tui_for_key.request_render_reusing_scroll_content();
3471                continue;
3472            }
3473            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
3474                scroll_for_key.scroll_to_end();
3475                tui_for_key.request_render_reusing_scroll_content();
3476                continue;
3477            }
3478
3479            // 5b. ↑/↓ browse submitted-message history when the editor is
3480            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
3481            //     without the surprise of replacing typed text. When the
3482            //     editor holds content, ↑/↓ fall through to cursor movement
3483            //     (typing "hello", pressing ↑ at the start, must never swap
3484            //     the draft for a history entry — reported as "text
3485            //     disappeared"). Once browsing, ↓ walks back and restores the
3486            //     draft.
3487            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
3488                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3489                if editor_for_key.get_text().is_empty() || browsing {
3490                    navigate_history(&state_for_key, &editor_for_key, -1);
3491                    tui_for_key.request_render_reusing_scroll_content();
3492                    continue;
3493                }
3494            }
3495            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
3496                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
3497                if editor_for_key.get_text().is_empty() || browsing {
3498                    navigate_history(&state_for_key, &editor_for_key, 1);
3499                    tui_for_key.request_render_reusing_scroll_content();
3500                    continue;
3501                }
3502            }
3503
3504            // Alt+Enter queues a follow-up while a run is active. It is
3505            // handled here because Editor treats only a bare Enter as submit;
3506            // idle Alt+Enter keeps the normal prompt behavior.
3507            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
3508                let prompt = editor_for_key.get_text().trim().to_string();
3509                if prompt.is_empty() {
3510                    continue;
3511                }
3512                editor_for_key.clear();
3513                let status = *state_for_key.status.lock().unwrap();
3514                if status == RunStatus::Idle {
3515                    if state_for_key.try_start_working() {
3516                        add_user_message(&state_for_key.chat_container, &prompt);
3517                        push_history(&state_for_key, &prompt);
3518                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
3519                    }
3520                } else {
3521                    add_note_message(
3522                        &state_for_key.chat_container,
3523                        &format!("Queued follow-up message: {prompt}"),
3524                    );
3525                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
3526                    let lane = lane_for_key.clone();
3527                    let chat = state_for_key.chat_container.clone();
3528                    let tui = tui_for_key.clone();
3529                    tokio::spawn(async move {
3530                        if let Err(error) = lane.follow_up(message).await {
3531                            add_error_message(&chat, &format!("Could not queue message: {error}"));
3532                            tui.request_render(false);
3533                        }
3534                    });
3535                }
3536                tui_for_key.request_render(false);
3537                continue;
3538            }
3539
3540            // 6. Otherwise forward to the editor + refresh autocomplete.
3541            editor_for_key.handle_key(key);
3542            refresh_autocomplete(&state_for_key, &editor_for_key);
3543            tui_for_key.request_render_reusing_scroll_content();
3544        }
3545    });
3546
3547    // ---- Initial prompts (run before reading from the channel) ----
3548    let mut prompts: Vec<String> = Vec::new();
3549    if let Some(init) = initial {
3550        prompts.push(init);
3551    }
3552    for m in extra_messages {
3553        prompts.push(m.clone());
3554    }
3555    for prompt in prompts {
3556        if !*running.lock().unwrap() {
3557            break;
3558        }
3559        add_user_message(&chat_container, &prompt);
3560        tui.request_render(false);
3561        run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3562    }
3563
3564    // ---- Main loop: process submitted input + lifecycle messages ----
3565    loop {
3566        if !*running.lock().unwrap() {
3567            break;
3568        }
3569        match rx.recv().await {
3570            Some(TuiMessage::UserInput(prompt)) => {
3571                // Clear the editor so the next prompt starts fresh (the submit
3572                // handler runs on the blocking key thread and can't mutate the
3573                // editor state safely there; clearing here, on the async loop,
3574                // keeps it on one thread).
3575                editor.clear();
3576                run_prompt_streaming(&lane, &prompt, &tui, &state, drain_handle.is_some()).await;
3577            }
3578            Some(TuiMessage::OpenTree) => {
3579                if *state.status.lock().unwrap() != RunStatus::Idle {
3580                    add_note_message(
3581                        &chat_container,
3582                        "Wait for the current run to finish before opening the tree.",
3583                    );
3584                    tui.request_render(false);
3585                } else {
3586                    open_tree_selector(
3587                        &harness,
3588                        &state,
3589                        &editor_container,
3590                        &editor,
3591                        &tui,
3592                        &chat_container,
3593                        &tx,
3594                    )
3595                    .await;
3596                }
3597            }
3598            Some(TuiMessage::NavigateTree(entry_id)) => {
3599                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
3600                    Ok(result) => match result.outcome {
3601                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
3602                            chat_container.clear();
3603                            add_welcome_message(&chat_container);
3604                            render_session_history(
3605                                &harness,
3606                                &chat_container,
3607                                state.markdown_transformer(),
3608                                Some(state.extension_session.clone()),
3609                            )
3610                            .await;
3611                            add_note_message(
3612                                &chat_container,
3613                                "Moved to the selected session entry.",
3614                            );
3615                        }
3616                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
3617                            add_error_message(&chat_container, &error.message);
3618                        }
3619                        _ => add_note_message(
3620                            &chat_container,
3621                            "The selected entry could not be opened.",
3622                        ),
3623                    },
3624                    Err(error) => add_error_message(
3625                        &chat_container,
3626                        &format!("Could not navigate session tree: {error}"),
3627                    ),
3628                }
3629                tui.request_render(false);
3630            }
3631            Some(TuiMessage::ClearChat) => {
3632                chat_container.clear();
3633                add_welcome_message(&chat_container);
3634                tui.request_render(false);
3635            }
3636            Some(TuiMessage::Compact) => {
3637                run_compact(&lane, &tui, &state).await;
3638            }
3639            Some(TuiMessage::Copy) => {
3640                copy_last_assistant(&state, &chat_container);
3641                tui.request_render(false);
3642            }
3643            Some(TuiMessage::Exit) => {
3644                *running.lock().unwrap() = false;
3645                break;
3646            }
3647            Some(TuiMessage::SwitchSession(id)) => {
3648                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
3649                tui.request_render(false);
3650            }
3651            Some(TuiMessage::ImportSession(path)) => {
3652                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
3653                tui.request_render(false);
3654            }
3655            Some(TuiMessage::ShareSession) => {
3656                share_session(&harness, &chat_container).await;
3657                tui.request_render(false);
3658            }
3659            Some(TuiMessage::SetSessionName(name)) => {
3660                let outcome = harness.session().set_name(Some(&name)).await;
3661                match outcome {
3662                    Ok(_) => add_note_message(
3663                        &chat_container,
3664                        &format!("Session renamed to \"{name}\"."),
3665                    ),
3666                    Err(e) => add_error_message(
3667                        &chat_container,
3668                        &format!("Could not rename session: {e}"),
3669                    ),
3670                }
3671                tui.request_render(false);
3672            }
3673            Some(TuiMessage::ExportSession) => {
3674                export_session(&harness, &chat_container).await;
3675                tui.request_render(false);
3676            }
3677            Some(TuiMessage::ForkSession) => {
3678                fork_session(&harness, &cwd, &chat_container, &state).await;
3679                tui.request_render(false);
3680            }
3681            Some(TuiMessage::ReloadExtensions) => {
3682                // B5d: drive the shared reload routine on the async runtime,
3683                // then surface the outcome. `reload_context` was passed into
3684                // `interactive_tui` and is the same `Arc<ReloadContext>` the
3685                // `ReloadCommand` + the plugin mailbox both route through —
3686                // clone the `Arc` out so the borrow of `harness` (the main
3687                // loop's `&AgentHarness`) lives across the await.
3688                let reload_ctx = ctx.reload_context.clone();
3689                add_note_message(&chat_container, "Reloading extensions + resources…");
3690                tui.request_render(false);
3691                let outcome =
3692                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
3693                // B5e: the reload swapped a fresh `ExtensionSession` into the
3694                // context's cell. Rebuild the markdown transformer from that
3695                // fresh snapshot and install it on the in-flight streaming
3696                // component (so a reloaded plugin's transformer takes effect on
3697                // the visible message immediately) + future components (they
3698                // read `state.markdown_transformer()` at construction). The old
3699                // closure no-ops once its snapshot's `active` flag flips false
3700                // (reload already did that before the swap).
3701                let fresh_transformer = build_markdown_transformer(
3702                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
3703                );
3704                state.set_markdown_transformer_with_reinstall(fresh_transformer);
3705                if outcome.had_warnings {
3706                    add_error_message(
3707                        &chat_container,
3708                        &format!(
3709                            "{} (with warnings — see stderr for details).",
3710                            outcome.summary
3711                        ),
3712                    );
3713                } else {
3714                    add_note_message(&chat_container, &outcome.summary);
3715                }
3716                tui.request_render(false);
3717            }
3718            None => break,
3719        }
3720    }
3721
3722    // ---- Shutdown ----
3723    *running.lock().unwrap() = false;
3724    // The input worker checks `running` at least every 50ms. Join it before
3725    // restoring cooked mode so no late event read races terminal cleanup.
3726    let _ = key_handle.await;
3727    tick_handle.abort();
3728    if let Some(handle) = drain_handle {
3729        handle.abort();
3730    }
3731    // Drop the reload bridge: clearing the mailbox closes the signal channel,
3732    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
3733    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
3734    reload_context.mailbox.clear();
3735    reload_bridge_handle.abort();
3736    tui.stop(Default::default());
3737    println!("\nGoodbye!");
3738    let _ = args;
3739
3740    0
3741}
3742
3743// ===========================================================================
3744// Run a single prompt (streaming or blocking)
3745// ===========================================================================
3746
3747/// Drive a single prompt through the lane. When `streaming` is true, the
3748/// `AgentEvent` drain task renders the response live and this function only
3749/// awaits completion (to surface hard errors). When false (no `event_rx`),
3750/// it falls back to the blocking await-final-text path.
3751async fn run_prompt_streaming(
3752    lane: &Arc<dyn AgentLane>,
3753    prompt: &str,
3754    tui: &Arc<TuiAltScreen>,
3755    state: &Arc<TuiState>,
3756    streaming: bool,
3757) {
3758    // Ensure the run starts in a clean streaming state.
3759    state.set_status(RunStatus::Working);
3760    tui.request_render(false);
3761
3762    let outcome = lane.prompt_text(prompt, Vec::new()).await;
3763
3764    // The drain task finalized the assistant message via MessageEnd/AgentEnd,
3765    // but guard against runs that ended without a terminal event (e.g. a hard
3766    // provider rejection before any streaming) by clearing streaming state.
3767    {
3768        let mut cur = state.current_assistant.lock().unwrap();
3769        if let Some(comp) = cur.take() {
3770            comp.set_streaming(false);
3771        }
3772    }
3773
3774    state.set_status(RunStatus::Idle);
3775
3776    match outcome {
3777        Ok(result) => match &result.outcome {
3778            HarnessRunOutcome::Failed {
3779                error,
3780                final_message,
3781                ..
3782            } => {
3783                // Only add an error line if the stream did NOT already render
3784                // an assistant message for it (drain task leaves
3785                // current_assistant Some only on an abrupt end).
3786                let already_rendered = final_message.is_some();
3787                if !already_rendered {
3788                    let msg = final_message
3789                        .as_ref()
3790                        .and_then(|m| m.error_message.clone())
3791                        .unwrap_or_else(|| format!("{error:?}"));
3792                    add_error_message(&state.chat_container, &msg);
3793                }
3794            }
3795            HarnessRunOutcome::Suspended { .. } => {
3796                add_error_message(
3797                    &state.chat_container,
3798                    "Run suspended (deferred) — resume is not supported in v1.",
3799                );
3800            }
3801            HarnessRunOutcome::Aborted { final_message, .. } => {
3802                // Aborted runs render their own partial/final message via the
3803                // stream; only add a note on the blocking fallback path.
3804                if !streaming {
3805                    add_error_message(&state.chat_container, "Request aborted.");
3806                    let _ = final_message; // (rendered by the stream in streaming mode)
3807                }
3808            }
3809            HarnessRunOutcome::Completed { final_message, .. } => {
3810                if !streaming {
3811                    let text = assistant_text(final_message);
3812                    if !text.is_empty() {
3813                        add_assistant_message_blocking(
3814                            &state.chat_container,
3815                            &text,
3816                            state.markdown_transformer(),
3817                        );
3818                        *state.last_assistant_text.lock().unwrap() = text;
3819                    }
3820                }
3821            }
3822        },
3823        Err(e) => {
3824            add_error_message(&state.chat_container, &e.to_string());
3825        }
3826    }
3827
3828    tui.request_render(false);
3829}
3830
3831/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
3832/// Reports the outcome as a transcript note; v1's compaction summarizes the
3833/// session in place, so no streaming display is wired (compaction emits no
3834/// `AgentEvent`s — only the harness bus `RunEnd`).
3835async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
3836    state.set_status(RunStatus::Working);
3837    tui.request_render(false);
3838    match lane.compact(None).await {
3839        Ok(_) => {
3840            add_note_message(&state.chat_container, "Conversation compacted.");
3841        }
3842        Err(e) => {
3843            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
3844        }
3845    }
3846    state.set_status(RunStatus::Idle);
3847    tui.request_render(false);
3848}
3849
3850/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
3851/// when no clipboard is available (or the `clipboard` feature is off), prints a
3852/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
3853fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
3854    let text = state.last_assistant_text.lock().unwrap().clone();
3855    if text.is_empty() {
3856        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
3857        return;
3858    }
3859    if copy_to_clipboard(&text) {
3860        add_note_message(chat, "Copied last reply to the clipboard.");
3861    } else {
3862        // Clipboard unavailable — print the text to the transcript so the user
3863        // can select/copy it manually (degrades gracefully in headless envs).
3864        let preview: String = text.chars().take(200).collect();
3865        add_note_message(
3866            chat,
3867            &format!(
3868                "Clipboard unavailable. Last reply: {preview}{}",
3869                if text.chars().count() > 200 {
3870                    "…"
3871                } else {
3872                    ""
3873                }
3874            ),
3875        );
3876    }
3877}
3878
3879/// Best-effort clipboard write. Enabled only with the `clipboard` feature
3880/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
3881#[cfg(feature = "clipboard")]
3882fn copy_to_clipboard(text: &str) -> bool {
3883    match arboard::Clipboard::new() {
3884        Ok(mut cb) => cb.set_text(text).is_ok(),
3885        Err(_) => false,
3886    }
3887}
3888
3889#[cfg(not(feature = "clipboard"))]
3890fn copy_to_clipboard(_text: &str) -> bool {
3891    false
3892}
3893
3894/// Blocking fallback (no `event_rx`): render the final assistant text as a
3895/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
3896/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
3897/// the identity path. The blocking path only fires when `event_rx` is absent,
3898/// so it shares the same transformer the streaming path installs on its
3899/// components.
3900fn add_assistant_message_blocking(
3901    container: &Arc<Container>,
3902    text: &str,
3903    transformer: Option<MarkdownTransformer>,
3904) {
3905    if text.is_empty() {
3906        return;
3907    }
3908    let msg = Arc::new(AssistantMessageComponent::new(
3909        AssistantMessageOptions::default(),
3910    ));
3911    if let Some(t) = &transformer {
3912        msg.set_markdown_transformer(Some(t.clone()));
3913    }
3914    msg.update_text(text);
3915    container.add_child(msg);
3916    container.add_child(Arc::new(Spacer::new(1)));
3917}
3918
3919// ===========================================================================
3920// AgentEvent drain task — the streaming core
3921// ===========================================================================
3922
3923/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
3924/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
3925/// lifetime of the TUI.
3926async fn drain_agent_events(
3927    mut rx: broadcast::Receiver<AgentEvent>,
3928    tui: Arc<TuiAltScreen>,
3929    state: Arc<TuiState>,
3930    chat: Arc<Container>,
3931) {
3932    loop {
3933        match rx.recv().await {
3934            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
3935            Err(broadcast::error::RecvError::Lagged(_)) => {
3936                // We dropped some intermediate deltas; the next MessageUpdate/
3937                // MessageEnd carries a full partial snapshot so the UI re-syncs.
3938                continue;
3939            }
3940            Err(broadcast::error::RecvError::Closed) => break,
3941        }
3942    }
3943}
3944
3945/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
3946/// (`interactive-mode.ts:3068-3396`).
3947async fn handle_agent_event(
3948    event: AgentEvent,
3949    tui: &Arc<TuiAltScreen>,
3950    state: &Arc<TuiState>,
3951    chat: &Arc<Container>,
3952) {
3953    match event {
3954        AgentEvent::AgentStart => {
3955            state.set_status(RunStatus::Working);
3956            tui.request_render(false);
3957        }
3958
3959        AgentEvent::AgentEnd { .. } => {
3960            // Finalize any still-streaming assistant message.
3961            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3962                comp.set_streaming(false);
3963            }
3964            state.set_status(RunStatus::Idle);
3965            tui.request_render(false);
3966        }
3967
3968        AgentEvent::TurnStart => {
3969            // A new turn: reset the streaming-assistant guard so the next
3970            // MessageStart creates a fresh component.
3971            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3972                comp.set_streaming(false);
3973            }
3974        }
3975
3976        AgentEvent::TurnEnd {
3977            message,
3978            tool_results,
3979        } => {
3980            // Finalize the assistant message for this turn.
3981            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
3982                if let AgentMessage::Assistant(a) = &message {
3983                    comp.update_blocks(&assistant_blocks(a));
3984                }
3985                comp.set_streaming(false);
3986            }
3987            // Any tool results whose components were never ended by a
3988            // ToolExecutionEnd get a static rendering here (best-effort). The
3989            // normal path removes the component via ToolExecutionEnd; this is
3990            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
3991            let tools = state.tool_components.lock().unwrap();
3992            for tr in &tool_results {
3993                if tools.contains_key(&tr.tool_call_id) {
3994                    // Will be removed below via ToolExecutionEnd in the normal
3995                    // path; leave as-is if still present.
3996                    let _ = tr;
3997                }
3998            }
3999            drop(tools);
4000            tui.request_render(false);
4001        }
4002
4003        AgentEvent::MessageStart { message } => match message {
4004            AgentMessage::Assistant(a) => {
4005                let comp = Arc::new(AssistantMessageComponent::new(
4006                    AssistantMessageOptions::default(),
4007                ));
4008                // B5e: install the live markdown transformer so the plugin's
4009                // `register_markdown_transformer` handlers apply from the very
4010                // first streamed delta. `set_streaming` before the transform
4011                // install is fine (transform fires on `update_blocks`, below).
4012                if let Some(t) = state.markdown_transformer() {
4013                    comp.set_markdown_transformer(Some(t));
4014                }
4015                comp.set_streaming(true);
4016                // Render text AND thinking blocks in order (the old path fed
4017                // only the concatenated text, so thinking blocks never showed).
4018                comp.update_blocks(&assistant_blocks(&a));
4019                chat.add_child(comp.clone());
4020                // Spacer(1) separates this assistant turn from the next entry;
4021                // the component itself adds no leading spacer.
4022                chat.add_child(Arc::new(Spacer::new(1)));
4023                *state.current_assistant.lock().unwrap() = Some(comp);
4024                tui.request_render(false);
4025            }
4026            AgentMessage::Custom(custom) => {
4027                let payload = serde_json::json!({
4028                    "customType": custom.role,
4029                    "content": custom.content,
4030                    "details": custom.data,
4031                    "expanded": false,
4032                    "outputPad": 1,
4033                });
4034                if let Some(component) = extension_message_component(
4035                    &state.extension_session,
4036                    &custom.role,
4037                    &payload,
4038                    state.markdown_transformer(),
4039                ) {
4040                    chat.add_child(component);
4041                    chat.add_child(Arc::new(Spacer::new(1)));
4042                    tui.request_render(false);
4043                } else {
4044                    add_note_message(chat, &custom_message_fallback(&custom));
4045                    tui.request_render(false);
4046                }
4047            }
4048            // User / ToolResult / Custom starts are echoed at submit time or
4049            // via the tool-execution components; ignore user/tool dupes.
4050            _ => {}
4051        },
4052
4053        AgentEvent::MessageUpdate {
4054            message,
4055            assistant_message_event,
4056        } => {
4057            if let AgentMessage::Assistant(a) = &message {
4058                let text = assistant_text(a);
4059                let mut saw_bash_tool_call = false;
4060                // Scan content for finalized tool calls → proactively create
4061                // tool components (TS shows the tool as soon as the assistant
4062                // emits the ToolCall; ToolExecutionStart coalesces if it
4063                // already exists).
4064                for c in &a.content {
4065                    if let Content::ToolCall(tc) = c {
4066                        if tc.name == "bash" {
4067                            saw_bash_tool_call = true;
4068                            // Bash has a dedicated component. Create it here as
4069                            // well as on ToolExecutionStart because the tool
4070                            // call can become visible in a MessageUpdate first.
4071                            // Keeping it in the bash map lets Start coalesce
4072                            // with this panel instead of appending a second one.
4073                            let command = tc
4074                                .arguments
4075                                .get("command")
4076                                .and_then(|v| v.as_str())
4077                                .unwrap_or("");
4078                            let mut bash = state.bash_components.lock().unwrap();
4079                            if !bash.contains_key(&tc.id) {
4080                                let comp = Arc::new(BashExecutionComponent::new(command));
4081                                chat.add_child(comp.clone());
4082                                bash.insert(tc.id.clone(), comp);
4083                            }
4084                        } else {
4085                            let mut tools = state.tool_components.lock().unwrap();
4086                            if !tools.contains_key(&tc.id) {
4087                                let comp = Arc::new(ToolExecutionComponent::new(
4088                                    &tc.name,
4089                                    &tc.arguments.to_string(),
4090                                ));
4091                                comp.set_running();
4092                                chat.add_child(comp.clone());
4093                                tools.insert(tc.id.clone(), comp);
4094                            }
4095                        }
4096                    }
4097                }
4098                // MessageUpdate can expose the finalized bash call before
4099                // ToolExecutionStart arrives. Hide the global `Working…`
4100                // loader immediately when creating that bash panel; otherwise
4101                // it briefly appears alongside the panel's `Running…` spinner.
4102                if saw_bash_tool_call {
4103                    state.sync_working_loader_with_bash();
4104                }
4105                let _ = assistant_message_event; // snapshot already applied via `a`
4106                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
4107                    // Stream the full block list (text + thinking) each update
4108                    // so thinking blocks render live as they arrive.
4109                    comp.update_blocks(&assistant_blocks(a));
4110                }
4111                *state.last_assistant_text.lock().unwrap() = text;
4112                tui.request_render(false);
4113            }
4114        }
4115
4116        AgentEvent::MessageEnd { message } => {
4117            if let AgentMessage::Assistant(a) = &message {
4118                let text = assistant_text(a);
4119                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
4120                    comp.update_blocks(&assistant_blocks(a));
4121                    comp.set_streaming(false);
4122                }
4123                // Cache the finalized text for `/copy`.
4124                if !text.is_empty() {
4125                    *state.last_assistant_text.lock().unwrap() = text;
4126                }
4127                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
4128                // the previous turn's input established a cacheable prefix; a
4129                // large input this turn that read nothing from cache means the
4130                // prefix was re-billed. No cost display — v1 has no per-run
4131                // cost tracking here.
4132                let usage = &a.usage;
4133                let prev_input = *state.last_input_tokens.lock().unwrap();
4134                if prev_input > 0
4135                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
4136                    && usage.cache_read == 0
4137                {
4138                    add_note_message(
4139                        &state.chat_container,
4140                        &format!(
4141                            "Cache miss: {} tokens re-billed",
4142                            format_tokens(usage.input)
4143                        ),
4144                    );
4145                }
4146                *state.last_input_tokens.lock().unwrap() = usage.input;
4147            }
4148            tui.request_render(false);
4149        }
4150
4151        AgentEvent::ToolExecutionStart {
4152            tool_call_id,
4153            tool_name,
4154            args,
4155        } => {
4156            if tool_name == "bash" {
4157                // Bash streams into a dedicated BashExecutionComponent (command
4158                // header + live preview + exit/truncation status) rather than a
4159                // generic ToolExecutionComponent. The command comes from the
4160                // `command` field of the bash tool args.
4161                let command = args
4162                    .get("command")
4163                    .and_then(|v| v.as_str())
4164                    .unwrap_or("")
4165                    .to_string();
4166                let mut bash_map = state.bash_components.lock().unwrap();
4167                if let Some(existing) = bash_map.get(&tool_call_id) {
4168                    // A ToolExecutionUpdate already created the panel (fast
4169                    // command — Update can arrive before Start); backfill the
4170                    // command header instead of adding a SECOND panel, which
4171                    // used to stack an empty "$ " box above the real one.
4172                    existing.set_command(&command);
4173                } else {
4174                    let comp = Arc::new(BashExecutionComponent::new(command));
4175                    chat.add_child(comp.clone());
4176                    bash_map.insert(tool_call_id.clone(), comp);
4177                }
4178            } else {
4179                let comp = {
4180                    let mut tools = state.tool_components.lock().unwrap();
4181                    if let Some(existing) = tools.get(&tool_call_id) {
4182                        existing.set_args(&args.to_string());
4183                        existing.clone()
4184                    } else {
4185                        let comp =
4186                            Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
4187                        comp.set_running();
4188                        chat.add_child(comp.clone());
4189                        tools.insert(tool_call_id.clone(), comp.clone());
4190                        comp
4191                    }
4192                };
4193                state.remember_tool(comp);
4194            }
4195            state.sync_working_loader_with_bash();
4196            tui.request_render(false);
4197        }
4198
4199        AgentEvent::ToolExecutionUpdate {
4200            tool_call_id,
4201            tool_name,
4202            partial_result,
4203            ..
4204        } => {
4205            if tool_name == "bash" {
4206                // Append the streamed chunk to the bash component's preview.
4207                // RAW text (no single-line collapsing) — the old
4208                // `summarize_tool_result` folded every newline into a `⏎`
4209                // glyph, cramming e.g. `ls -la`'s listing onto one line.
4210                let chunk = tool_result_text(&partial_result);
4211                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
4212                    bash.append_output(&chunk);
4213                } else {
4214                    // No component yet — create a running bash one so the
4215                    // partial shows (command unknown at Update time; leave blank).
4216                    let comp = Arc::new(BashExecutionComponent::new(""));
4217                    comp.append_output(&chunk);
4218                    chat.add_child(comp.clone());
4219                    state
4220                        .bash_components
4221                        .lock()
4222                        .unwrap()
4223                        .insert(tool_call_id.clone(), comp);
4224                }
4225            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
4226                // Raw multi-line text — read/ls-style tools must show their
4227                // full content, not the single-line ⏎-folded summary.
4228                comp.set_result(&tool_result_text(&partial_result), false);
4229                apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
4230                state.remember_tool(comp.clone());
4231            } else {
4232                // No component yet — create a running one so the partial shows.
4233                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4234                comp.set_running();
4235                comp.set_result(&tool_result_text(&partial_result), false);
4236                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
4237                chat.add_child(comp.clone());
4238                state
4239                    .tool_components
4240                    .lock()
4241                    .unwrap()
4242                    .insert(tool_call_id.clone(), comp.clone());
4243                state.remember_tool(comp);
4244            }
4245            state.sync_working_loader_with_bash();
4246            tui.request_render(false);
4247        }
4248
4249        AgentEvent::ToolExecutionEnd {
4250            tool_call_id,
4251            tool_name,
4252            result,
4253            is_error,
4254        } => {
4255            if tool_name == "bash" {
4256                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
4257                if let Some(bash) = bash {
4258                    finalize_bash(&bash, &result, is_error);
4259                } else {
4260                    // Bash ended without a Start/Update — render a finalized
4261                    // component directly from the result text.
4262                    let command = result
4263                        .details
4264                        .get("command")
4265                        .and_then(|v| v.as_str())
4266                        .unwrap_or("")
4267                        .to_string();
4268                    let comp = Arc::new(BashExecutionComponent::new(command));
4269                    comp.append_output(&tool_result_text(&result));
4270                    finalize_bash(&comp, &result, is_error);
4271                    chat.add_child(comp);
4272                }
4273            } else {
4274                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
4275                if let Some(comp) = comp {
4276                    comp.set_result(&tool_result_text(&result), is_error);
4277                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4278                } else {
4279                    // Tool ended without a Start/Update (e.g. a very fast tool):
4280                    // render a finalized component directly.
4281                    let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
4282                    comp.set_result(&tool_result_text(&result), is_error);
4283                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
4284                    chat.add_child(comp.clone());
4285                    state.remember_tool(comp);
4286                }
4287            }
4288            state.sync_working_loader_with_bash();
4289            tui.request_render(false);
4290        }
4291    }
4292}
4293
4294/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
4295/// tool result and mark the component complete. Mirrors the TS bash finalize
4296/// path; only the fields `BashExecutionComponent` needs are read.
4297fn finalize_bash(
4298    comp: &Arc<BashExecutionComponent>,
4299    result: &rpi_agent::AgentToolResult,
4300    is_error: bool,
4301) {
4302    // The exit code isn't in details directly (TS carries it elsewhere); use
4303    // `is_error` as the error signal and 0/1 as a best-effort exit code.
4304    let exit_code = if is_error { Some(1) } else { Some(0) };
4305    let truncated = result
4306        .details
4307        .get("truncation")
4308        .and_then(|t| t.get("truncated"))
4309        .and_then(|v| v.as_bool())
4310        .unwrap_or(false);
4311    let full_output_path = result
4312        .details
4313        .get("full_output_path")
4314        .and_then(|v| v.as_str())
4315        .map(|s| s.to_string());
4316    let truncation = BashTruncation {
4317        truncated,
4318        full_output_path,
4319    };
4320    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
4321    comp.set_complete(exit_code, cancelled, truncation);
4322}
4323
4324/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
4325/// display-diff string, render it with colors and attach to the component so
4326/// the changes show in the transcript. `write` has no diff (details: Null) and
4327/// stays a plain summary.
4328fn apply_edit_diff(
4329    comp: &Arc<ToolExecutionComponent>,
4330    tool_name: &str,
4331    details: &serde_json::Value,
4332    tui: &Arc<TuiAltScreen>,
4333) {
4334    if tool_name != "edit" {
4335        return;
4336    }
4337    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
4338        return;
4339    };
4340    if diff_text.is_empty() {
4341        return;
4342    }
4343    let width = tui.width();
4344    let lines = render_diff(diff_text, width);
4345    comp.set_diff(lines);
4346}
4347
4348/// Render an `AgentToolResult` as a single-line summary for the
4349/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
4350fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
4351    use rpi_agent::TextContentOrImage;
4352    let mut parts: Vec<String> = Vec::new();
4353    for c in &result.content {
4354        if let TextContentOrImage::Text(t) = c {
4355            parts.push(t.text.clone());
4356        }
4357    }
4358    let joined = parts.join("\n");
4359    // Keep the tool line compact: collapse to a single line, trim length.
4360    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
4361    if one_line.chars().count() > 200 {
4362        let truncated: String = one_line.chars().take(200).collect();
4363        format!("{truncated}…")
4364    } else {
4365        one_line
4366    }
4367}
4368
4369/// The raw multi-line text of a tool result (no single-line collapsing). The
4370/// bash panel needs the original line structure — the old path fed it through
4371/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
4372/// crammed e.g. `ls -la`'s whole listing onto one line.
4373fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
4374    use rpi_agent::TextContentOrImage;
4375    let mut parts: Vec<String> = Vec::new();
4376    for c in &result.content {
4377        if let TextContentOrImage::Text(t) = c {
4378            parts.push(t.text.clone());
4379        }
4380    }
4381    parts.join("\n")
4382}
4383
4384// ===========================================================================
4385// Selectors — editor-container swap (TS showSelector pattern)
4386// ===========================================================================
4387
4388/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
4389/// hiding the editor while the selector is open. Records the selector in
4390/// `state.active_selector` so the key loop routes to it.
4391fn open_selector(
4392    state: &Arc<TuiState>,
4393    editor_container: &Arc<Container>,
4394    editor: &Arc<Editor>,
4395    tui: &Arc<TuiAltScreen>,
4396    list: Arc<SelectList>,
4397    kind: SelectorKind,
4398) {
4399    // Unfocus the editor so its cursor marker doesn't render behind the list.
4400    editor.set_focused(false);
4401    // Swap: clear the container and add just the list.
4402    editor_container.clear();
4403    editor_container.add_child(list.clone());
4404    *state.active_selector.lock().unwrap() = Some((list, kind));
4405    tui.request_render(false);
4406}
4407
4408/// Restore the editor into the `editor_container` and clear the active
4409/// selector. Called by selector `on_cancel` and the Esc handler.
4410fn close_selector(
4411    state: &Arc<TuiState>,
4412    editor_container: &Arc<Container>,
4413    editor: &Arc<Editor>,
4414    tui: &Arc<TuiAltScreen>,
4415) {
4416    editor_container.clear();
4417    editor_container.add_child(editor.clone());
4418    editor.set_focused(true);
4419    *state.active_selector.lock().unwrap() = None;
4420    tui.request_render(false);
4421}
4422
4423/// Build + open the `/model` selector. Items are the resolved catalog (display
4424/// label = model name; description = id), with the current model marked.
4425/// Selecting applies the model **live** via `lane.set_model` (takes effect on
4426/// the next user message — the in-flight run's config is already snapshotted),
4427/// updates the footer, and notes the next-prompt effect.
4428fn open_model_selector(
4429    state: &Arc<TuiState>,
4430    editor_container: &Arc<Container>,
4431    editor: &Arc<Editor>,
4432    tui: &Arc<TuiAltScreen>,
4433    catalog: &[rpi_ai::Model],
4434    lane: &Arc<dyn AgentLane>,
4435    lane_model_id: &str,
4436    chat: &Arc<Container>,
4437) {
4438    let mut items: Vec<SelectItem> = Vec::new();
4439    for m in catalog {
4440        let label = if m.name.is_empty() {
4441            short_model_name(&m.id)
4442        } else {
4443            m.name.clone()
4444        };
4445        let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
4446            " (current)"
4447        } else {
4448            ""
4449        };
4450        items.push(
4451            SelectItem::new(&m.id, &label).with_description(&format!("{id}{marker}", id = m.id)),
4452        );
4453    }
4454    if items.is_empty() {
4455        add_note_message(
4456            chat,
4457            "No models in the catalog. Use --model at startup to select one.",
4458        );
4459        tui.request_render(false);
4460        return;
4461    }
4462    let list = Arc::new(SelectList::new(items, 10));
4463
4464    // Capture the catalog + lane so the on_select closure can resolve the
4465    // chosen Model and apply it. `on_select` fires on the blocking key thread,
4466    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
4467    let catalog_arc = catalog.to_vec();
4468    let state_sel = state.clone();
4469    let ec_sel = editor_container.clone();
4470    let editor_sel = editor.clone();
4471    let tui_sel = tui.clone();
4472    let chat_sel = chat.clone();
4473    let lane_sel = lane.clone();
4474    list.on_select(Arc::new(move |item| {
4475        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
4476            add_note_message(
4477                &chat_sel,
4478                &format!("Model {} not found in catalog.", item.label),
4479            );
4480            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4481            return;
4482        };
4483        state_sel.set_current_model(&model);
4484        let lane = lane_sel.clone();
4485        tokio::spawn(async move {
4486            let _ = lane.set_model(model).await;
4487        });
4488        add_note_message(
4489            &chat_sel,
4490            &format!(
4491                "Model set to {} — applies to the next message.",
4492                short_model_name(&item.value)
4493            ),
4494        );
4495        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4496    }));
4497    let state_cancel = state.clone();
4498    let ec_cancel = editor_container.clone();
4499    let editor_cancel = editor.clone();
4500    let tui_cancel = tui.clone();
4501    list.on_cancel(Arc::new(move || {
4502        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4503    }));
4504
4505    open_selector(
4506        state,
4507        editor_container,
4508        editor,
4509        tui,
4510        list,
4511        SelectorKind::Model,
4512    );
4513}
4514
4515/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
4516/// Returns `None` only when the catalog is empty or the current id isn't
4517/// found (in which case the first entry is returned — a no-op if it IS the
4518/// current). Used by the Ctrl+M model-cycle hotkey.
4519fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
4520    if catalog.is_empty() {
4521        return None;
4522    }
4523    let idx = catalog
4524        .iter()
4525        .position(|m| m.id.eq_ignore_ascii_case(current_id));
4526    match idx {
4527        Some(i) => {
4528            let next = (i + 1) % catalog.len();
4529            Some(catalog[next].clone())
4530        }
4531        None => Some(catalog[0].clone()),
4532    }
4533}
4534
4535/// Build + open the `/session` selector. Lists JSONL session files under the
4536/// default session dir (`<cwd>/.pi/sessions`). Selecting reports "restore not
4537/// implemented in v1" (existing constraint) but shows the list for
4538/// discoverability.
4539fn open_session_selector(
4540    state: &Arc<TuiState>,
4541    editor_container: &Arc<Container>,
4542    editor: &Arc<Editor>,
4543    tui: &Arc<TuiAltScreen>,
4544    cwd: &std::path::Path,
4545    tx: &mpsc::UnboundedSender<TuiMessage>,
4546) {
4547    let dir = crate::session::default_session_dir(cwd);
4548    let mut items: Vec<SelectItem> = Vec::new();
4549    if let Ok(entries) = std::fs::read_dir(&dir) {
4550        for entry in entries.flatten() {
4551            let path = entry.path();
4552            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
4553                continue;
4554            }
4555            let stem = path
4556                .file_stem()
4557                .and_then(|s| s.to_str())
4558                .unwrap_or("(unnamed)")
4559                .to_string();
4560            let display = path
4561                .file_name()
4562                .and_then(|s| s.to_str())
4563                .unwrap_or(&stem)
4564                .to_string();
4565            items.push(SelectItem::new(&stem, &display));
4566        }
4567    }
4568    if items.is_empty() {
4569        add_note_message(
4570            &state.chat_container,
4571            "No saved sessions found. Sessions are created automatically in interactive mode.",
4572        );
4573        tui.request_render(false);
4574        return;
4575    }
4576    let list = Arc::new(SelectList::new(items, 10));
4577
4578    let state_sel = state.clone();
4579    let ec_sel = editor_container.clone();
4580    let editor_sel = editor.clone();
4581    let tui_sel = tui.clone();
4582    let tx_sel = tx.clone();
4583    list.on_select(Arc::new(move |item| {
4584        // Close the selector first, then ask the async loop to hot-switch:
4585        // opening the session file + swapping the harness backing is async
4586        // (repo list/open) and must not run on the blocking key thread.
4587        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4588        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
4589    }));
4590    let state_cancel = state.clone();
4591    let ec_cancel = editor_container.clone();
4592    let editor_cancel = editor.clone();
4593    let tui_cancel = tui.clone();
4594    list.on_cancel(Arc::new(move || {
4595        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4596    }));
4597
4598    open_selector(
4599        state,
4600        editor_container,
4601        editor,
4602        tui,
4603        list,
4604        SelectorKind::Session,
4605    );
4606}
4607
4608fn custom_entry_display_text(
4609    custom_type: &str,
4610    data: Option<&serde_json::Value>,
4611) -> Option<String> {
4612    let data = data?;
4613    let text = data
4614        .get("summary")
4615        .or_else(|| data.get("text"))
4616        .or_else(|| data.get("output"))
4617        .and_then(|value| value.as_str())
4618        .filter(|value| !value.trim().is_empty())?;
4619    let label = match custom_type {
4620        "compactionSummary" => "Compaction summary",
4621        "branchSummary" => "Branch summary",
4622        "bashExecution" => "Command output",
4623        other => other,
4624    };
4625    Some(format!("{label}: {text}"))
4626}
4627
4628/// Open a selector for the current session's persisted entry tree. Selecting a
4629/// message moves the main lane leaf to that entry, then the caller reloads the
4630/// visible branch from durable storage.
4631async fn open_tree_selector(
4632    harness: &AgentHarness,
4633    state: &Arc<TuiState>,
4634    editor_container: &Arc<Container>,
4635    editor: &Arc<Editor>,
4636    tui: &Arc<TuiAltScreen>,
4637    chat: &Arc<Container>,
4638    tx: &mpsc::UnboundedSender<TuiMessage>,
4639) {
4640    let entries = match harness
4641        .session()
4642        .view("main")
4643        .find_entries(&EntryQuery {
4644            order: Some(EntryOrder::OldestFirst),
4645            ..Default::default()
4646        })
4647        .await
4648    {
4649        Ok(entries) => entries,
4650        Err(error) => {
4651            add_error_message(chat, &format!("Could not read session tree: {error}"));
4652            tui.request_render(false);
4653            return;
4654        }
4655    };
4656    let current = harness.session().get_leaf_id().await.ok().flatten();
4657    let items: Vec<SelectItem> = entries
4658        .iter()
4659        .map(|entry| {
4660            let marker = if current.as_deref() == Some(entry.id()) {
4661                " (current)"
4662            } else {
4663                ""
4664            };
4665            SelectItem::new(
4666                entry.id(),
4667                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
4668            )
4669            .with_description(&entry.id()[..entry.id().len().min(12)])
4670        })
4671        .collect();
4672    if items.is_empty() {
4673        add_note_message(chat, "The current session has no entries to navigate.");
4674        tui.request_render(false);
4675        return;
4676    }
4677    let list = Arc::new(SelectList::new(items, 12));
4678    let state_sel = state.clone();
4679    let ec_sel = editor_container.clone();
4680    let editor_sel = editor.clone();
4681    let tui_sel = tui.clone();
4682    let tx_sel = tx.clone();
4683    list.on_select(Arc::new(move |item| {
4684        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
4685        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4686    }));
4687    let state_cancel = state.clone();
4688    let ec_cancel = editor_container.clone();
4689    let editor_cancel = editor.clone();
4690    let tui_cancel = tui.clone();
4691    list.on_cancel(Arc::new(move || {
4692        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4693    }));
4694    open_selector(
4695        state,
4696        editor_container,
4697        editor,
4698        tui,
4699        list,
4700        SelectorKind::Tree,
4701    );
4702}
4703
4704/// Build + open the `/theme` selector. Presets [dark, light, monochrome];
4705/// selecting applies it live via the owned `ThemeManager` + re-renders.
4706fn open_theme_selector(
4707    state: &Arc<TuiState>,
4708    editor_container: &Arc<Container>,
4709    editor: &Arc<Editor>,
4710    tui: &Arc<TuiAltScreen>,
4711) {
4712    let items = vec![
4713        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
4714        SelectItem::new("light", "Light").with_description("Light background"),
4715        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
4716    ];
4717    let list = Arc::new(SelectList::new(items, 10));
4718
4719    let state_sel = state.clone();
4720    let ec_sel = editor_container.clone();
4721    let editor_sel = editor.clone();
4722    let tui_sel = tui.clone();
4723    let chat_sel = state.chat_container.clone();
4724    list.on_select(Arc::new(move |item| {
4725        let preset = match item.value.as_str() {
4726            "light" => ThemePreset::Light,
4727            "monochrome" => ThemePreset::Monochrome,
4728            _ => ThemePreset::Dark,
4729        };
4730        apply_theme_preset(preset);
4731        // A quick accent note so the user sees the change registered even if
4732        // the terminal's own colors mask the preset difference.
4733        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
4734        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4735        tui_sel.render_now(true);
4736    }));
4737    let state_cancel = state.clone();
4738    let ec_cancel = editor_container.clone();
4739    let editor_cancel = editor.clone();
4740    let tui_cancel = tui.clone();
4741    list.on_cancel(Arc::new(move || {
4742        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4743    }));
4744
4745    open_selector(
4746        state,
4747        editor_container,
4748        editor,
4749        tui,
4750        list,
4751        SelectorKind::Theme,
4752    );
4753}
4754
4755// ===========================================================================
4756// Feasible selectors — /thinking, /tools, /images
4757// ===========================================================================
4758
4759/// One-line descriptions for each thinking level, ported from
4760/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
4761fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4762    use rpi_ai::types::ThinkingLevel::*;
4763    match level {
4764        Off => "Off — No reasoning",
4765        Minimal => "Minimal — Brief reasoning (~1k tokens)",
4766        Low => "Low — Light reasoning (~1k tokens)",
4767        Medium => "Medium — Moderate reasoning (~80% of max)",
4768        High => "High — Extensive reasoning (~95% of max)",
4769        Xhigh => "Xhigh — Near-maximal reasoning",
4770        Max => "Max — Maximum reasoning",
4771    }
4772}
4773
4774/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
4775/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
4776fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
4777    use rpi_ai::types::ThinkingLevel::*;
4778    match level {
4779        Off => "off",
4780        Minimal => "minimal",
4781        Low => "low",
4782        Medium => "medium",
4783        High => "high",
4784        Xhigh => "xhigh",
4785        Max => "max",
4786    }
4787}
4788
4789/// Parse a thinking-level name back to the enum (case-insensitive). Returns
4790/// `None` for an unknown name; used by the `/thinking` selector callback.
4791fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
4792    use rpi_ai::types::ThinkingLevel::*;
4793    match name.to_ascii_lowercase().as_str() {
4794        "off" => Some(Off),
4795        "minimal" => Some(Minimal),
4796        "low" => Some(Low),
4797        "medium" => Some(Medium),
4798        "high" => Some(High),
4799        "xhigh" => Some(Xhigh),
4800        "max" => Some(Max),
4801        _ => None,
4802    }
4803}
4804
4805/// Build + open the `/thinking` selector. Items are the levels the current
4806/// model supports (`Model::supported_thinking_levels`), each with a
4807/// description; the current level (read beforehand via `lane.get_thinking_level`)
4808/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
4809///
4810/// `on_select` fires on the blocking key thread, so it can't await
4811/// `lane.get_thinking_level()` to know the current level — the opener resolves
4812/// it first (best-effort) and preselects; the toggle on_select just applies
4813/// whatever was picked.
4814fn open_thinking_selector(
4815    state: &Arc<TuiState>,
4816    editor_container: &Arc<Container>,
4817    editor: &Arc<Editor>,
4818    tui: &Arc<TuiAltScreen>,
4819    lane: &Arc<dyn AgentLane>,
4820    catalog: &[rpi_ai::Model],
4821    lane_model_id: &str,
4822    chat: &Arc<Container>,
4823) {
4824    // Find the current model in the catalog to read its supported levels. If
4825    // absent, fall back to all levels so the selector still opens.
4826    let model = catalog
4827        .iter()
4828        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
4829    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
4830        .map(|m| m.supported_thinking_levels())
4831        .unwrap_or_else(|| {
4832            use rpi_ai::types::ThinkingLevel::*;
4833            vec![Off, Minimal, Low, Medium, High]
4834        });
4835    let mut items: Vec<SelectItem> = Vec::new();
4836    for lvl in &levels {
4837        let name = thinking_level_name(*lvl);
4838        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
4839    }
4840    if items.is_empty() {
4841        add_note_message(chat, "This model has no supported thinking levels.");
4842        tui.request_render(false);
4843        return;
4844    }
4845    let list = Arc::new(SelectList::new(items, 10));
4846
4847    let state_sel = state.clone();
4848    let ec_sel = editor_container.clone();
4849    let editor_sel = editor.clone();
4850    let tui_sel = tui.clone();
4851    let chat_sel = chat.clone();
4852    let lane_sel = lane.clone();
4853    list.on_select(Arc::new(move |item| {
4854        let Some(level) = thinking_level_from_name(&item.value) else {
4855            add_note_message(
4856                &chat_sel,
4857                &format!("Unknown thinking level: {}.", item.label),
4858            );
4859            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4860            return;
4861        };
4862        let lane = lane_sel.clone();
4863        let footer_sel = state_sel.footer.clone();
4864        tokio::spawn(async move {
4865            let _ = lane.set_thinking_level(level).await;
4866        });
4867        // Reflect the chosen level in the footer's model suffix (pi parity:
4868        // `model • thinking off` / `model • medium`). The shown text for the
4869        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
4870        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
4871        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
4872        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4873    }));
4874    let state_cancel = state.clone();
4875    let ec_cancel = editor_container.clone();
4876    let editor_cancel = editor.clone();
4877    let tui_cancel = tui.clone();
4878    list.on_cancel(Arc::new(move || {
4879        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4880    }));
4881
4882    open_selector(
4883        state,
4884        editor_container,
4885        editor,
4886        tui,
4887        list,
4888        SelectorKind::Thinking,
4889    );
4890}
4891
4892/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
4893/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
4894/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
4895/// — the blocking key thread can't await) and selecting a tool **toggles** it
4896/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
4897fn open_tools_selector(
4898    state: &Arc<TuiState>,
4899    editor_container: &Arc<Container>,
4900    editor: &Arc<Editor>,
4901    tui: &Arc<TuiAltScreen>,
4902    lane: &Arc<dyn AgentLane>,
4903    chat: &Arc<Container>,
4904) {
4905    // Best-effort read of the current active set. The opener runs on the async
4906    // runtime (it's called from the main loop's channel dispatch or the submit
4907    // closure that lives on the blocking thread — but `handle.block_on` is safe
4908    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
4909    let active = match tokio::runtime::Handle::try_current() {
4910        Ok(h) => h
4911            .block_on(async { lane.get_active_tools().await })
4912            .unwrap_or_default(),
4913        Err(_) => Vec::new(),
4914    };
4915    let mut items: Vec<SelectItem> = Vec::new();
4916    for name in crate::session::BUILTIN_TOOL_NAMES {
4917        let on = active.iter().any(|a| a == name);
4918        let label = if on {
4919            format!("{name} (on)")
4920        } else {
4921            (*name).to_string()
4922        };
4923        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
4924    }
4925    let list = Arc::new(SelectList::new(items, 10));
4926
4927    // Capture the active set so on_select can toggle without re-reading.
4928    let active_captured = active.clone();
4929    let state_sel = state.clone();
4930    let ec_sel = editor_container.clone();
4931    let editor_sel = editor.clone();
4932    let tui_sel = tui.clone();
4933    let chat_sel = chat.clone();
4934    let lane_sel = lane.clone();
4935    list.on_select(Arc::new(move |item| {
4936        let mut next = active_captured.clone();
4937        if let Some(pos) = next.iter().position(|a| a == &item.value) {
4938            next.remove(pos);
4939        } else {
4940            next.push(item.value.clone());
4941        }
4942        let on = next.iter().any(|a| a == &item.value);
4943        let lane = lane_sel.clone();
4944        let next_clone = next.clone();
4945        tokio::spawn(async move {
4946            let _ = lane.set_active_tools(next_clone).await;
4947        });
4948        let list_str = if next.is_empty() {
4949            "(none)".to_string()
4950        } else {
4951            next.join(", ")
4952        };
4953        add_note_message(
4954            &chat_sel,
4955            &format!(
4956                "{} {} — active tools: {}",
4957                item.value,
4958                if on { "enabled" } else { "disabled" },
4959                list_str
4960            ),
4961        );
4962        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
4963    }));
4964    let state_cancel = state.clone();
4965    let ec_cancel = editor_container.clone();
4966    let editor_cancel = editor.clone();
4967    let tui_cancel = tui.clone();
4968    list.on_cancel(Arc::new(move || {
4969        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
4970    }));
4971
4972    open_selector(
4973        state,
4974        editor_container,
4975        editor,
4976        tui,
4977        list,
4978        SelectorKind::Tools,
4979    );
4980}
4981
4982/// Build + open the `/images` selector (Yes/No). Stores the choice in
4983/// `state.show_images` and notes it. Image wiring is minimal this pass — the
4984/// flag is consulted where images would be shown and echoed back here.
4985fn open_images_selector(
4986    state: &Arc<TuiState>,
4987    editor_container: &Arc<Container>,
4988    editor: &Arc<Editor>,
4989    tui: &Arc<TuiAltScreen>,
4990    chat: &Arc<Container>,
4991) {
4992    let current = *state.show_images.lock().unwrap();
4993    let items = vec![
4994        SelectItem::new("yes", "Yes").with_description(if current {
4995            "Inline images (current)"
4996        } else {
4997            "Inline images"
4998        }),
4999        SelectItem::new("no", "No").with_description(if current {
5000            "Placeholder only"
5001        } else {
5002            "Placeholder only (current)"
5003        }),
5004    ];
5005    let list = Arc::new(SelectList::new(items, 5));
5006
5007    let state_sel = state.clone();
5008    let ec_sel = editor_container.clone();
5009    let editor_sel = editor.clone();
5010    let tui_sel = tui.clone();
5011    let chat_sel = chat.clone();
5012    list.on_select(Arc::new(move |item| {
5013        let on = item.value == "yes";
5014        *state_sel.show_images.lock().unwrap() = on;
5015        add_note_message(
5016            &chat_sel,
5017            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
5018        );
5019        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
5020    }));
5021    let state_cancel = state.clone();
5022    let ec_cancel = editor_container.clone();
5023    let editor_cancel = editor.clone();
5024    let tui_cancel = tui.clone();
5025    list.on_cancel(Arc::new(move || {
5026        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
5027    }));
5028
5029    open_selector(
5030        state,
5031        editor_container,
5032        editor,
5033        tui,
5034        list,
5035        SelectorKind::Images,
5036    );
5037}
5038
5039// ===========================================================================
5040// Autocomplete
5041// ===========================================================================
5042
5043/// Refresh the autocomplete suggestion list from the current editor text +
5044/// cursor. Renders the suggestions into `autocomplete_container` (above the
5045/// editor) or clears it when there are none.
5046fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
5047    let text = editor.get_text();
5048    let (_row, col) = editor.cursor_position();
5049    // The editor's `cursor_col` is a byte offset into the current line; for
5050    // single-line input (the common case) that equals the byte offset into
5051    // `get_text()`, which is exactly what the autocomplete providers expect to
5052    // slice on. Clamp to the text length so a stale/multi-line col can't
5053    // overshoot. Providers snap to a char boundary internally as a safety net
5054    // (`autocomplete::snap_cursor`), so a byte col landing mid-character never
5055    // panics.
5056    let cursor = col.min(text.len());
5057    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
5058    render_autocomplete(state, suggestions);
5059}
5060
5061/// Render (or clear) the autocomplete suggestion list into the container.
5062fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
5063    state.autocomplete_container.clear();
5064    let Some(sugg) = suggestions else {
5065        return;
5066    };
5067    if sugg.items.is_empty() {
5068        return;
5069    }
5070    // Build a compact list: top item marked with `→`, rest with `  `.
5071    // Cap at 5 lines so the dock doesn't swallow the transcript.
5072    let accent = state.theme_manager.get().colors.accent;
5073    let muted = state.theme_manager.get().colors.muted;
5074    for (i, item) in sugg.items.iter().take(5).enumerate() {
5075        let prefix = if i == 0 { "→ " } else { "  " };
5076        let label = item.display_text();
5077        let line = if i == 0 {
5078            format!(
5079                "{prefix}{} {}",
5080                accent.fg(label),
5081                muted.fg(item.description.as_deref().unwrap_or(""))
5082            )
5083        } else {
5084            format!(
5085                "{prefix}{} {}",
5086                muted.fg(label),
5087                muted.fg(item.description.as_deref().unwrap_or(""))
5088            )
5089        };
5090        state
5091            .autocomplete_container
5092            .add_child(Arc::new(Text::new(line, 1, 0)));
5093    }
5094}
5095
5096/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
5097/// suggestion text, reposition the caret, and clear the suggestion list.
5098/// Returns `true` if a suggestion was accepted.
5099fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
5100    let text = editor.get_text();
5101    let (_row, col) = editor.cursor_position();
5102    let cursor = col.min(text.len());
5103    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
5104        return false;
5105    };
5106    let Some(top) = sugg.items.first() else {
5107        return false;
5108    };
5109    // Replace the [start, end) span with the suggestion text. `start`/`end`
5110    // are byte offsets emitted by the providers on char boundaries, so the
5111    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
5112    let start = sugg.start.min(text.len());
5113    let end = sugg.end.min(text.len());
5114    let mut replaced = String::with_capacity(text.len() + top.text.len());
5115    replaced.push_str(&text[..start]);
5116    replaced.push_str(&top.text);
5117    // Keep the text AFTER the replaced span (mid-line completion: replacing
5118    // `[start, end)` must not drop the rest of the line).
5119    replaced.push_str(&text[end..]);
5120    if top.insert_space && !replaced.ends_with('/') {
5121        replaced.push(' ');
5122    }
5123    // New caret position: after the inserted text (byte offset; the editor
5124    // snaps `set_cursor` to a char boundary as a safety net).
5125    let new_cursor = replaced.len().min(
5126        start
5127            + top.text.len()
5128            + if top.insert_space && !top.text.ends_with('/') {
5129                1
5130            } else {
5131                0
5132            },
5133    );
5134    editor.set_text(&replaced);
5135    editor.set_cursor(0, new_cursor);
5136    state.autocomplete_container.clear();
5137    true
5138}
5139
5140// ===========================================================================
5141// Transcript message helpers
5142// ===========================================================================
5143
5144/// Add the welcome header to the chat container.
5145fn add_welcome_message(container: &Arc<Container>) {
5146    let c = current_theme().colors;
5147    // Accent logotype + a dim tagline, separated from the rest by a thin
5148    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
5149    // to the body text, so the header didn't read as a header.
5150    let title = format!(
5151        "{} {}",
5152        c.accent.fg(&tui_bold("rpi")),
5153        c.muted.fg("interactive TUI")
5154    );
5155    container.add_child(Arc::new(Text::new(title, 1, 0)));
5156    container.add_child(Arc::new(Spacer::new(1)));
5157    container.add_child(Arc::new(Text::new(
5158        c.dim.fg("Type your message and press Enter to send."),
5159        1,
5160        0,
5161    )));
5162    let hint = c
5163        .dim
5164        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
5165    container.add_child(Arc::new(Text::new(hint, 1, 0)));
5166    container.add_child(Arc::new(DynamicBorder::new()));
5167}
5168
5169/// Add the `/help` command listing to the chat container.
5170fn add_help_message(container: &Arc<Container>) {
5171    let c = current_theme().colors;
5172    // Section header + a thin themed rule, then a two-column command table:
5173    // `cmd` in accent, `— desc` in muted. The old single-space layout made
5174    // the description column wander depending on command length.
5175    container.add_child(Arc::new(Text::new(
5176        c.md_heading.fg(&tui_bold("📚 Available Commands")),
5177        1,
5178        0,
5179    )));
5180    container.add_child(Arc::new(Spacer::new(1)));
5181
5182    let cmds: &[(&str, &str)] = &[
5183        ("/help, /?", "Show this help message"),
5184        ("/clear, /new", "Clear the conversation"),
5185        ("/exit, /quit, /q", "Exit the application"),
5186        ("/version, /v", "Show version information"),
5187        ("/model, /m", "Choose a model (live switch)"),
5188        ("/thinking, /think", "Set reasoning depth (selector)"),
5189        ("/tools", "Toggle built-in tools on/off"),
5190        ("/images", "Toggle inline image rendering"),
5191        ("/session", "List saved sessions"),
5192        ("/theme", "Choose a theme (selector)"),
5193        ("/compact", "Compact the conversation"),
5194        ("/copy", "Copy last reply to clipboard"),
5195        ("/hotkeys", "Show keyboard shortcuts"),
5196        ("/armin", "🐾 Easter egg"),
5197        ("/earendil", "Earendil announcement"),
5198    ];
5199    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5200    for (cmd, desc) in cmds {
5201        let row = format!(
5202            "  {:<cmd_w$}  {}  {}",
5203            c.accent.fg(cmd),
5204            c.dim.fg("—"),
5205            c.muted.fg(desc)
5206        );
5207        container.add_child(Arc::new(Text::new(row, 1, 0)));
5208    }
5209    container.add_child(Arc::new(Spacer::new(1)));
5210}
5211
5212/// Add the `/version` block to the chat container.
5213fn add_version_message(container: &Arc<Container>) {
5214    let c = current_theme().colors;
5215    container.add_child(Arc::new(Text::new(
5216        c.md_heading.fg(&tui_bold("📦 Version Information")),
5217        1,
5218        0,
5219    )));
5220    container.add_child(Arc::new(Spacer::new(1)));
5221    // Use the crate version (kept in sync via `version.workspace = true`)
5222    // instead of the stale hardcoded "v0.1.2".
5223    container.add_child(Arc::new(Text::new(
5224        format!(
5225            "  {} {}",
5226            c.muted.fg("rpi-cli"),
5227            c.text.fg(&format!("v{}", crate::VERSION))
5228        ),
5229        1,
5230        0,
5231    )));
5232    container.add_child(Arc::new(Text::new(
5233        format!(
5234            "  {}",
5235            c.dim.fg("Rust implementation of pi coding agent TUI")
5236        ),
5237        1,
5238        0,
5239    )));
5240    container.add_child(Arc::new(Spacer::new(1)));
5241}
5242
5243/// Add the `/hotkeys` block to the chat container.
5244fn add_hotkeys_message(container: &Arc<Container>) {
5245    let c = current_theme().colors;
5246    container.add_child(Arc::new(Text::new(
5247        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
5248        1,
5249        0,
5250    )));
5251    container.add_child(Arc::new(Spacer::new(1)));
5252    let keys: &[(&str, &str)] = &[
5253        ("Enter", "Send message"),
5254        ("Shift+Enter", "New line"),
5255        ("Tab", "Accept autocomplete suggestion"),
5256        ("Ctrl+A / Ctrl+E", "Line start / end"),
5257        (
5258            "Ctrl+K / Ctrl+U",
5259            "Kill to end / start of line (Ctrl+Y yanks)",
5260        ),
5261        ("Ctrl+- / Ctrl+R", "Undo / redo"),
5262        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
5263        ("Alt+Backspace", "Kill previous word"),
5264        ("Ctrl+C", "Abort a run, or exit when idle"),
5265        ("Esc", "Abort a running prompt"),
5266        ("Ctrl+L", "Open model selector"),
5267        ("Ctrl+M", "Cycle to the next model (live)"),
5268        ("Ctrl+T", "Expand/collapse last tool result"),
5269        ("PageUp/Down", "Scroll transcript by one page"),
5270        ("Home / End", "Jump to transcript start / latest output"),
5271    ];
5272    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
5273    for (key, desc) in keys {
5274        let row = format!(
5275            "  {:<key_w$}  {}  {}",
5276            c.accent.fg(key),
5277            c.dim.fg("—"),
5278            c.muted.fg(desc)
5279        );
5280        container.add_child(Arc::new(Text::new(row, 1, 0)));
5281    }
5282    container.add_child(Arc::new(Spacer::new(1)));
5283}
5284
5285/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
5286/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
5287/// plain `> text` echo. A trailing Spacer(1) separates it from the next
5288// transcript entry (every entry contributes one trailing spacer so
5289// consecutive turns are separated by exactly one blank line).
5290fn add_user_message(container: &Arc<Container>, text: &str) {
5291    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
5292    container.add_child(Arc::new(Spacer::new(1)));
5293}
5294
5295/// Add an error message to the chat container.
5296fn add_error_message(container: &Arc<Container>, text: &str) {
5297    let c = current_theme().colors;
5298    container.add_child(Arc::new(Text::new(
5299        format!("  {} {}", c.error.fg("✗"), c.error.fg(text)),
5300        1,
5301        0,
5302    )));
5303    container.add_child(Arc::new(Spacer::new(1)));
5304}
5305
5306/// Add a neutral note (e.g. unsupported-command message) to the chat container.
5307fn add_note_message(container: &Arc<Container>, text: &str) {
5308    let c = current_theme().colors;
5309    container.add_child(Arc::new(Text::new(
5310        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
5311        1,
5312        0,
5313    )));
5314    container.add_child(Arc::new(Spacer::new(1)));
5315}
5316
5317/// Render the `/context` panel: a transcript message listing the discovered
5318/// context files, skills, and prompt templates loaded for this session
5319/// (Part A resource discovery). Reads the harness resources snapshot captured
5320/// at TUI startup (the blocking submit handler can't `await get_resources()`.
5321///
5322/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
5323/// via `/reload`); here it's a transcript note rather than an overlay since the
5324/// resource set is session-static between `/reload`s (deferred).
5325fn show_context_panel(
5326    chat: &Arc<Container>,
5327    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
5328) {
5329    let skills = resources.skills.as_deref().unwrap_or(&[]);
5330    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
5331    let mut lines: Vec<String> = Vec::new();
5332    lines.push("📂 Discovered resources for this session:".into());
5333
5334    if skills.is_empty() {
5335        lines.push(
5336            "  Skills: (none discovered — create .pi/skills/ or ~/.rpi/agent/skills/)".into(),
5337        );
5338    } else {
5339        lines.push(format!("  Skills ({}):", skills.len()));
5340        for s in skills {
5341            let marker = if s.disable_model_invocation == Some(true) {
5342                " [hidden]"
5343            } else {
5344                ""
5345            };
5346            let desc: String = s.description.chars().take(72).collect();
5347            lines.push(format!("    • {}{marker} — {desc}", s.name));
5348        }
5349    }
5350
5351    if templates.is_empty() {
5352        lines.push(
5353            "  Prompt templates: (none — create .pi/prompts/ or ~/.rpi/agent/prompts/)".into(),
5354        );
5355    } else {
5356        lines.push(format!("  Prompt templates ({}):", templates.len()));
5357        for t in templates {
5358            let desc = t
5359                .description
5360                .as_deref()
5361                .unwrap_or("(no description)")
5362                .chars()
5363                .take(72)
5364                .collect::<String>();
5365            lines.push(format!("    • /{} — {desc}", t.name));
5366        }
5367    }
5368    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
5369    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
5370    lines.push(
5371        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
5372            .into(),
5373    );
5374    let body = lines.join("\n");
5375    container_note_block(chat, &body);
5376}
5377
5378/// Append a multi-line neutral note (header line + body) to the chat container.
5379fn container_note_block(container: &Arc<Container>, body: &str) {
5380    for line in body.lines() {
5381        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
5382    }
5383    container.add_child(Arc::new(Spacer::new(1)));
5384}
5385
5386// ===========================================================================
5387// TUI support + entry detection
5388// ===========================================================================
5389
5390/// Check if the terminal supports TUI mode.
5391pub fn is_tui_supported() -> bool {
5392    std::io::stdout().is_terminal()
5393}
5394
5395// Keep the `Color` import used (theme accent rendering in autocomplete).
5396#[allow(unused_imports)]
5397use rpi_tui::Color as _Color;
5398
5399#[cfg(test)]
5400mod tests {
5401    use super::*;
5402    use rpi_tui::Component;
5403
5404    #[test]
5405    fn transcript_page_uses_viewport_with_overlap() {
5406        assert_eq!(transcript_page_size(24), 20);
5407        assert_eq!(transcript_page_size(4), 1);
5408        assert_eq!(transcript_page_size(0), 1);
5409    }
5410
5411    #[test]
5412    fn key_repeat_is_dispatched_but_release_is_not() {
5413        assert!(should_dispatch_key(KeyEventKind::Press));
5414        assert!(should_dispatch_key(KeyEventKind::Repeat));
5415        assert!(!should_dispatch_key(KeyEventKind::Release));
5416    }
5417
5418    #[test]
5419    fn test_layout_renders_welcome_message() {
5420        let chat = Arc::new(Container::new());
5421        add_welcome_message(&chat);
5422
5423        let scroll = Arc::new(ScrollView::new(
5424            chat.clone(),
5425            ScrollViewOptions {
5426                follow: FollowMode::End,
5427                primary: true,
5428                ..Default::default()
5429            },
5430        ));
5431
5432        let editor = Arc::new(Editor::new(
5433            EditorOptions {
5434                padding_x: 1,
5435                ..Default::default()
5436            },
5437            EditorStyle::default(),
5438            Arc::new(rpi_tui::Keybindings::new()),
5439        ));
5440        let dock = Arc::new(Container::new());
5441        dock.add_child(editor);
5442
5443        let footer = Arc::new(FooterComponent::new());
5444
5445        let root = VStack::from_children(vec![
5446            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
5447            StackChild::Entry(StackEntry::new(dock)),
5448            StackChild::Entry(StackEntry::new(footer)),
5449        ]);
5450
5451        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
5452
5453        let all: String = frame.lines.join("\n");
5454        assert!(
5455            all.contains("rpi"),
5456            "Welcome message not found. Rendered: {}",
5457            all
5458        );
5459        assert!(
5460            all.contains("Type your message"),
5461            "Help text not found. Rendered: {}",
5462            all
5463        );
5464    }
5465
5466    #[test]
5467    fn test_chat_container_has_welcome_content() {
5468        let chat = Arc::new(Container::new());
5469        add_welcome_message(&chat);
5470
5471        let lines = chat.render(80);
5472        let all: String = lines.join("\n");
5473        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
5474        // joined by an ANSI reset — strip ANSI before checking the substring.
5475        let plain = strip_ansi(&all);
5476        assert!(
5477            plain.contains("rpi"),
5478            "Welcome message not in chat container: {:?}",
5479            lines
5480        );
5481    }
5482
5483    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
5484    /// replaces the editor text, the NEXT rendered frame must show the
5485    /// completed text (" /model " with the caret after it), not the old
5486    /// prefix. Mirrors the real dock layout (autocomplete_container above the
5487    /// bordered editor) and drives the same accept path the Tab handler uses.
5488    #[test]
5489    fn tab_accept_suggestion_reflects_in_next_render() {
5490        use rpi_tui::render_layout_frame;
5491
5492        let editor = Arc::new(Editor::new(
5493            EditorOptions {
5494                padding_x: 1,
5495                ..Default::default()
5496            },
5497            EditorStyle::default(),
5498            Arc::new(rpi_tui::Keybindings::new()),
5499        ));
5500        editor.set_focused(true);
5501        let editor_container = Arc::new(Container::new());
5502        editor_container.add_child(editor.clone());
5503        let autocomplete_container = Arc::new(Container::new());
5504        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
5505        let dock = Arc::new(VStack::from_children(vec![
5506            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5507            StackChild::Entry(
5508                StackEntry::new(editor_container.clone())
5509                    .shrink(0)
5510                    .min_size(3),
5511            ),
5512            StackChild::Entry(StackEntry::new(footer)),
5513        ]));
5514
5515        // Simulate the user typing "/mo" (the popup shows suggestions).
5516        let mut manager = AutocompleteManager::new();
5517        let mut combined = CombinedAutocompleteProvider::new();
5518        combined.add_provider(Arc::new(
5519            SlashCommandAutocompleteProvider::with_default_commands(),
5520        ));
5521        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
5522        manager.set_provider(Arc::new(combined));
5523        // Simulate typing "/mo" via the real insert path (advances the caret
5524        // by char length, like `handle_key` does).
5525        editor.insert("/mo");
5526        assert_eq!(editor.cursor_position(), (0, 3));
5527
5528        let frame_before = render_layout_frame(dock.clone(), 80, 10);
5529        assert!(
5530            frame_before.lines.iter().any(|l| l.contains("/mo")),
5531            "precondition: editor shows the typed prefix. Frame rows:\n{}",
5532            frame_before
5533                .lines
5534                .iter()
5535                .map(|l| format!("  [{l}]"))
5536                .collect::<Vec<_>>()
5537                .join("\n")
5538        );
5539
5540        // Tab: accept the top suggestion (the same code path as the key loop).
5541        let text = editor.get_text();
5542        let (_row, col) = editor.cursor_position();
5543        let cursor = col.min(text.len());
5544        let sugg = manager
5545            .get_suggestions(&text, cursor)
5546            .expect("slash suggestions for /mo");
5547        let top = sugg.items.first().expect("at least one suggestion");
5548        let start = sugg.start.min(text.len());
5549        let end = sugg.end.min(text.len());
5550        let mut replaced = String::new();
5551        replaced.push_str(&text[..start]);
5552        replaced.push_str(&top.text);
5553        replaced.push_str(&text[end..]);
5554        if top.insert_space && !replaced.ends_with('/') {
5555            replaced.push(' ');
5556        }
5557        editor.set_text(&replaced);
5558        editor.set_cursor(0, replaced.len().min(start + top.text.len()));
5559        autocomplete_container.clear();
5560        assert_eq!(editor.get_text(), "/model");
5561
5562        // The next render MUST display the completed text.
5563        let frame_after = render_layout_frame(dock, 80, 10);
5564        let all: String = frame_after.lines.join("\n");
5565        assert!(
5566            all.contains("/model"),
5567            "completed text missing from next render. Got:\n{all}"
5568        );
5569        // The caret must sit AFTER the completed command (the snap_boundary
5570        // regression put it one char early: "/mode|l" with the final char
5571        // dangling past the caret).
5572        let editor_line = frame_after
5573            .lines
5574            .iter()
5575            .find(|l| l.contains("/model"))
5576            .expect("editor row with completed text");
5577        assert!(
5578            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
5579            "caret must follow the full completed text. Got: {editor_line:?}"
5580        );
5581    }
5582
5583    #[test]
5584    fn test_slash_command_dispatch() {
5585        // The registry is the single source of truth for dispatch: `find(token)`
5586        // returns the command (by name or alias) whose `name()` is the canonical
5587        // form, or `None` for an unknown token. This replaces the old enum-based
5588        // `handle_slash_command` assertions with equivalent registry lookups.
5589        let registry = build_builtin_registry();
5590
5591        // Helper: a token resolves to the command with this canonical name.
5592        let resolves_to = |token: &str, canonical: &str| {
5593            let found = registry.find(token).expect("{token} should resolve");
5594            assert_eq!(
5595                found.name(),
5596                canonical,
5597                "{token} resolved to {} (expected {canonical})",
5598                found.name()
5599            );
5600        };
5601
5602        resolves_to("/help", "/help");
5603        resolves_to("/?", "/help"); // alias → canonical
5604        resolves_to("/clear", "/clear");
5605        resolves_to("/new", "/clear"); // alias
5606        resolves_to("/q", "/exit"); // alias
5607        resolves_to("/quit", "/exit"); // alias
5608        resolves_to("/version", "/version");
5609        resolves_to("/v", "/version"); // alias
5610        resolves_to("/hotkeys", "/hotkeys");
5611        resolves_to("/model", "/model");
5612        resolves_to("/m", "/model"); // alias
5613        resolves_to("/theme", "/theme");
5614        resolves_to("/session", "/session");
5615        resolves_to("/resume", "/session"); // alias
5616        resolves_to("/compact", "/compact");
5617        resolves_to("/copy", "/copy");
5618        resolves_to("/thinking", "/thinking");
5619        resolves_to("/think", "/thinking"); // alias
5620        resolves_to("/tools", "/tools");
5621        resolves_to("/images", "/images");
5622        resolves_to("/armin", "/armin");
5623        resolves_to("/earendil", "/earendil");
5624        resolves_to("/context", "/context");
5625        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
5626        resolves_to("/settings", "/settings");
5627        resolves_to("/name", "/name");
5628        resolves_to("/export", "/export");
5629
5630        // Unknown token → not found.
5631        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
5632    }
5633
5634    #[test]
5635
5636    fn test_registry_visible_entries_cover_dispatch() {
5637        // The autocomplete list is derived from the registry, so every visible
5638        // command the dispatcher recognizes must appear in it — by construction,
5639        // but this guards against a future command being registered with
5640        // `visible()` / a non-empty description that the builder drops.
5641        let registry = build_builtin_registry();
5642        let names: Vec<String> = registry
5643            .visible_entries()
5644            .iter()
5645            .map(|c| c.name.clone())
5646            .collect();
5647        for recognized in [
5648            "/help",
5649            "/clear",
5650            "/new",
5651            "/exit",
5652            "/quit",
5653            "/version",
5654            "/model",
5655            "/session",
5656            "/theme",
5657            "/compact",
5658            "/copy",
5659            "/hotkeys",
5660            "/tools",
5661            "/images",
5662            "/thinking",
5663            "/armin",
5664            "/earendil",
5665        ] {
5666            assert!(
5667                names.contains(&recognized.to_string()),
5668                "{recognized} missing from autocomplete list"
5669            );
5670        }
5671        // Hidden commands stay off the list.
5672        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
5673            assert!(
5674                !names.contains(&hidden.to_string()),
5675                "{hidden} should be hidden from autocomplete"
5676            );
5677        }
5678    }
5679
5680    #[test]
5681    fn test_agent_event_mapping_creates_assistant_and_tool() {
5682        // Synthetic AgentEvent sequence → UI mutations, exercised against the
5683        // real drain handler with a no-op TUI stand-in.
5684        use rpi_ai::types::{
5685            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
5686            ToolCall, ToolCallType, Usage,
5687        };
5688
5689        let state = Arc::new(TuiState {
5690            current_assistant: std::sync::Mutex::new(None),
5691            tool_components: std::sync::Mutex::new(HashMap::new()),
5692            bash_components: std::sync::Mutex::new(HashMap::new()),
5693            last_tool_comp: std::sync::Mutex::new(None),
5694            status: std::sync::Mutex::new(RunStatus::Idle),
5695            footer: Arc::new(FooterComponent::new()),
5696            status_container: Arc::new(Container::new()),
5697            chat_container: Arc::new(Container::new()),
5698            loader: Arc::new(Loader::new()),
5699            last_assistant_text: std::sync::Mutex::new(String::new()),
5700            active_selector: std::sync::Mutex::new(None),
5701            active_extension_editor: std::sync::Mutex::new(None),
5702            autocomplete: AutocompleteManager::new(),
5703            autocomplete_container: Arc::new(Container::new()),
5704            theme_manager: Arc::new(ThemeManager::new()),
5705            tui: None,
5706            current_model_id: std::sync::Mutex::new(String::new()),
5707            show_images: std::sync::Mutex::new(true),
5708            history: std::sync::Mutex::new(Vec::new()),
5709            history_index: std::sync::Mutex::new(-1),
5710            history_draft: std::sync::Mutex::new(None),
5711            last_input_tokens: std::sync::Mutex::new(0),
5712            scoped_edit: std::sync::Mutex::new(None),
5713            markdown_transformer: std::sync::Mutex::new(None),
5714            extension_session: Arc::new(std::sync::Mutex::new(
5715                rpi_extensions::ExtensionSession::none(),
5716            )),
5717        });
5718
5719        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
5720        // terminal; instead, exercise the *mutation* half directly against a
5721        // captured chat container via a synthetic message-start event's data.
5722        let assistant = AssistantMessage {
5723            role: rpi_ai::types::AssistantRole,
5724            content: vec![
5725                Content::Thinking(ThinkingContent {
5726                    kind: ThinkingContentType,
5727                    thinking: "Reasoning about the reply.".into(),
5728                    thinking_signature: None,
5729                    redacted: false,
5730                }),
5731                Content::Text(TextContent {
5732                    kind: TextContentType,
5733                    text: "Hello.".into(),
5734                    text_signature: None,
5735                }),
5736                Content::ToolCall(ToolCall {
5737                    kind: ToolCallType,
5738                    id: "tc1".into(),
5739                    name: "bash".into(),
5740                    arguments: serde_json::json!({"command": "echo hi"}),
5741                    thought_signature: None,
5742                    namespace: None,
5743                }),
5744            ],
5745            api: rpi_ai::Api::AnthropicMessages,
5746            provider: "anthropic".into(),
5747            model: "claude-sonnet-5".into(),
5748            response_model: None,
5749            response_id: None,
5750            usage: Usage::zero(),
5751            stop_reason: StopReason::Stop,
5752            deferred: None,
5753            error_message: None,
5754            raw_stop_reason: None,
5755            end_turn: None,
5756            timestamp: 0,
5757        };
5758
5759        // Manually apply the MessageStart assistant branch logic (mirrors the
5760        // drain handler, without needing a TuiAltScreen).
5761        let comp = Arc::new(AssistantMessageComponent::new(
5762            AssistantMessageOptions::default(),
5763        ));
5764        comp.set_streaming(true);
5765        comp.update_blocks(&assistant_blocks(&assistant));
5766        let chat = Arc::new(Container::new());
5767        chat.add_child(comp.clone());
5768        *state.current_assistant.lock().unwrap() = Some(comp);
5769
5770        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
5771        for c in &assistant.content {
5772            if let Content::ToolCall(tc) = c {
5773                let mut tools = state.tool_components.lock().unwrap();
5774                if !tools.contains_key(&tc.id) {
5775                    let tc_comp = Arc::new(ToolExecutionComponent::new(
5776                        &tc.name,
5777                        &tc.arguments.to_string(),
5778                    ));
5779                    tc_comp.set_running();
5780                    chat.add_child(tc_comp.clone());
5781                    tools.insert(tc.id.clone(), tc_comp);
5782                }
5783            }
5784        }
5785
5786        // Assert: the assistant component rendered the text + the thinking
5787        // block (the update_blocks path keeps thinking visible), and a tool
5788        // component was registered.
5789        let rendered = chat.render(80);
5790        let joined: String = rendered.join("\n");
5791        assert!(
5792            joined.contains("Hello."),
5793            "assistant text not rendered: {joined}"
5794        );
5795        assert!(
5796            joined.contains("Reasoning about the reply."),
5797            "thinking block not rendered: {joined}"
5798        );
5799        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
5800        assert!(state.current_assistant.lock().unwrap().is_some());
5801
5802        // Manually apply ToolExecutionEnd (mirrors drain).
5803        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
5804        ended.set_result("hi", false);
5805        assert!(state.tool_components.lock().unwrap().is_empty());
5806
5807        // A running bash panel owns the visible spinner. The global loader is
5808        // hidden until the last concurrent bash tool completes, then restored
5809        // while the agent remains in the Working state.
5810        assert!(state.try_start_working());
5811        assert!(
5812            !state.try_start_working(),
5813            "a second submit must be rejected"
5814        );
5815        state.set_status(RunStatus::Idle);
5816        state.set_status(RunStatus::Working);
5817        assert_eq!(state.status_container.child_count(), 1);
5818        {
5819            let mut bash = state.bash_components.lock().unwrap();
5820            bash.insert(
5821                "bash-1".into(),
5822                Arc::new(BashExecutionComponent::new("one")),
5823            );
5824            bash.insert(
5825                "bash-2".into(),
5826                Arc::new(BashExecutionComponent::new("two")),
5827            );
5828        }
5829        state.sync_working_loader_with_bash();
5830        assert_eq!(state.status_container.child_count(), 0);
5831        state.bash_components.lock().unwrap().remove("bash-1");
5832        state.sync_working_loader_with_bash();
5833        assert_eq!(state.status_container.child_count(), 0);
5834        state.bash_components.lock().unwrap().remove("bash-2");
5835        state.sync_working_loader_with_bash();
5836        assert_eq!(state.status_container.child_count(), 1);
5837
5838        state.set_status(RunStatus::Aborting);
5839        assert_eq!(state.status_container.child_count(), 0);
5840        assert!(!state.loader.is_running());
5841    }
5842
5843    #[test]
5844    fn fresh_launch_does_not_restore_old_history() {
5845        let fresh = Args::default();
5846        assert!(!launch_restores_history(&fresh));
5847
5848        let continued = Args {
5849            continue_session: true,
5850            ..Args::default()
5851        };
5852        assert!(launch_restores_history(&continued));
5853
5854        let selected = Args {
5855            session: Some("session-id".into()),
5856            ..Args::default()
5857        };
5858        assert!(launch_restores_history(&selected));
5859    }
5860
5861    #[test]
5862    fn test_short_model_name() {
5863        assert_eq!(
5864            short_model_name("anthropic:claude-sonnet-5"),
5865            "claude-sonnet-5"
5866        );
5867        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
5868    }
5869
5870    #[test]
5871    fn test_cycle_next_model_wraps_around() {
5872        use rpi_ai::{Api, Model};
5873        let mk = |id: &str| {
5874            Model::new(
5875                id,
5876                id,
5877                Api::AnthropicMessages,
5878                "anthropic",
5879                "https://api.anthropic.com",
5880            )
5881        };
5882        let catalog = [mk("a"), mk("b"), mk("c")];
5883        // Next after "a" is "b"; after "c" wraps to "a".
5884        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
5885        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
5886        // An unknown current id falls back to the first model.
5887        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
5888        // Empty catalog yields None.
5889        let empty: Vec<Model> = vec![];
5890        assert!(cycle_next_model(&empty, "a").is_none());
5891    }
5892
5893    #[test]
5894    fn test_autocomplete_slash_suggestions_render() {
5895        // The autocomplete container should render at least one suggestion
5896        // line when the editor holds a `/` prefix, and clear when it doesn't.
5897        let state = Arc::new(TuiState {
5898            current_assistant: std::sync::Mutex::new(None),
5899            tool_components: std::sync::Mutex::new(HashMap::new()),
5900            bash_components: std::sync::Mutex::new(HashMap::new()),
5901            last_tool_comp: std::sync::Mutex::new(None),
5902            status: std::sync::Mutex::new(RunStatus::Idle),
5903            footer: Arc::new(FooterComponent::new()),
5904            status_container: Arc::new(Container::new()),
5905            chat_container: Arc::new(Container::new()),
5906            loader: Arc::new(Loader::new()),
5907            last_assistant_text: std::sync::Mutex::new(String::new()),
5908            active_selector: std::sync::Mutex::new(None),
5909            active_extension_editor: std::sync::Mutex::new(None),
5910            autocomplete: AutocompleteManager::new(),
5911            autocomplete_container: Arc::new(Container::new()),
5912            theme_manager: Arc::new(ThemeManager::new()),
5913            tui: None,
5914            current_model_id: std::sync::Mutex::new(String::new()),
5915            show_images: std::sync::Mutex::new(true),
5916            history: std::sync::Mutex::new(Vec::new()),
5917            history_index: std::sync::Mutex::new(-1),
5918            history_draft: std::sync::Mutex::new(None),
5919            last_input_tokens: std::sync::Mutex::new(0),
5920            scoped_edit: std::sync::Mutex::new(None),
5921            markdown_transformer: std::sync::Mutex::new(None),
5922            extension_session: Arc::new(std::sync::Mutex::new(
5923                rpi_extensions::ExtensionSession::none(),
5924            )),
5925        });
5926        {
5927            let mut combined = CombinedAutocompleteProvider::new();
5928            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5929                build_builtin_registry().visible_entries(),
5930            )));
5931            state.autocomplete.set_provider(Arc::new(combined));
5932        }
5933
5934        let editor = Arc::new(Editor::simple());
5935        editor.set_text("/he");
5936        editor.set_cursor(0, 3);
5937        refresh_autocomplete(&state, &editor);
5938        let lines = state.autocomplete_container.render(80);
5939        let joined: String = lines.join("\n");
5940        assert!(
5941            joined.contains("/help"),
5942            "slash suggestions not rendered: {joined}"
5943        );
5944
5945        // Clear: no suggestions for plain text.
5946        editor.set_text("hello");
5947        editor.set_cursor(0, 5);
5948        refresh_autocomplete(&state, &editor);
5949        assert!(state.autocomplete_container.render(80).is_empty());
5950    }
5951
5952    #[test]
5953    fn test_select_list_swap_restores_editor() {
5954        // The editor-container swap: opening a selector replaces the editor
5955        // child; closing restores it. Verify the container child count + the
5956        // active_selector flag round-trip.
5957        let state = Arc::new(TuiState {
5958            current_assistant: std::sync::Mutex::new(None),
5959            tool_components: std::sync::Mutex::new(HashMap::new()),
5960            bash_components: std::sync::Mutex::new(HashMap::new()),
5961            last_tool_comp: std::sync::Mutex::new(None),
5962            status: std::sync::Mutex::new(RunStatus::Idle),
5963            footer: Arc::new(FooterComponent::new()),
5964            status_container: Arc::new(Container::new()),
5965            chat_container: Arc::new(Container::new()),
5966            loader: Arc::new(Loader::new()),
5967            last_assistant_text: std::sync::Mutex::new(String::new()),
5968            active_selector: std::sync::Mutex::new(None),
5969            active_extension_editor: std::sync::Mutex::new(None),
5970            autocomplete: AutocompleteManager::new(),
5971            autocomplete_container: Arc::new(Container::new()),
5972            theme_manager: Arc::new(ThemeManager::new()),
5973            tui: None,
5974            current_model_id: std::sync::Mutex::new(String::new()),
5975            show_images: std::sync::Mutex::new(true),
5976            history: std::sync::Mutex::new(Vec::new()),
5977            history_index: std::sync::Mutex::new(-1),
5978            history_draft: std::sync::Mutex::new(None),
5979            last_input_tokens: std::sync::Mutex::new(0),
5980            scoped_edit: std::sync::Mutex::new(None),
5981            markdown_transformer: std::sync::Mutex::new(None),
5982            extension_session: Arc::new(std::sync::Mutex::new(
5983                rpi_extensions::ExtensionSession::none(),
5984            )),
5985        });
5986        let editor_container = Arc::new(Container::new());
5987        let editor = Arc::new(Editor::simple());
5988        editor_container.add_child(editor.clone());
5989        assert!(!state.selector_open());
5990
5991        let tui_terminal = Box::new(ProcessTerminal::new());
5992        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
5993        let list = Arc::new(SelectList::new(
5994            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
5995            5,
5996        ));
5997        open_selector(
5998            &state,
5999            &editor_container,
6000            &editor,
6001            &tui,
6002            list,
6003            SelectorKind::Theme,
6004        );
6005        assert!(state.selector_open());
6006        // list only (editor swapped out).
6007        assert_eq!(editor_container.child_count(), 1);
6008
6009        close_selector(&state, &editor_container, &editor, &tui);
6010        assert!(!state.selector_open());
6011        // editor restored.
6012        assert_eq!(editor_container.child_count(), 1);
6013    }
6014
6015    #[test]
6016    fn test_message_history_browse_restores_draft() {
6017        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
6018        // messages, browse older → newer → back past the newest restores the
6019        // draft the user was typing.
6020        let state = Arc::new(TuiState {
6021            current_assistant: std::sync::Mutex::new(None),
6022            tool_components: std::sync::Mutex::new(HashMap::new()),
6023            bash_components: std::sync::Mutex::new(HashMap::new()),
6024            last_tool_comp: std::sync::Mutex::new(None),
6025            status: std::sync::Mutex::new(RunStatus::Idle),
6026            footer: Arc::new(FooterComponent::new()),
6027            status_container: Arc::new(Container::new()),
6028            chat_container: Arc::new(Container::new()),
6029            loader: Arc::new(Loader::new()),
6030            last_assistant_text: std::sync::Mutex::new(String::new()),
6031            active_selector: std::sync::Mutex::new(None),
6032            active_extension_editor: std::sync::Mutex::new(None),
6033            autocomplete: AutocompleteManager::new(),
6034            autocomplete_container: Arc::new(Container::new()),
6035            theme_manager: Arc::new(ThemeManager::new()),
6036            tui: None,
6037            current_model_id: std::sync::Mutex::new(String::new()),
6038            show_images: std::sync::Mutex::new(true),
6039            history: std::sync::Mutex::new(Vec::new()),
6040            history_index: std::sync::Mutex::new(-1),
6041            history_draft: std::sync::Mutex::new(None),
6042            last_input_tokens: std::sync::Mutex::new(0),
6043            scoped_edit: std::sync::Mutex::new(None),
6044            markdown_transformer: std::sync::Mutex::new(None),
6045            extension_session: Arc::new(std::sync::Mutex::new(
6046                rpi_extensions::ExtensionSession::none(),
6047            )),
6048        });
6049        let editor = Arc::new(Editor::simple());
6050
6051        push_history(&state, "first message");
6052        push_history(&state, "second message");
6053        // Consecutive duplicate is skipped.
6054        push_history(&state, "second message");
6055        push_history(&state, "   "); // empty → skipped
6056        assert_eq!(state.history.lock().unwrap().len(), 2);
6057        assert_eq!(state.history.lock().unwrap()[0], "second message");
6058
6059        // User starts typing a fresh prompt.
6060        editor.set_text("half-typed");
6061        editor.set_cursor(0, 11);
6062
6063        // ↑ → most recent.
6064        navigate_history(&state, &editor, -1);
6065        assert_eq!(editor.get_text(), "second message");
6066        assert_eq!(*state.history_index.lock().unwrap(), 0);
6067        // ↑ → older.
6068        navigate_history(&state, &editor, -1);
6069        assert_eq!(editor.get_text(), "first message");
6070        assert_eq!(*state.history_index.lock().unwrap(), 1);
6071        // ↑ past the oldest → stays (no wrap).
6072        navigate_history(&state, &editor, -1);
6073        assert_eq!(editor.get_text(), "first message");
6074        // ↓ → newer.
6075        navigate_history(&state, &editor, 1);
6076        assert_eq!(editor.get_text(), "second message");
6077        // ↓ past the newest → restores the draft.
6078        navigate_history(&state, &editor, 1);
6079        assert_eq!(editor.get_text(), "half-typed");
6080        assert_eq!(*state.history_index.lock().unwrap(), -1);
6081    }
6082
6083    #[test]
6084    fn test_accept_top_suggestion_replaces_prefix() {
6085        // `/he` + Tab → `/help ` (slash command provider inserts a space).
6086        let state = Arc::new(TuiState {
6087            current_assistant: std::sync::Mutex::new(None),
6088            tool_components: std::sync::Mutex::new(HashMap::new()),
6089            bash_components: std::sync::Mutex::new(HashMap::new()),
6090            last_tool_comp: std::sync::Mutex::new(None),
6091            status: std::sync::Mutex::new(RunStatus::Idle),
6092            footer: Arc::new(FooterComponent::new()),
6093            status_container: Arc::new(Container::new()),
6094            chat_container: Arc::new(Container::new()),
6095            loader: Arc::new(Loader::new()),
6096            last_assistant_text: std::sync::Mutex::new(String::new()),
6097            active_selector: std::sync::Mutex::new(None),
6098            active_extension_editor: std::sync::Mutex::new(None),
6099            autocomplete: AutocompleteManager::new(),
6100            autocomplete_container: Arc::new(Container::new()),
6101            theme_manager: Arc::new(ThemeManager::new()),
6102            tui: None,
6103            current_model_id: std::sync::Mutex::new(String::new()),
6104            show_images: std::sync::Mutex::new(true),
6105            history: std::sync::Mutex::new(Vec::new()),
6106            history_index: std::sync::Mutex::new(-1),
6107            history_draft: std::sync::Mutex::new(None),
6108            last_input_tokens: std::sync::Mutex::new(0),
6109            scoped_edit: std::sync::Mutex::new(None),
6110            markdown_transformer: std::sync::Mutex::new(None),
6111            extension_session: Arc::new(std::sync::Mutex::new(
6112                rpi_extensions::ExtensionSession::none(),
6113            )),
6114        });
6115        {
6116            let mut combined = CombinedAutocompleteProvider::new();
6117            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
6118                build_builtin_registry().visible_entries(),
6119            )));
6120            state.autocomplete.set_provider(Arc::new(combined));
6121        }
6122        let editor = Arc::new(Editor::simple());
6123        editor.set_text("/he");
6124        editor.set_cursor(0, 3);
6125        refresh_autocomplete(&state, &editor);
6126        let accepted = accept_top_suggestion(&state, &editor);
6127        assert!(accepted, "should accept the top suggestion");
6128        let text = editor.get_text();
6129        assert!(
6130            text.starts_with("/help"),
6131            "editor text should start with /help, got {text}"
6132        );
6133    }
6134}