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