Skip to main content

rpi_cli/
interactive_tui.rs

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