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, HashSet, VecDeque};
27use std::io::IsTerminal;
28use std::sync::{mpsc as std_mpsc, Arc, Mutex};
29
30use base64::Engine;
31use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
32use tokio::sync::{broadcast, mpsc};
33use tokio_util::sync::CancellationToken;
34
35use rpi_agent::{AgentEvent, AgentMessage};
36use rpi_ai::types::{AssistantMessage, Content, UserMessage};
37use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
38use rpi_harness::session::types::{Entry, EntryOrder, EntryQuery};
39use rpi_tui::scroll_view::{OverscrollMode, ScrollbarMode};
40#[cfg(test)]
41use rpi_tui::strip_ansi;
42use rpi_tui::{
43    apply_theme_preset, render_diff, AssistantBlock, AssistantMessageComponent,
44    AssistantMessageOptions, AutocompleteManager, AutocompleteSuggestions, BashExecutionComponent,
45    BashTruncation, CombinedAutocompleteProvider, Component, Container, DynamicBorder, Editor,
46    EditorOptions, EditorStyle, FilePathAutocompleteProvider, Focusable, FollowMode,
47    FooterComponent, Image, ImageOptions, Input, Loader, ProcessTerminal, ScrollView,
48    ScrollViewOptions, SelectItem, SelectList, SlashCommand as SlashCommandEntry,
49    SlashCommandAutocompleteProvider, Spacer, StackChild, StackEntry, StatusIndicator, Text,
50    ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack,
51    TUI,
52};
53use rpi_tui::{bold as tui_bold, theme as current_theme};
54
55#[allow(unused_imports)]
56use rpi_tui::BashStatus;
57
58use crate::args::Args;
59
60/// B5e: the markdown-transformer trait object the assistant-message render path
61/// applies to raw text BEFORE the [`Markdown`] renderer styles it. A plain
62/// `Fn(&str) -> String` (NO `rpi-extensions` types) so `rpi-tui` stays free of
63/// an `rpi-extensions` dep — `rpi-cli` (which already depends on
64/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot` and
65/// hands the trait object to `AssistantMessageComponent::set_markdown_transformer`.
66type MarkdownTransformer = Arc<dyn Fn(&str) -> String + Send + Sync>;
67
68/// A synchronous rendezvous between the Node runtime-request thread and the
69/// blocking TUI key loop. Node's `ctx.ui.*` methods are promises, so the host
70/// request must remain pending while the user interacts with the native
71/// component. The key loop owns opening/closing components; this bridge only
72/// carries JSON results and cancellation state across the threads.
73#[derive(Clone, Default)]
74struct JsDialogBridge {
75    pending: Arc<Mutex<VecDeque<JsDialogPending>>>,
76    active: Arc<Mutex<HashMap<String, JsDialogActive>>>,
77    /// The one dialog currently installed in the TUI input slot. Other
78    /// requests may remain active while a command is waiting, but a cancel
79    /// notification must never close whichever dialog happens to be visible.
80    visible: Arc<Mutex<Option<String>>>,
81    cancelled_before_open: Arc<Mutex<HashSet<String>>>,
82    closed: Arc<Mutex<bool>>,
83}
84
85struct JsDialogPending {
86    request: JsDialogRequest,
87    result: std_mpsc::Sender<serde_json::Value>,
88}
89
90struct JsDialogActive {
91    result: std_mpsc::Sender<serde_json::Value>,
92    cancel_requested: bool,
93}
94
95#[derive(Clone, Debug)]
96struct JsDialogRequest {
97    id: String,
98    method: String,
99    title: String,
100    message: String,
101    options: Vec<String>,
102    placeholder: Option<String>,
103    prefill: Option<String>,
104}
105
106impl JsDialogRequest {
107    fn parse(args: &serde_json::Value) -> Result<Self, String> {
108        let id = args
109            .get("dialogId")
110            .and_then(serde_json::Value::as_str)
111            .filter(|value| !value.is_empty())
112            .ok_or("ui.dialog missing dialogId")?
113            .to_string();
114        let method = args
115            .get("method")
116            .and_then(serde_json::Value::as_str)
117            .ok_or("ui.dialog missing method")?
118            .to_string();
119        if !matches!(method.as_str(), "select" | "confirm" | "input" | "editor") {
120            return Err(format!("unsupported UI dialog method: {method}"));
121        }
122        let options = args
123            .get("options")
124            .and_then(serde_json::Value::as_array)
125            .map(|values| {
126                values
127                    .iter()
128                    .filter_map(serde_json::Value::as_str)
129                    .map(ToOwned::to_owned)
130                    .collect()
131            })
132            .unwrap_or_default();
133        Ok(Self {
134            id,
135            method,
136            title: args
137                .get("title")
138                .and_then(serde_json::Value::as_str)
139                .unwrap_or_default()
140                .to_string(),
141            message: args
142                .get("message")
143                .and_then(serde_json::Value::as_str)
144                .unwrap_or_default()
145                .to_string(),
146            options,
147            placeholder: args
148                .get("placeholder")
149                .and_then(serde_json::Value::as_str)
150                .map(ToOwned::to_owned),
151            prefill: args
152                .get("prefill")
153                .and_then(serde_json::Value::as_str)
154                .map(ToOwned::to_owned),
155        })
156    }
157}
158
159impl JsDialogBridge {
160    fn handle_runtime_request(
161        &self,
162        action: &str,
163        args: serde_json::Value,
164    ) -> Result<serde_json::Value, String> {
165        match action {
166            "ui.dialog" => self.wait_for_dialog(args),
167            "ui.dialog.cancel" => {
168                let id = args
169                    .get("dialogId")
170                    .and_then(serde_json::Value::as_str)
171                    .ok_or("ui.dialog.cancel missing dialogId")?;
172                self.cancel(id);
173                Ok(serde_json::json!(true))
174            }
175            _ => Err(format!("unsupported capability: {action}")),
176        }
177    }
178
179    fn wait_for_dialog(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
180        let request = JsDialogRequest::parse(&args)?;
181        let (sender, receiver) = std_mpsc::channel();
182        // Hold the closed flag while enqueueing. `cancel_all` takes this same
183        // lock before draining pending requests, so shutdown cannot observe
184        // an empty queue and then have this request arrive behind the drain.
185        let _closed = self
186            .closed
187            .lock()
188            .map_err(|_| "JS dialog bridge poisoned")?;
189        if *_closed {
190            return Ok(serde_json::json!({ "cancelled": true }));
191        }
192        let cancelled_before_open = self
193            .cancelled_before_open
194            .lock()
195            .map_err(|_| "JS dialog cancellation state poisoned")?
196            .remove(&request.id);
197        if cancelled_before_open {
198            return Ok(serde_json::json!({ "cancelled": true }));
199        }
200        // Do not hold the cancellation-state lock while taking `pending`:
201        // `take_pending` takes those locks in the opposite order.
202        self.pending
203            .lock()
204            .map_err(|_| "JS dialog pending state poisoned")?
205            .push_back(JsDialogPending {
206                request,
207                result: sender,
208            });
209        drop(_closed);
210        receiver
211            .recv()
212            .map_err(|_| "JS dialog closed before it received an answer".to_string())
213    }
214
215    /// Move one request to the active set. The caller invokes this only when
216    /// the TUI has no other modal occupying the editor slot.
217    fn take_pending(&self) -> Option<JsDialogRequest> {
218        loop {
219            let pending = self.pending.lock().ok()?.pop_front()?;
220            let mut active = self.active.lock().ok()?;
221            // Check cancellation while holding the active lock and insert the
222            // entry in the same critical section. `cancel()` checks `active`
223            // before recording a pre-open cancellation, so it will either see
224            // this entry or leave a marker that we consume here. Checking the
225            // marker before acquiring `active` had a small race where a cancel
226            // could land between the check and insertion and strand the dialog.
227            if self
228                .cancelled_before_open
229                .lock()
230                .ok()?
231                .remove(&pending.request.id)
232            {
233                drop(active);
234                let _ = pending
235                    .result
236                    .send(serde_json::json!({ "cancelled": true }));
237                continue;
238            }
239            active.insert(
240                pending.request.id.clone(),
241                JsDialogActive {
242                    result: pending.result,
243                    cancel_requested: false,
244                },
245            );
246            if let Ok(mut visible) = self.visible.lock() {
247                *visible = Some(pending.request.id.clone());
248            }
249            return Some(pending.request);
250        }
251    }
252
253    fn respond(&self, id: &str, result: serde_json::Value) {
254        if let Ok(mut active) = self.active.lock() {
255            if let Some(entry) = active.remove(id) {
256                let _ = entry.result.send(result);
257            }
258        }
259        if let Ok(mut visible) = self.visible.lock() {
260            if visible.as_deref() == Some(id) {
261                *visible = None;
262            }
263        }
264    }
265
266    fn cancel(&self, id: &str) {
267        if let Ok(mut pending) = self.pending.lock() {
268            if let Some(index) = pending.iter().position(|item| item.request.id == id) {
269                if let Some(item) = pending.remove(index) {
270                    let _ = item.result.send(serde_json::json!({ "cancelled": true }));
271                    return;
272                }
273            }
274        }
275        if let Ok(mut active) = self.active.lock() {
276            if let Some(entry) = active.get_mut(id) {
277                if !entry.cancel_requested {
278                    entry.cancel_requested = true;
279                    let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
280                }
281                return;
282            }
283        }
284        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
285            cancelled.insert(id.to_string());
286        }
287    }
288
289    fn cancelled_active_ids(&self) -> Vec<String> {
290        self.active
291            .lock()
292            .map(|active| {
293                active
294                    .iter()
295                    .filter_map(|(id, entry)| entry.cancel_requested.then_some(id.clone()))
296                    .collect()
297            })
298            .unwrap_or_default()
299    }
300
301    fn is_visible(&self, id: &str) -> bool {
302        self.visible
303            .lock()
304            .map(|visible| visible.as_deref() == Some(id))
305            .unwrap_or(false)
306    }
307
308    fn finish(&self, id: &str) {
309        if let Ok(mut active) = self.active.lock() {
310            active.remove(id);
311        }
312        if let Ok(mut visible) = self.visible.lock() {
313            if visible.as_deref() == Some(id) {
314                *visible = None;
315            }
316        }
317    }
318
319    fn cancel_all(&self) {
320        // Keep the closed lock through the queue drains. `wait_for_dialog`
321        // holds it while enqueueing, making the shutdown check + enqueue an
322        // atomic operation with respect to this drain.
323        let Ok(mut closed) = self.closed.lock() else {
324            return;
325        };
326        *closed = true;
327        if let Ok(mut pending) = self.pending.lock() {
328            for item in pending.drain(..) {
329                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
330            }
331        }
332        if let Ok(mut active) = self.active.lock() {
333            for (_, entry) in active.drain() {
334                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
335            }
336        }
337        if let Ok(mut visible) = self.visible.lock() {
338            *visible = None;
339        }
340        drop(closed);
341    }
342
343    /// Cancel requests owned by one interrupted prompt preparation while
344    /// keeping the bridge available to a replacement Node host.
345    fn cancel_open_requests(&self) {
346        // Keep enqueueing closed until the old host and its preparation
347        // worker have stopped. Otherwise a late runtime request can land just
348        // after the drain and strand its handler thread.
349        let Ok(mut closed) = self.closed.lock() else {
350            return;
351        };
352        *closed = true;
353        if let Ok(mut pending) = self.pending.lock() {
354            for item in pending.drain(..) {
355                let _ = item.result.send(serde_json::json!({ "cancelled": true }));
356            }
357        }
358        if let Ok(mut active) = self.active.lock() {
359            for (_, entry) in active.drain() {
360                let _ = entry.result.send(serde_json::json!({ "cancelled": true }));
361            }
362        }
363        if let Ok(mut visible) = self.visible.lock() {
364            *visible = None;
365        }
366        if let Ok(mut cancelled) = self.cancelled_before_open.lock() {
367            cancelled.clear();
368        }
369        drop(closed);
370    }
371
372    fn reopen(&self) {
373        if let Ok(mut closed) = self.closed.lock() {
374            *closed = false;
375        }
376    }
377}
378
379/// B5e: build the `AssistantMessageComponent` markdown-transformer closure the
380/// render path applies to raw assistant text before styling. Wraps any plugin
381/// `register_markdown_transformer` handlers registered in `snapshot` (chained
382/// in registration order: each handler's output feeds the next). `None` when
383/// no markdown transformers are registered (the component defaults to the
384/// identity transform + this avoids a closure allocation on the hot render
385/// path).
386///
387/// The closure captures an `Arc<RegistrySnapshot>` clone so it outlives the
388/// borrow that built it (the snapshot's `active` flag guards dispatch in
389/// `emit_resources_discover`/event translation; a reloaded session's old
390/// snapshot flips false, so a stale closure no-ops rather than driving a
391/// half-swapped registry — the transformer falls back to the input unchanged
392/// on an inactive snapshot, matching the plugin's per-handler skip-on-error).
393///
394/// This is the cycle-free seam: `rpi-tui` takes a `Fn(&str) -> String` trait
395/// object (no `rpi-extensions` dep); `rpi-cli` (which already depends on
396/// `rpi-extensions`) builds the closure from the live `RegistrySnapshot`. The
397/// calling pattern mirrors `plugin_stub_smoke.rs`'s direct `RenderFn` round-
398/// trip (input `{"markdown":…}` → `render_fn` → reclaim `out` via the plugin's
399/// `free_string` → parse `{"markdown":…}`).
400fn build_markdown_transformer(
401    snapshot: Option<std::sync::Arc<rpi_extensions::RegistrySnapshot>>,
402) -> Option<MarkdownTransformer> {
403    let snapshot = snapshot?;
404    // Pre-check: if no markdown renderers are registered, return None so the
405    // component uses the identity path (no per-delta closure call). The
406    // renderers list is a per-call `renderers_of` clone; snapshotting it once
407    // here keeps the closure cheap on the hot path.
408    let renderers = snapshot.renderers_of(rpi_extensions::RegisteredRendererKind::Markdown);
409    if renderers.is_empty() {
410        return None;
411    }
412    Some(Arc::new(move |raw: &str| -> String {
413        transform_markdown_chain(&snapshot, &renderers, raw)
414    }))
415}
416
417/// Drive the markdown-transformer chain for one input string. Each registered
418/// handler receives the previous handler's output (or the raw input for the
419/// first), as a `{"markdown": <text>}` JSON envelope; its `RenderFn` returns
420/// `{"markdown": <transformed>}` (rc=0) or an error (rc!=0). On any failure —
421/// nonzero rc, a panic across the FFI (caught), a missing `markdown` field, or
422/// an inactive snapshot — the chain short-circuits to the current text
423/// unchanged (per-handler skip-on-error, mirroring pi's `runner.ts` fan-out).
424fn transform_markdown_chain(
425    snapshot: &rpi_extensions::RegistrySnapshot,
426    renderers: &[rpi_extensions::RegisteredRenderer],
427    raw: &str,
428) -> String {
429    // A stale snapshot (post-/reload) must not drive a swapped-out registry.
430    // The renderers were captured from this snapshot; if it has gone inactive,
431    // fall back to the raw input so the UI never renders stale-transformed text
432    // from a dead plugin.
433    if !snapshot.is_active() {
434        return raw.to_string();
435    }
436
437    let mut current = raw.to_string();
438    for renderer in renderers {
439        let input = match serde_json::to_string(&serde_json::json!({ "markdown": current })) {
440            Ok(s) => s,
441            Err(_) => return current, // serialize failure — keep current, stop chain
442        };
443        // SAFETY: `render_fn` is a plugin-provided `extern "C" fn` over a
444        // borrowed `StbStringRef` + an out-param. The plugin warrants
445        // `poll`/`render` are non-blocking + thread-safe (the same contract
446        // the tool adapter relies on). `user_data` is the plugin's opaque
447        // pointer, stable for the registry lifetime (the keepalive keeps the
448        // cdylib mapped). We reclaim `out` via the plugin's `free_string`
449        // exactly once. The whole call is `catch_unwind`-wrapped — a plugin
450        // panic must not unwind across the FFI boundary (same policy as the
451        // tool partial cb + the runtime_action trampoline).
452        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
453            let mut out = rpi_plugin_sdk::StbString::empty();
454            let rc = (renderer.render_fn)(
455                rpi_plugin_sdk::StbStringRef::from_str(&input),
456                &mut out as *mut rpi_plugin_sdk::StbString,
457                renderer.user_data,
458            );
459            let text = if rc == 0 {
460                let s = out.to_string_lossy();
461                Some(s)
462            } else {
463                None
464            };
465            // Reclaim the plugin-owned `out` regardless of rc (rc!=0 may still
466            // have written an error JSON the plugin allocated). `free_with` is
467            // idempotent on an empty `StbString`.
468            out.free_with(Some(renderer.plugin_free_string));
469            text
470        }));
471        let out_text = match outcome {
472            Ok(Some(s)) => s,
473            Ok(None) => return current, // rc != 0 — skip this handler, keep current
474            Err(_) => return current,   // panic — skip, keep current (do not abort: the
475                                         // render path is not the action trampoline; a panicking transformer
476                                         // degrades to identity rather than killing the process. Logged via
477                                         // the `tracing` crate's panic hook.)
478        };
479        // Parse `{"markdown": <text>}`; lenient — a missing/non-string field
480        // keeps the current text (skip this handler).
481        let next = serde_json::from_str::<serde_json::Value>(&out_text)
482            .ok()
483            .and_then(|v| {
484                v.get("markdown")
485                    .and_then(|m| m.as_str())
486                    .map(|s| s.to_string())
487            })
488            .unwrap_or(current);
489        current = next;
490    }
491    current
492}
493
494// ===========================================================================
495// Slash commands — trait + registry
496// ===========================================================================
497//
498// Each built-in slash command is one `impl SlashCommand`. The commands are
499// registered at startup into a [`CommandRegistry`] (one source of truth) that
500// serves both dispatch ("given this token, run the command") and autocomplete
501// ("list the visible commands"). This replaces the old two-list + sync-test
502// arrangement, where `handle_slash_command` and `v1_slash_commands()` had to be
503// kept in lock-step by hand.
504//
505// `execute` runs on the blocking key/compose thread (the editor `on_submit`
506// callback and the Ctrl+L hotkey both land there), so it MUST stay synchronous:
507//   - commands needing async (`set_model`/`set_thinking_level`/`set_active_tools`)
508//     `tokio::spawn` the work and return immediately;
509//   - commands needing the main async loop (`compact`/`copy`/`exit`/`clear`/
510//     `user-input`) signal it via `ctx.tx.send(TuiMessage::…)`;
511//   - everything else mutates the chat container + requests a render directly.
512
513/// The borrowed world a slash command runs against. All fields are `Arc` (or a
514/// cheap `String` snapshot), so one `CommandContext` clones freely into each
515/// command without per-capture ceremony — this struct is exactly the set of
516/// `*_for_cb` clones the old submit closure used to make individually.
517#[derive(Clone)]
518struct CommandContext {
519    chat: Arc<Container>,
520    tui: Arc<TuiAltScreen>,
521    tx: mpsc::UnboundedSender<TuiMessage>,
522    state: Arc<TuiState>,
523    editor: Arc<Editor>,
524    editor_container: Arc<Container>,
525    lane: Arc<dyn AgentLane>,
526    model_catalog: Arc<Vec<rpi_ai::Model>>,
527    /// Lane model id snapshot, read once via `lane.get_model().await` BEFORE the
528    /// blocking key loop starts. Selectors/key loop can't await, so they read
529    /// this owned string instead. Semantically unchanged from pre-refactor.
530    lane_model_id: String,
531    cwd: std::path::PathBuf,
532    /// Package resources resolved at startup. An empty set means Pi package
533    /// loading was not explicitly enabled and must remain disabled for all
534    /// interactive theme selectors.
535    package_resources: Arc<crate::packages::PackageResources>,
536    /// Harness resources snapshot (skills + prompt templates) for `/context`.
537    /// Captured once at TUI startup because the blocking submit thread can't
538    /// `.await get_resources()`.
539    resources: Arc<rpi_harness::types::AgentHarnessResources>,
540    /// B5d: the reload context `/reload` drives. `Arc<ReloadContext>` so the
541    /// blocking submit thread can cheaply clone it into the `ReloadCommand`
542    /// without an `.await` (the command can't drive reload directly — it signals
543    /// the main loop via `TuiMessage::ReloadExtensions`, which awaits the shared
544    /// `reload_extension_resources` routine on the async runtime).
545    reload_context: Arc<crate::session::ReloadContext>,
546}
547
548/// One slash command.
549trait SlashCommand: Send + Sync {
550    /// Canonical name, with the leading `/` (e.g. "/model").
551    fn name(&self) -> &str;
552    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
553    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
554    /// `/`-autocomplete list (most aliases stay hidden).
555    fn aliases(&self) -> &'static [&'static str] {
556        &[]
557    }
558    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
559    /// commands (`/context`, `/name`, …) return `false`.
560    fn visible(&self) -> bool {
561        true
562    }
563    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
564    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
565    /// the list to keep it short. `/new` and `/quit` override this to surface.
566    fn alias_visible(&self) -> &'static [&'static str] {
567        &[]
568    }
569    /// Description shown in autocomplete and `/help`. A non-empty description is
570    /// required to surface in autocomplete even when `visible()` is true.
571    fn description(&self) -> &'static str {
572        ""
573    }
574    fn description_owned(&self) -> String {
575        self.description().to_string()
576    }
577    /// Execute the command. Only invoked for inputs starting with `/` whose
578    /// first token matches `name()` or an alias. `args` is the whitespace-
579    /// trimmed remainder after the command token ("" when none). Must stay
580    /// synchronous (see the module-level note) — async work goes through
581    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
582    fn execute(&self, ctx: &CommandContext, args: &str);
583}
584
585/// Holds all registered slash commands; the single source of truth for both
586/// dispatch and the autocomplete list.
587struct CommandRegistry {
588    commands: Vec<Arc<dyn SlashCommand>>,
589}
590
591impl CommandRegistry {
592    fn new() -> Self {
593        Self {
594            commands: Vec::new(),
595        }
596    }
597
598    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
599        self.commands.push(cmd);
600    }
601
602    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
603    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
604    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
605        self.commands
606            .iter()
607            .find(|c| c.name() == token || c.aliases().contains(&token))
608    }
609
610    /// The autocomplete entries, derived from the registry so it can never drift
611    /// from what dispatch recognizes. Surfaces the canonical name when
612    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
613    /// Order = registration order; built-ins are registered before templates,
614    /// so they win on a fuzzy tie (unchanged).
615    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
616        let mut out: Vec<SlashCommandEntry> = Vec::new();
617        for c in &self.commands {
618            let description = c.description_owned();
619            if c.visible() && !description.is_empty() {
620                out.push(SlashCommandEntry {
621                    name: c.name().into(),
622                    description: description.clone(),
623                });
624            }
625            // Surfaced aliases share the command's description.
626            for alias in c.alias_visible() {
627                out.push(SlashCommandEntry {
628                    name: (*alias).into(),
629                    description: description.clone(),
630                });
631            }
632        }
633        out
634    }
635}
636
637/// Resolve the command for a `/`-prefixed input and run it, or emit the
638/// unknown-command error if nothing matches. Non-slash text never reaches here
639/// — callers route only `/`-prefixed inputs and send plain text directly.
640fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
641    let mut parts = text.split_whitespace();
642    let token = parts.next().unwrap_or("");
643    let args = parts.collect::<Vec<_>>().join(" ");
644    match registry.find(token) {
645        Some(cmd) => cmd.execute(ctx, &args),
646        None => {
647            add_error_message(
648                &ctx.chat,
649                &format!("Unknown command: {text}. Type /help for available commands."),
650            );
651            ctx.tui.request_render(false);
652        }
653    }
654}
655
656/// Encode a crossterm key into the raw key data consumed by the Node TUI
657/// compatibility layer. Plain keys retain the usual terminal sequences;
658/// modified functional keys use Kitty CSI-u so Shift/Alt/Ctrl combinations are
659/// not collapsed into their unmodified equivalent (notably Shift+Enter).
660fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
661    use crossterm::event::{KeyCode, KeyModifiers};
662
663    let modifiers = key.modifiers;
664    let ctrl = modifiers.contains(KeyModifiers::CONTROL);
665    let shift = modifiers.contains(KeyModifiers::SHIFT);
666    let alt = modifiers.contains(KeyModifiers::ALT);
667    let super_key = modifiers.contains(KeyModifiers::SUPER);
668
669    if modifiers == KeyModifiers::NONE {
670        return match key.code {
671            KeyCode::Char(ch) => ch.to_string(),
672            KeyCode::Enter => "\r".into(),
673            KeyCode::Esc => "\x1b".into(),
674            KeyCode::Backspace => "\x7f".into(),
675            KeyCode::Tab => "\t".into(),
676            // Crossterm represents Shift+Tab as `BackTab` on both Unix
677            // (`ESC[Z`) and Windows. Preserve the canonical terminal form
678            // so the Node keybinding matcher sees `shift+tab`.
679            KeyCode::BackTab => "\x1b[Z".into(),
680            KeyCode::Up => "\x1b[A".into(),
681            KeyCode::Down => "\x1b[B".into(),
682            KeyCode::Right => "\x1b[C".into(),
683            KeyCode::Left => "\x1b[D".into(),
684            KeyCode::Home => "\x1b[H".into(),
685            KeyCode::End => "\x1b[F".into(),
686            KeyCode::PageUp => "\x1b[5~".into(),
687            KeyCode::PageDown => "\x1b[6~".into(),
688            KeyCode::Delete => "\x1b[3~".into(),
689            KeyCode::Insert => "\x1b[2~".into(),
690            KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
691            _ => String::new(),
692        };
693    }
694
695    // Legacy control bytes are what the native `matchesKey` implementation
696    // expects for the common Ctrl+letter actions (Ctrl+C, Ctrl+O, Ctrl+J...).
697    if ctrl && !shift && !alt && !super_key {
698        if let KeyCode::Char(ch) = key.code {
699            if let Some(code) = control_code(ch) {
700                return char::from(code).to_string();
701            }
702        }
703    }
704
705    // Legacy Alt+character input is unambiguous when no other modifier is
706    // present and is accepted by pi's `matchesKey` fallback parser.
707    if alt && !ctrl && !shift && !super_key {
708        if let KeyCode::Char(ch) = key.code {
709            return format!("\x1b{ch}");
710        }
711    }
712
713    // Crossterm has already resolved the keyboard layout for character events
714    // (for example, Windows reports Shift+1 as `Char('!')`). Pass that actual
715    // character through unchanged so custom components receive text instead
716    // of a CSI-u escape sequence. Functional keys and combined modifiers use
717    // CSI-u below so their modifier identity remains available to keybindings.
718    if shift && !ctrl && !alt && !super_key {
719        if let KeyCode::Char(ch) = key.code {
720            return ch.to_string();
721        }
722    }
723
724    if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
725        return sequence;
726    }
727    let Some(codepoint) = key_codepoint(key.code, ctrl) else {
728        return String::new();
729    };
730    kitty_key_sequence(codepoint, modifiers)
731}
732
733fn control_code(ch: char) -> Option<u8> {
734    let ch = ch.to_ascii_lowercase();
735    Some(match ch {
736        '@' | ' ' => 0,
737        'a'..='z' => (ch as u8) & 0x1f,
738        '[' => 0x1b,
739        '\\' => 0x1c,
740        ']' => 0x1d,
741        '^' => 0x1e,
742        '_' | '-' => 0x1f,
743        _ => return None,
744    })
745}
746
747fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
748    use crossterm::event::KeyCode;
749    Some(match code {
750        KeyCode::Char(ch) => {
751            if ctrl {
752                ch.to_ascii_lowercase() as u32
753            } else {
754                ch as u32
755            }
756        }
757        KeyCode::Enter => 13,
758        KeyCode::Esc => 27,
759        KeyCode::Backspace => 127,
760        KeyCode::Tab => 9,
761        // Keep modified BackTab combinations representable through CSI-u;
762        // the unmodified/SHIFT form is handled as the legacy `ESC[Z` above.
763        KeyCode::BackTab => 9,
764        _ => return None,
765    })
766}
767
768fn modified_functional_sequence(
769    code: crossterm::event::KeyCode,
770    modifiers: crossterm::event::KeyModifiers,
771) -> Option<String> {
772    use crossterm::event::{KeyCode, KeyModifiers};
773    // `BackTab` is already a semantic Shift+Tab event. Crossterm normally
774    // includes SHIFT in its modifier bits, but preserving the legacy sequence
775    // for a synthetic event without that bit keeps the adapter portable.
776    if code == KeyCode::BackTab
777        && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
778    {
779        return Some("\x1b[Z".into());
780    }
781    let modifier = kitty_modifier(modifiers);
782    let sequence = match code {
783        KeyCode::Up => format!("\x1b[1;{modifier}A"),
784        KeyCode::Down => format!("\x1b[1;{modifier}B"),
785        KeyCode::Right => format!("\x1b[1;{modifier}C"),
786        KeyCode::Left => format!("\x1b[1;{modifier}D"),
787        KeyCode::Home => format!("\x1b[1;{modifier}H"),
788        KeyCode::End => format!("\x1b[1;{modifier}F"),
789        KeyCode::Insert => format!("\x1b[2;{modifier}~"),
790        KeyCode::Delete => format!("\x1b[3;{modifier}~"),
791        KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
792        KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
793        _ => return None,
794    };
795    Some(sequence)
796}
797
798fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
799    use crossterm::event::KeyModifiers;
800    let mut modifier = 1u8;
801    if modifiers.contains(KeyModifiers::SHIFT) {
802        modifier += 1;
803    }
804    if modifiers.contains(KeyModifiers::ALT) {
805        modifier += 2;
806    }
807    if modifiers.contains(KeyModifiers::CONTROL) {
808        modifier += 4;
809    }
810    if modifiers.contains(KeyModifiers::SUPER) {
811        modifier += 8;
812    }
813    modifier
814}
815
816fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
817    let modifier = kitty_modifier(modifiers);
818    format!("\x1b[{codepoint};{modifier}u")
819}
820
821/// A slash command registered by a native extension. The command metadata is
822/// captured for autocomplete, while the handler is looked up from the live
823/// session on every invocation so `/reload` takes effect without rebuilding
824/// the editor callback.
825struct ExtensionCommand {
826    name: String,
827    description: String,
828    session: crate::session::ExtensionSessionCell,
829}
830
831struct JsExtensionCommand {
832    name: String,
833    session: crate::js_extensions::JsExtensionSession,
834}
835
836impl SlashCommand for JsExtensionCommand {
837    fn name(&self) -> &str {
838        &self.name
839    }
840    fn description(&self) -> &'static str {
841        "JS extension command"
842    }
843    fn description_owned(&self) -> String {
844        "JS extension command".to_string()
845    }
846    fn execute(&self, ctx: &CommandContext, args: &str) {
847        // JS commands may own the terminal for their entire lifetime (for
848        // example pi-btw's fullscreen side thread). Running them inline here
849        // would block the crossterm key thread, so no input could reach the
850        // extension while it is waiting for `ui.custom()` to complete.
851        let session = self.session.clone();
852        let command = self.name.trim_start_matches('/').to_string();
853        let args = args.to_string();
854        let ctx = ctx.clone();
855        // The command runs off the key thread. Keep the startup snapshot so a
856        // late editorText result cannot overwrite text typed while the command
857        // was in flight.
858        let initial_editor_text = ctx.editor.get_text();
859        tokio::task::spawn_blocking(move || {
860            match session.invoke_command_with_context(
861                &command,
862                &args,
863                serde_json::json!({"editorText": initial_editor_text}),
864            ) {
865                Ok(value) => {
866                    if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
867                        if ctx.editor.get_text() == initial_editor_text
868                            && editor_text != initial_editor_text
869                        {
870                            let cursor = editor_text.chars().count();
871                            ctx.editor.set_text(editor_text);
872                            ctx.editor.set_cursor(0, cursor);
873                        }
874                    }
875                    if let Some(notifications) =
876                        value.get("notifications").and_then(|v| v.as_array())
877                    {
878                        for notification in notifications {
879                            let message = notification
880                                .get("message")
881                                .and_then(|v| v.as_str())
882                                .unwrap_or_default();
883                            if message.is_empty() {
884                                continue;
885                            }
886                            match notification.get("level").and_then(|v| v.as_str()) {
887                                Some("error") => add_error_message(&ctx.chat, message),
888                                _ => add_note_message(&ctx.chat, message),
889                            }
890                        }
891                    }
892                    let result = value.get("result").unwrap_or(&value);
893                    let text = result
894                        .get("text")
895                        .and_then(|item| item.as_str())
896                        .map(str::to_string)
897                        .or_else(|| result.as_str().map(str::to_string))
898                        .filter(|text| !text.is_empty() && text != "null");
899                    if let Some(text) = text {
900                        add_note_message(&ctx.chat, &text);
901                    }
902                }
903                Err(error) => {
904                    add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
905                }
906            }
907            ctx.tui.request_render(false);
908        });
909    }
910}
911
912impl SlashCommand for ExtensionCommand {
913    fn name(&self) -> &str {
914        &self.name
915    }
916
917    fn description(&self) -> &'static str {
918        "extension command"
919    }
920
921    fn description_owned(&self) -> String {
922        self.description.clone()
923    }
924
925    fn execute(&self, ctx: &CommandContext, args: &str) {
926        let result = invoke_extension_command(&self.session, &self.name, args);
927        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
928    }
929}
930
931fn invoke_extension_command(
932    session: &crate::session::ExtensionSessionCell,
933    name: &str,
934    args: &str,
935) -> Option<serde_json::Value> {
936    let command = session
937        .lock()
938        .ok()
939        .and_then(|s| s.snapshot_arc())
940        .and_then(|snap| {
941            snap.commands()
942                .iter()
943                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
944                .cloned()
945        })?;
946    let input = serde_json::json!({ "args": args, "command": name });
947    let input = serde_json::to_string(&input).ok()?;
948    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
949        let mut out = rpi_plugin_sdk::StbString::empty();
950        let rc = (command.handler)(
951            rpi_plugin_sdk::StbStringRef::from_str(&input),
952            &mut out as *mut rpi_plugin_sdk::StbString,
953            command.user_data,
954        );
955        let text = if rc == 0 {
956            Some(out.to_string_lossy())
957        } else {
958            None
959        };
960        rpi_extensions::host_free_string(out);
961        text
962    }))
963    .ok()
964    .flatten()?;
965    serde_json::from_str(&outcome).ok()
966}
967
968fn handle_extension_ui_result(
969    result: Option<serde_json::Value>,
970    ctx: &CommandContext,
971    session: crate::session::ExtensionSessionCell,
972    command_name: String,
973) {
974    let Some(value) = result else {
975        add_error_message(&ctx.chat, "Extension command failed.");
976        ctx.tui.request_render(false);
977        return;
978    };
979    // A cancellation continuation may intentionally return JSON null. Native
980    // pi resolves the pending promise with `undefined` and does not add a
981    // visible "null" message to the transcript.
982    if value.is_null() {
983        ctx.tui.request_render(false);
984        return;
985    }
986    match value.get("kind").and_then(|v| v.as_str()) {
987        Some("message") | None => {
988            let fallback = value.to_string();
989            let text = value
990                .get("text")
991                .and_then(|v| v.as_str())
992                .unwrap_or(&fallback)
993                .to_string();
994            if !text.is_empty() {
995                add_note_message(&ctx.chat, &text);
996            }
997            ctx.tui.request_render(false);
998        }
999        Some("selector") => open_extension_selector(ctx, session, command_name, value),
1000        Some("editor") => open_extension_editor(ctx, session, command_name, value),
1001        // Native pi exposes `ctx.ui.input(title, placeholder)` separately
1002        // from the multiline editor. Render it as a focused single-line
1003        // dialog in the swapped input slot.
1004        Some("input") => open_extension_input(ctx, session, command_name, value),
1005        Some(other) => {
1006            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1007            ctx.tui.request_render(false);
1008        }
1009    }
1010}
1011
1012/// Render the title used by the native pi extension dialogs.  Keeping it in
1013/// the swapped editor container makes the question stay visible while the
1014/// extension waits for the answer, instead of adding a transient chat note.
1015fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1016    let colors = current_theme().colors;
1017    let text = if bold {
1018        tui_bold(title)
1019    } else {
1020        title.to_string()
1021    };
1022    Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1023}
1024
1025fn extension_dialog_hint(label: &str) -> Arc<Text> {
1026    Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1027}
1028
1029/// Take and run the cancellation callback for the active extension dialog.
1030/// Taking it before invoking the callback breaks the temporary Arc cycle: the
1031/// callback owns the command context so it can process a follow-up result.
1032fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1033    let callback = state.active_extension_cancel.lock().unwrap().take();
1034    if let Some(callback) = callback {
1035        callback();
1036        true
1037    } else {
1038        false
1039    }
1040}
1041
1042fn open_extension_input(
1043    ctx: &CommandContext,
1044    session: crate::session::ExtensionSessionCell,
1045    command_name: String,
1046    value: serde_json::Value,
1047) {
1048    let title = value
1049        .get("title")
1050        .and_then(|v| v.as_str())
1051        .filter(|title| !title.is_empty())
1052        .unwrap_or("Input");
1053    let input = value
1054        .get("placeholder")
1055        .and_then(|v| v.as_str())
1056        .map(Input::with_placeholder)
1057        .unwrap_or_default();
1058    let input = Arc::new(input);
1059    if let Some(initial) = value
1060        .get("initialText")
1061        .or_else(|| value.get("text"))
1062        .and_then(|v| v.as_str())
1063    {
1064        input.set_value(initial);
1065    }
1066    input.set_focused(true);
1067
1068    let frame = Arc::new(Container::new());
1069    frame.add_child(Arc::new(DynamicBorder::new()));
1070    frame.add_child(Arc::new(Spacer::new(1)));
1071    frame.add_child(extension_dialog_title(title, false));
1072    frame.add_child(Arc::new(Spacer::new(1)));
1073    frame.add_child(input.clone());
1074    frame.add_child(Arc::new(Spacer::new(1)));
1075    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1076    frame.add_child(Arc::new(Spacer::new(1)));
1077    frame.add_child(Arc::new(DynamicBorder::new()));
1078
1079    *ctx.state.active_extension_editor.lock().unwrap() = None;
1080    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1081    ctx.editor_container.clear();
1082    ctx.editor_container.add_child(frame);
1083
1084    let state = ctx.state.clone();
1085    let ec = ctx.editor_container.clone();
1086    let original = ctx.editor.clone();
1087    let tui = ctx.tui.clone();
1088    let session_submit = session.clone();
1089    let command_submit = command_name.clone();
1090    let ctx_submit = ctx.clone();
1091    input.on_submit(Arc::new(move |text| {
1092        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1093        let result = invoke_extension_command(
1094            &session_submit,
1095            &command_submit,
1096            &serde_json::to_string(&args).unwrap_or_default(),
1097        );
1098        close_extension_editor(&state, &ec, &original, &tui);
1099        handle_extension_ui_result(
1100            result,
1101            &ctx_submit,
1102            session_submit.clone(),
1103            command_submit.clone(),
1104        );
1105    }));
1106
1107    let state_cancel = ctx.state.clone();
1108    let ec_cancel = ctx.editor_container.clone();
1109    let original_cancel = ctx.editor.clone();
1110    let tui_cancel = ctx.tui.clone();
1111    let session_cancel = session.clone();
1112    let command_cancel = command_name.clone();
1113    let ctx_cancel = ctx.clone();
1114    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1115        let args = serde_json::json!({ "action": "cancel" });
1116        let result = invoke_extension_command(
1117            &session_cancel,
1118            &command_cancel,
1119            &serde_json::to_string(&args).unwrap_or_default(),
1120        );
1121        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1122        handle_extension_ui_result(
1123            result,
1124            &ctx_cancel,
1125            session_cancel.clone(),
1126            command_cancel.clone(),
1127        );
1128    }));
1129
1130    ctx.tui.set_focus(Some(input));
1131    ctx.tui.request_render(false);
1132}
1133
1134fn open_extension_selector(
1135    ctx: &CommandContext,
1136    session: crate::session::ExtensionSessionCell,
1137    command_name: String,
1138    value: serde_json::Value,
1139) {
1140    let items = value
1141        .get("items")
1142        .and_then(|v| v.as_array())
1143        .map(|items| {
1144            items
1145                .iter()
1146                .filter_map(|item| {
1147                    // Native pi's selector accepts `string[]`; the Rust ABI
1148                    // also permits `{value,label,description}` objects.
1149                    let value = if let Some(value) = item.as_str() {
1150                        value
1151                    } else {
1152                        item.get("value")?.as_str()?
1153                    };
1154                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1155                    let mut out = SelectItem::new(value, label);
1156                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1157                        out = out.with_description(desc);
1158                    }
1159                    Some(out)
1160                })
1161                .collect::<Vec<_>>()
1162        })
1163        .unwrap_or_default();
1164    if items.is_empty() {
1165        add_error_message(&ctx.chat, "Extension selector has no items.");
1166        ctx.tui.request_render(false);
1167        return;
1168    }
1169    let list = Arc::new(SelectList::new(items, 10));
1170    let title = value
1171        .get("title")
1172        .and_then(|v| v.as_str())
1173        .filter(|title| !title.is_empty())
1174        .unwrap_or("Select");
1175    let frame = Arc::new(Container::new());
1176    frame.add_child(Arc::new(DynamicBorder::new()));
1177    frame.add_child(Arc::new(Spacer::new(1)));
1178    frame.add_child(extension_dialog_title(title, true));
1179    frame.add_child(Arc::new(Spacer::new(1)));
1180    frame.add_child(list.clone());
1181    frame.add_child(Arc::new(Spacer::new(1)));
1182    frame.add_child(extension_dialog_hint(
1183        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1184    ));
1185    frame.add_child(Arc::new(Spacer::new(1)));
1186    frame.add_child(Arc::new(DynamicBorder::new()));
1187
1188    let state = ctx.state.clone();
1189    let ec = ctx.editor_container.clone();
1190    let editor = ctx.editor.clone();
1191    let tui = ctx.tui.clone();
1192    let session_select = session.clone();
1193    let command_select = command_name.clone();
1194    let ctx_select = ctx.clone();
1195    list.on_select(Arc::new(move |item| {
1196        let args = serde_json::json!({ "action": "select", "value": item.value });
1197        let result = invoke_extension_command(
1198            &session_select,
1199            &command_select,
1200            &serde_json::to_string(&args).unwrap_or_default(),
1201        );
1202        close_selector(&state, &ec, &editor, &tui);
1203        handle_extension_ui_result(
1204            result,
1205            &ctx_select,
1206            session_select.clone(),
1207            command_select.clone(),
1208        );
1209    }));
1210    let state_cancel = ctx.state.clone();
1211    let ec_cancel = ctx.editor_container.clone();
1212    let editor_cancel = ctx.editor.clone();
1213    let tui_cancel = ctx.tui.clone();
1214    list.on_cancel(Arc::new(move || {
1215        if !run_extension_cancel(&state_cancel) {
1216            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1217        }
1218    }));
1219    let state_cancel = ctx.state.clone();
1220    let ec_cancel = ctx.editor_container.clone();
1221    let editor_cancel = ctx.editor.clone();
1222    let tui_cancel = ctx.tui.clone();
1223    let session_cancel = session.clone();
1224    let command_cancel = command_name.clone();
1225    let ctx_cancel = ctx.clone();
1226    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1227        let args = serde_json::json!({ "action": "cancel" });
1228        let result = invoke_extension_command(
1229            &session_cancel,
1230            &command_cancel,
1231            &serde_json::to_string(&args).unwrap_or_default(),
1232        );
1233        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1234        handle_extension_ui_result(
1235            result,
1236            &ctx_cancel,
1237            session_cancel.clone(),
1238            command_cancel.clone(),
1239        );
1240    }));
1241    open_selector_with_view(
1242        &ctx.state,
1243        &ctx.editor_container,
1244        &ctx.editor,
1245        &ctx.tui,
1246        list,
1247        frame,
1248        SelectorKind::Extension,
1249    );
1250}
1251
1252fn open_extension_editor(
1253    ctx: &CommandContext,
1254    session: crate::session::ExtensionSessionCell,
1255    command_name: String,
1256    value: serde_json::Value,
1257) {
1258    let initial = value
1259        .get("initialText")
1260        .or_else(|| value.get("text"))
1261        .and_then(|v| v.as_str())
1262        .unwrap_or_default()
1263        .to_string();
1264    let title = value
1265        .get("title")
1266        .and_then(|v| v.as_str())
1267        .filter(|title| !title.is_empty())
1268        .unwrap_or("Editor");
1269    let editor = Arc::new(Editor::new(
1270        EditorOptions {
1271            padding_x: 1,
1272            autocomplete_max_visible: 0,
1273            placeholder: value
1274                .get("placeholder")
1275                .and_then(|v| v.as_str())
1276                .map(str::to_string),
1277            initial_text: Some(initial),
1278        },
1279        EditorStyle {
1280            prompt: "> ".to_string(),
1281            placeholder: String::new(),
1282        },
1283        Arc::new(rpi_tui::Keybindings::new()),
1284    ));
1285    editor.set_focused(true);
1286    let frame = Arc::new(Container::new());
1287    frame.add_child(Arc::new(DynamicBorder::new()));
1288    frame.add_child(Arc::new(Spacer::new(1)));
1289    frame.add_child(extension_dialog_title(title, false));
1290    frame.add_child(Arc::new(Spacer::new(1)));
1291    frame.add_child(editor.clone());
1292    frame.add_child(Arc::new(Spacer::new(1)));
1293    frame.add_child(extension_dialog_hint(
1294        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1295    ));
1296    frame.add_child(Arc::new(Spacer::new(1)));
1297    frame.add_child(Arc::new(DynamicBorder::new()));
1298
1299    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1300    *ctx.state.active_extension_input.lock().unwrap() = None;
1301    ctx.editor_container.clear();
1302    ctx.editor_container.add_child(frame);
1303
1304    let state = ctx.state.clone();
1305    let ec = ctx.editor_container.clone();
1306    let original = ctx.editor.clone();
1307    let tui = ctx.tui.clone();
1308    let session_submit = session.clone();
1309    let command_submit = command_name.clone();
1310    let ctx_submit = ctx.clone();
1311    editor.on_submit(Arc::new(move |text| {
1312        let args = serde_json::json!({ "action": "edit", "text": text });
1313        let result = invoke_extension_command(
1314            &session_submit,
1315            &command_submit,
1316            &serde_json::to_string(&args).unwrap_or_default(),
1317        );
1318        close_extension_editor(&state, &ec, &original, &tui);
1319        handle_extension_ui_result(
1320            result,
1321            &ctx_submit,
1322            session_submit.clone(),
1323            command_submit.clone(),
1324        );
1325    }));
1326
1327    let state_cancel = ctx.state.clone();
1328    let ec_cancel = ctx.editor_container.clone();
1329    let original_cancel = ctx.editor.clone();
1330    let tui_cancel = ctx.tui.clone();
1331    let session_cancel = session.clone();
1332    let command_cancel = command_name.clone();
1333    let ctx_cancel = ctx.clone();
1334    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1335        let args = serde_json::json!({ "action": "cancel" });
1336        let result = invoke_extension_command(
1337            &session_cancel,
1338            &command_cancel,
1339            &serde_json::to_string(&args).unwrap_or_default(),
1340        );
1341        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1342        handle_extension_ui_result(
1343            result,
1344            &ctx_cancel,
1345            session_cancel.clone(),
1346            command_cancel.clone(),
1347        );
1348    }));
1349
1350    ctx.tui.set_focus(Some(editor));
1351    ctx.tui.request_render(false);
1352}
1353
1354fn close_extension_editor(
1355    state: &Arc<TuiState>,
1356    editor_container: &Arc<Container>,
1357    editor: &Arc<Editor>,
1358    tui: &Arc<TuiAltScreen>,
1359) {
1360    editor_container.clear();
1361    editor_container.add_child(editor.clone());
1362    state.autocomplete_container.clear();
1363    *state.active_extension_editor.lock().unwrap() = None;
1364    *state.active_extension_input.lock().unwrap() = None;
1365    *state.active_extension_cancel.lock().unwrap() = None;
1366    editor.set_focused(true);
1367    tui.set_focus(Some(editor.clone()));
1368    tui.request_render(false);
1369}
1370
1371/// Open one Node `ctx.ui.*` request in the native editor slot. The Node host
1372/// waits on the runtime response while these callbacks resolve the bridge on
1373/// Enter, selection, or cancellation.
1374fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1375    match request.method.as_str() {
1376        "select" => open_js_selector(ctx, bridge, request, false),
1377        "confirm" => open_js_selector(ctx, bridge, request, true),
1378        "input" => open_js_input(ctx, bridge, request),
1379        "editor" => open_js_editor(ctx, bridge, request),
1380        _ => {
1381            bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1382        }
1383    }
1384}
1385
1386fn open_js_selector(
1387    ctx: &CommandContext,
1388    bridge: Arc<JsDialogBridge>,
1389    request: JsDialogRequest,
1390    confirm: bool,
1391) {
1392    let values = if confirm {
1393        vec!["Yes".to_string(), "No".to_string()]
1394    } else {
1395        request.options.clone()
1396    };
1397    if values.is_empty() {
1398        bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1399        return;
1400    }
1401    let items = values
1402        .iter()
1403        .map(|value| SelectItem::new(value, value))
1404        .collect::<Vec<_>>();
1405    let list = Arc::new(SelectList::new(items, 10));
1406    let frame = Arc::new(Container::new());
1407    frame.add_child(Arc::new(DynamicBorder::new()));
1408    frame.add_child(Arc::new(Spacer::new(1)));
1409    frame.add_child(extension_dialog_title(
1410        if request.title.is_empty() {
1411            if confirm {
1412                "Confirm"
1413            } else {
1414                "Select"
1415            }
1416        } else {
1417            request.title.as_str()
1418        },
1419        true,
1420    ));
1421    if !request.message.is_empty() {
1422        frame.add_child(Arc::new(Spacer::new(1)));
1423        frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1424    }
1425    frame.add_child(Arc::new(Spacer::new(1)));
1426    frame.add_child(list.clone());
1427    frame.add_child(Arc::new(Spacer::new(1)));
1428    frame.add_child(extension_dialog_hint(
1429        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1430    ));
1431    frame.add_child(Arc::new(Spacer::new(1)));
1432    frame.add_child(Arc::new(DynamicBorder::new()));
1433
1434    let id = request.id.clone();
1435    let bridge_select = bridge.clone();
1436    let state_select = ctx.state.clone();
1437    let ec_select = ctx.editor_container.clone();
1438    let editor_select = ctx.editor.clone();
1439    let tui_select = ctx.tui.clone();
1440    list.on_select(Arc::new(move |item| {
1441        let result = if confirm {
1442            serde_json::json!({ "confirmed": item.value == "Yes" })
1443        } else {
1444            serde_json::json!({ "value": item.value })
1445        };
1446        bridge_select.respond(&id, result);
1447        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1448    }));
1449
1450    let id_cancel = request.id.clone();
1451    let bridge_cancel = bridge.clone();
1452    let state_cancel = ctx.state.clone();
1453    let ec_cancel = ctx.editor_container.clone();
1454    let editor_cancel = ctx.editor.clone();
1455    let tui_cancel = ctx.tui.clone();
1456    list.on_cancel(Arc::new(move || {
1457        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1458        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1459    }));
1460
1461    let id_abort = request.id.clone();
1462    let bridge_abort = bridge.clone();
1463    let state_abort = ctx.state.clone();
1464    let ec_abort = ctx.editor_container.clone();
1465    let editor_abort = ctx.editor.clone();
1466    let tui_abort = ctx.tui.clone();
1467    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1468        bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1469        close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1470    }));
1471
1472    open_selector_with_view(
1473        &ctx.state,
1474        &ctx.editor_container,
1475        &ctx.editor,
1476        &ctx.tui,
1477        list,
1478        frame,
1479        SelectorKind::Extension,
1480    );
1481}
1482
1483fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1484    let input = request
1485        .placeholder
1486        .as_deref()
1487        .map(Input::with_placeholder)
1488        .unwrap_or_default();
1489    let input = Arc::new(input);
1490    input.set_focused(true);
1491
1492    let frame = Arc::new(Container::new());
1493    frame.add_child(Arc::new(DynamicBorder::new()));
1494    frame.add_child(Arc::new(Spacer::new(1)));
1495    frame.add_child(extension_dialog_title(
1496        if request.title.is_empty() {
1497            "Input"
1498        } else {
1499            &request.title
1500        },
1501        false,
1502    ));
1503    frame.add_child(Arc::new(Spacer::new(1)));
1504    frame.add_child(input.clone());
1505    frame.add_child(Arc::new(Spacer::new(1)));
1506    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1507    frame.add_child(Arc::new(Spacer::new(1)));
1508    frame.add_child(Arc::new(DynamicBorder::new()));
1509
1510    *ctx.state.active_extension_editor.lock().unwrap() = None;
1511    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1512    ctx.editor_container.clear();
1513    ctx.editor_container.add_child(frame);
1514
1515    let id = request.id.clone();
1516    let bridge_submit = bridge.clone();
1517    let state_submit = ctx.state.clone();
1518    let ec_submit = ctx.editor_container.clone();
1519    let editor_submit = ctx.editor.clone();
1520    let tui_submit = ctx.tui.clone();
1521    input.on_submit(Arc::new(move |value| {
1522        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1523        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1524    }));
1525
1526    let id_cancel = request.id.clone();
1527    let bridge_cancel = bridge.clone();
1528    let state_cancel = ctx.state.clone();
1529    let ec_cancel = ctx.editor_container.clone();
1530    let editor_cancel = ctx.editor.clone();
1531    let tui_cancel = ctx.tui.clone();
1532    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1533        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1534        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1535    }));
1536    ctx.tui.set_focus(Some(input));
1537    ctx.tui.request_render(false);
1538}
1539
1540fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1541    let editor = Arc::new(Editor::new(
1542        EditorOptions {
1543            padding_x: 1,
1544            autocomplete_max_visible: 0,
1545            initial_text: request.prefill.clone(),
1546            ..Default::default()
1547        },
1548        EditorStyle {
1549            prompt: "> ".to_string(),
1550            placeholder: String::new(),
1551        },
1552        Arc::new(rpi_tui::Keybindings::new()),
1553    ));
1554    editor.set_focused(true);
1555
1556    let frame = Arc::new(Container::new());
1557    frame.add_child(Arc::new(DynamicBorder::new()));
1558    frame.add_child(Arc::new(Spacer::new(1)));
1559    frame.add_child(extension_dialog_title(
1560        if request.title.is_empty() {
1561            "Editor"
1562        } else {
1563            &request.title
1564        },
1565        false,
1566    ));
1567    frame.add_child(Arc::new(Spacer::new(1)));
1568    frame.add_child(editor.clone());
1569    frame.add_child(Arc::new(Spacer::new(1)));
1570    frame.add_child(extension_dialog_hint(
1571        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1572    ));
1573    frame.add_child(Arc::new(Spacer::new(1)));
1574    frame.add_child(Arc::new(DynamicBorder::new()));
1575
1576    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1577    *ctx.state.active_extension_input.lock().unwrap() = None;
1578    ctx.editor_container.clear();
1579    ctx.editor_container.add_child(frame);
1580
1581    let id = request.id.clone();
1582    let bridge_submit = bridge.clone();
1583    let state_submit = ctx.state.clone();
1584    let ec_submit = ctx.editor_container.clone();
1585    let editor_submit = ctx.editor.clone();
1586    let tui_submit = ctx.tui.clone();
1587    editor.on_submit(Arc::new(move |value| {
1588        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1589        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1590    }));
1591
1592    let id_cancel = request.id.clone();
1593    let bridge_cancel = bridge.clone();
1594    let state_cancel = ctx.state.clone();
1595    let ec_cancel = ctx.editor_container.clone();
1596    let editor_cancel = ctx.editor.clone();
1597    let tui_cancel = ctx.tui.clone();
1598    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1599        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1600        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1601    }));
1602    ctx.tui.set_focus(Some(editor));
1603    ctx.tui.request_render(false);
1604}
1605
1606fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1607    for id in bridge.cancelled_active_ids() {
1608        // Several commands can ask for a dialog concurrently. Only the id
1609        // currently occupying the TUI slot may close the visible component;
1610        // an older cancellation must leave a newer ask dialog untouched.
1611        if !bridge.is_visible(&id) {
1612            bridge.finish(&id);
1613            continue;
1614        }
1615        if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1616            selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1617        } else if ctx.state.extension_dialog_open() {
1618            if !run_extension_cancel(&ctx.state) {
1619                close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1620            }
1621        }
1622        // `respond` normally removes the active entry from the callback. The
1623        // fallback path above can run before a callback was installed, so
1624        // always discard the id after routing the cancellation.
1625        bridge.finish(&id);
1626    }
1627}
1628
1629// ===========================================================================
1630// ask_user — plugin UI-dialog bridge (runtime action 17)
1631// ===========================================================================
1632
1633/// Sentinel option value that switches a freeform-capable selector into the
1634/// single-line input slot (native `allowFreeform` affordance).
1635const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1636
1637/// One in-flight `ask_user` request being shown in the TUI. Unlike
1638/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1639/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1640/// bridge only tracks which request id currently occupies the input slot.
1641#[derive(Clone)]
1642struct AskUserBridge {
1643    mailbox: rpi_extensions::UiDialogMailbox,
1644    visible: Arc<Mutex<Option<String>>>,
1645}
1646
1647impl AskUserBridge {
1648    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1649        Self {
1650            mailbox,
1651            visible: Arc::new(Mutex::new(None)),
1652        }
1653    }
1654
1655    /// Mark the interactive consumer as available. Idempotent.
1656    fn attach(&self) {
1657        self.mailbox.attach();
1658    }
1659
1660    /// Take the next pending request and mark it visible. Only the single key
1661    /// loop calls this while no other dialog is open, so the visible-id
1662    /// bookkeeping is race-free without a separate lock.
1663    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1664        let mut visible = self.visible.lock().ok()?;
1665        if visible.is_some() {
1666            return None;
1667        }
1668        let request = self.mailbox.take_pending()?;
1669        *visible = Some(request.request_id.clone());
1670        Some(request)
1671    }
1672
1673    fn clear_visible(&self, id: &str) {
1674        if let Ok(mut visible) = self.visible.lock() {
1675            if visible.as_deref() == Some(id) {
1676                *visible = None;
1677            }
1678        }
1679    }
1680
1681    /// Record an answer for `id` and release the input slot.
1682    fn respond(&self, id: &str, answer: serde_json::Value) {
1683        let _ = self.mailbox.respond(id, answer);
1684        self.clear_visible(id);
1685    }
1686
1687    /// Cancel `id` and release the input slot.
1688    fn cancel(&self, id: &str) {
1689        let _ = self.mailbox.cancel(id);
1690        self.clear_visible(id);
1691    }
1692
1693    /// Cancel every outstanding request (run abort). Pending prompts from the
1694    /// aborted turn must not surface in a later turn.
1695    fn cancel_all(&self) {
1696        self.mailbox.cancel_all();
1697        if let Ok(mut visible) = self.visible.lock() {
1698            *visible = None;
1699        }
1700    }
1701
1702    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1703    /// observes a terminal state instead of blocking on a dead session.
1704    fn shutdown(&self) {
1705        self.mailbox.detach();
1706        if let Ok(mut visible) = self.visible.lock() {
1707            *visible = None;
1708        }
1709    }
1710}
1711
1712/// Parsed presentation data for one `ask_user` question. Supports both the
1713/// flat native question contract and the earlier `questions[0]` alias, plus a
1714/// `confirm` summary.
1715#[derive(Clone)]
1716struct AskUserPrompt {
1717    question_id: String,
1718    question: String,
1719    header: Option<String>,
1720    context: Option<String>,
1721    options: Vec<(String, Option<String>)>,
1722    allow_multiple: bool,
1723    allow_freeform: bool,
1724    suggest: Option<String>,
1725    kind: String,
1726}
1727
1728fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1729    keys.iter().find_map(|key| {
1730        value
1731            .get(*key)
1732            .and_then(serde_json::Value::as_str)
1733            .map(str::trim)
1734            .filter(|text| !text.is_empty())
1735            .map(str::to_string)
1736    })
1737}
1738
1739fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1740    keys.iter()
1741        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1742}
1743
1744fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1745    let mut options = Vec::new();
1746    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1747        return options;
1748    };
1749    for item in items {
1750        if let Some(title) = item.as_str() {
1751            let title = title.trim();
1752            if !title.is_empty() {
1753                options.push((title.to_string(), None));
1754            }
1755            continue;
1756        }
1757        let Some(object) = item.as_object() else { continue };
1758        let title = ["title", "label", "text", "value", "name"]
1759            .iter()
1760            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1761            .map(str::trim)
1762            .filter(|title| !title.is_empty());
1763        let Some(title) = title else { continue };
1764        let description = object
1765            .get("description")
1766            .and_then(serde_json::Value::as_str)
1767            .map(str::trim)
1768            .filter(|text| !text.is_empty())
1769            .map(str::to_string);
1770        options.push((title.to_string(), description));
1771    }
1772    options
1773}
1774
1775fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1776    let ui = &request.ui;
1777    let kind = ui
1778        .get("kind")
1779        .and_then(serde_json::Value::as_str)
1780        .unwrap_or("selector")
1781        .to_string();
1782    // A request may carry a single flat question or a `questions[]` array. The
1783    // plugin emits one question per request, but accepting `questions[0]` keeps
1784    // the host compatible with hosts/plugins that batch.
1785    let nested = ui
1786        .get("questions")
1787        .and_then(serde_json::Value::as_array)
1788        .and_then(|items| items.first());
1789    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1790
1791    let question = ask_user_str(source, &["question", "summary", "title"])
1792        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1793        .unwrap_or_default();
1794    let question_id = ask_user_str(source, &["id", "questionId"])
1795        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1796        .unwrap_or_else(|| request.request_id.clone());
1797    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1798    let context = ask_user_str(source, &["context", "message"])
1799        .or_else(|| ask_user_str(ui, &["context", "message"]));
1800    let mut options = ask_user_options(source);
1801    if options.is_empty() {
1802        options = ask_user_options(ui);
1803    }
1804    if kind == "confirm" && options.is_empty() {
1805        options = vec![
1806            ("Yes".to_string(), None),
1807            ("No".to_string(), None),
1808        ];
1809    }
1810    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1811        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1812        .unwrap_or(false);
1813    // Freeform defaults on when there is nothing to pick from, matching the
1814    // "no options ⇒ free input" contract.
1815    let allow_freeform = ask_user_bool(
1816        source,
1817        &["allowFreeform", "allow_freeform", "allow_custom"],
1818    )
1819    .or_else(|| {
1820        ask_user_bool(
1821            ui,
1822            &["allowFreeform", "allow_freeform", "allow_custom"],
1823        )
1824    })
1825    .unwrap_or(options.is_empty());
1826    let suggest = ask_user_str(source, &["suggest", "placeholder"])
1827        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1828
1829    AskUserPrompt {
1830        question_id,
1831        question,
1832        header,
1833        context,
1834        options,
1835        allow_multiple,
1836        allow_freeform,
1837        suggest,
1838        kind,
1839    }
1840}
1841
1842/// Build the shared header frame for an ask-user prompt.
1843fn ask_user_frame(
1844    prompt: &AskUserPrompt,
1845    body: Arc<dyn Component>,
1846    hint: &str,
1847) -> Arc<Container> {
1848    let frame = Arc::new(Container::new());
1849    frame.add_child(Arc::new(DynamicBorder::new()));
1850    frame.add_child(Arc::new(Spacer::new(1)));
1851    if let Some(header) = prompt.header.as_deref() {
1852        frame.add_child(extension_dialog_title(header, true));
1853    }
1854    if !prompt.question.trim().is_empty() {
1855        frame.add_child(extension_dialog_title(
1856            &prompt.question,
1857            prompt.header.is_none(),
1858        ));
1859    }
1860    if let Some(context) = prompt.context.as_deref() {
1861        frame.add_child(extension_dialog_hint(context));
1862    }
1863    frame.add_child(Arc::new(Spacer::new(1)));
1864    frame.add_child(body);
1865    frame.add_child(Arc::new(Spacer::new(1)));
1866    frame.add_child(extension_dialog_hint(hint));
1867    frame.add_child(Arc::new(Spacer::new(1)));
1868    frame.add_child(Arc::new(DynamicBorder::new()));
1869    frame
1870}
1871
1872/// Open one `ask_user` request in the TUI input slot.
1873fn open_ask_user_dialog(
1874    ctx: &CommandContext,
1875    bridge: AskUserBridge,
1876    request: rpi_extensions::UiDialogRequest,
1877) {
1878    let prompt = parse_ask_user_prompt(&request);
1879    if prompt.kind == "input" || prompt.options.is_empty() {
1880        open_ask_user_input(ctx, bridge, request, prompt);
1881    } else {
1882        open_ask_user_selector(ctx, bridge, request, prompt);
1883    }
1884}
1885
1886fn open_ask_user_selector(
1887    ctx: &CommandContext,
1888    bridge: AskUserBridge,
1889    request: rpi_extensions::UiDialogRequest,
1890    prompt: AskUserPrompt,
1891) {
1892    let multi = prompt.allow_multiple && prompt.kind != "confirm";
1893    let mut items: Vec<SelectItem> = Vec::new();
1894    for (title, description) in &prompt.options {
1895        let item = SelectItem::new(title, title);
1896        let item = match description.as_deref() {
1897            Some(description) => item.with_description(description),
1898            None => item,
1899        };
1900        items.push(item);
1901    }
1902    // A freeform-capable single selector gets an extra sentinel row that opens
1903    // the input slot for a typed answer.
1904    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1905        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1906    }
1907    let list = if multi {
1908        Arc::new(SelectList::new_multi(items, 10))
1909    } else {
1910        Arc::new(SelectList::new(items, 10))
1911    };
1912
1913    let request_id = request.request_id.clone();
1914    let state = ctx.state.clone();
1915    let editor_container = ctx.editor_container.clone();
1916    let editor = ctx.editor.clone();
1917    let tui = ctx.tui.clone();
1918
1919    // Single-select: respond with the chosen title, or fall through to the
1920    // input slot for the freeform sentinel.
1921    let bridge_select = bridge.clone();
1922    let request_for_freeform = request.clone();
1923    let prompt_for_freeform = AskUserPrompt {
1924        question_id: prompt.question_id.clone(),
1925        question: prompt.question.clone(),
1926        header: prompt.header.clone(),
1927        context: prompt.context.clone(),
1928        options: Vec::new(),
1929        allow_multiple: false,
1930        allow_freeform: true,
1931        suggest: prompt.suggest.clone(),
1932        kind: "input".to_string(),
1933    };
1934    let state_select = state.clone();
1935    let ec_select = editor_container.clone();
1936    let editor_select = editor.clone();
1937    let tui_select = tui.clone();
1938    let ctx_select = ctx.clone();
1939    list.on_select(Arc::new(move |item: &SelectItem| {
1940        if item.value == ASK_USER_FREEFORM {
1941            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1942            open_ask_user_input(
1943                &ctx_select,
1944                bridge_select.clone(),
1945                request_for_freeform.clone(),
1946                prompt_for_freeform.clone(),
1947            );
1948            return;
1949        }
1950        bridge_select.respond(
1951            &request_id,
1952            serde_json::json!({
1953                "values": [item.display_value()],
1954                "kind": "selector",
1955            }),
1956        );
1957        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1958    }));
1959
1960    if multi {
1961        let bridge_multi = bridge.clone();
1962        let request_id_multi = request.request_id.clone();
1963        let state_multi = state.clone();
1964        let ec_multi = editor_container.clone();
1965        let editor_multi = editor.clone();
1966        let tui_multi = tui.clone();
1967        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
1968            let values: Vec<String> = selected
1969                .iter()
1970                .map(|item| item.display_value().to_string())
1971                .collect();
1972            bridge_multi.respond(
1973                &request_id_multi,
1974                serde_json::json!({"values": values, "kind": "selector"}),
1975            );
1976            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
1977        }));
1978    }
1979
1980    let bridge_cancel = bridge.clone();
1981    let request_id_cancel = request.request_id.clone();
1982    let state_cancel = state.clone();
1983    let ec_cancel = editor_container.clone();
1984    let editor_cancel = editor.clone();
1985    let tui_cancel = tui.clone();
1986    list.on_cancel(Arc::new(move || {
1987        bridge_cancel.cancel(&request_id_cancel);
1988        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1989    }));
1990
1991    let hint = if multi {
1992        "Space toggle · Enter submit · Esc cancel"
1993    } else {
1994        "Enter select · Esc cancel"
1995    };
1996    let frame = ask_user_frame(&prompt, list.clone(), hint);
1997    open_selector_with_view(
1998        &state,
1999        &editor_container,
2000        &editor,
2001        &tui,
2002        list,
2003        frame,
2004        SelectorKind::Extension,
2005    );
2006}
2007
2008fn open_ask_user_input(
2009    ctx: &CommandContext,
2010    bridge: AskUserBridge,
2011    request: rpi_extensions::UiDialogRequest,
2012    prompt: AskUserPrompt,
2013) {
2014    let placeholder = prompt
2015        .suggest
2016        .clone()
2017        .unwrap_or_else(|| "Type your answer…".to_string());
2018    let input = Arc::new(Input::with_placeholder(&placeholder));
2019    input.set_focused(true);
2020
2021    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2022    *ctx.state.active_extension_editor.lock().unwrap() = None;
2023    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2024    ctx.editor_container.clear();
2025    ctx.editor_container.add_child(frame);
2026    ctx.tui.set_focus(Some(input.clone()));
2027    ctx.tui.request_render(false);
2028
2029    let state = ctx.state.clone();
2030    let ec = ctx.editor_container.clone();
2031    let editor = ctx.editor.clone();
2032    let tui = ctx.tui.clone();
2033    let bridge_submit = bridge.clone();
2034    let request_id = request.request_id.clone();
2035    input.on_submit(Arc::new(move |text: &str| {
2036        bridge_submit.respond(
2037            &request_id,
2038            serde_json::json!({
2039                "values": [text],
2040                "value": text,
2041                "text": text,
2042                "kind": "input",
2043            }),
2044        );
2045        close_extension_editor(&state, &ec, &editor, &tui);
2046    }));
2047
2048    let state_cancel = ctx.state.clone();
2049    let ec_cancel = ctx.editor_container.clone();
2050    let editor_cancel = ctx.editor.clone();
2051    let tui_cancel = ctx.tui.clone();
2052    let bridge_cancel = bridge.clone();
2053    let request_id_cancel = request.request_id.clone();
2054    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2055        bridge_cancel.cancel(&request_id_cancel);
2056        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2057    }));
2058}
2059
2060// ---- Built-in command implementations ----
2061
2062struct HelpCommand;
2063impl SlashCommand for HelpCommand {
2064    fn name(&self) -> &'static str {
2065        "/help"
2066    }
2067    fn aliases(&self) -> &'static [&'static str] {
2068        &["/?"]
2069    }
2070    fn description(&self) -> &'static str {
2071        "Show available commands"
2072    }
2073    fn execute(&self, ctx: &CommandContext, _args: &str) {
2074        add_help_message(&ctx.chat);
2075        ctx.tui.request_render(false);
2076    }
2077}
2078
2079struct ClearChatCommand;
2080impl SlashCommand for ClearChatCommand {
2081    fn name(&self) -> &'static str {
2082        "/clear"
2083    }
2084    fn aliases(&self) -> &'static [&'static str] {
2085        &["/new"]
2086    }
2087    // `/new` carries its own weight as a discoverable entry, so surface it.
2088    fn alias_visible(&self) -> &'static [&'static str] {
2089        &["/new"]
2090    }
2091    fn description(&self) -> &'static str {
2092        "Clear the conversation"
2093    }
2094    fn execute(&self, ctx: &CommandContext, _args: &str) {
2095        let _ = ctx.tx.send(TuiMessage::ClearChat);
2096    }
2097}
2098
2099struct ExitCommand;
2100impl SlashCommand for ExitCommand {
2101    fn name(&self) -> &'static str {
2102        "/exit"
2103    }
2104    fn aliases(&self) -> &'static [&'static str] {
2105        &["/quit", "/q"]
2106    }
2107    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
2108    fn alias_visible(&self) -> &'static [&'static str] {
2109        &["/quit"]
2110    }
2111    fn description(&self) -> &'static str {
2112        "Exit the application"
2113    }
2114    fn execute(&self, ctx: &CommandContext, _args: &str) {
2115        ctx.state.cancel_js_preparation();
2116        let _ = ctx.tx.send(TuiMessage::Exit);
2117    }
2118}
2119
2120struct VersionCommand;
2121impl SlashCommand for VersionCommand {
2122    fn name(&self) -> &'static str {
2123        "/version"
2124    }
2125    fn aliases(&self) -> &'static [&'static str] {
2126        &["/v"]
2127    }
2128    fn description(&self) -> &'static str {
2129        "Show version information"
2130    }
2131    fn execute(&self, ctx: &CommandContext, _args: &str) {
2132        add_version_message(&ctx.chat);
2133        ctx.tui.request_render(false);
2134    }
2135}
2136
2137struct ChangelogCommand;
2138impl SlashCommand for ChangelogCommand {
2139    fn name(&self) -> &'static str {
2140        "/changelog"
2141    }
2142    fn description(&self) -> &'static str {
2143        "Show recent release changes"
2144    }
2145    fn execute(&self, ctx: &CommandContext, _args: &str) {
2146        add_changelog_message(&ctx.chat);
2147        ctx.tui.request_render(false);
2148    }
2149}
2150
2151struct HotkeysCommand;
2152impl SlashCommand for HotkeysCommand {
2153    fn name(&self) -> &'static str {
2154        "/hotkeys"
2155    }
2156    fn description(&self) -> &'static str {
2157        "Show keyboard shortcuts"
2158    }
2159    fn execute(&self, ctx: &CommandContext, _args: &str) {
2160        add_hotkeys_message(&ctx.chat);
2161        ctx.tui.request_render(false);
2162    }
2163}
2164
2165struct ModelCommand;
2166impl SlashCommand for ModelCommand {
2167    fn name(&self) -> &'static str {
2168        "/model"
2169    }
2170    fn aliases(&self) -> &'static [&'static str] {
2171        &["/m"]
2172    }
2173    fn description(&self) -> &'static str {
2174        "Choose a model (selector)"
2175    }
2176    fn execute(&self, ctx: &CommandContext, args: &str) {
2177        let term = args.trim();
2178        if !term.is_empty() {
2179            // /model <name> — direct switch by id (pi handleModelCommand).
2180            let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
2181                add_error_message(
2182                    &ctx.chat,
2183                    &format!("No model matches \"{term}\". Try /model for the list."),
2184                );
2185                ctx.tui.request_render(false);
2186                return;
2187            };
2188            let model_id = model.id.clone();
2189            ctx.state.set_current_model(&model);
2190            let lane = ctx.lane.clone();
2191            tokio::spawn(async move {
2192                let _ = lane.set_model(model).await;
2193            });
2194            add_note_message(
2195                &ctx.chat,
2196                &format!(
2197                    "Model set to {} — applies to the next message.",
2198                    short_model_name(&model_id)
2199                ),
2200            );
2201            ctx.tui.request_render(false);
2202            return;
2203        }
2204        open_model_selector(
2205            &ctx.state,
2206            &ctx.editor_container,
2207            &ctx.editor,
2208            &ctx.tui,
2209            &ctx.model_catalog,
2210            &ctx.lane,
2211            &ctx.lane_model_id,
2212            &ctx.chat,
2213        );
2214    }
2215}
2216
2217struct ThinkingCommand;
2218impl SlashCommand for ThinkingCommand {
2219    fn name(&self) -> &'static str {
2220        "/thinking"
2221    }
2222    fn aliases(&self) -> &'static [&'static str] {
2223        &["/think"]
2224    }
2225    fn description(&self) -> &'static str {
2226        "Set thinking level (selector)"
2227    }
2228    fn execute(&self, ctx: &CommandContext, args: &str) {
2229        let level_name = args.trim();
2230        if !level_name.is_empty() {
2231            // /thinking <level> — direct set (pi supports the param form).
2232            let Some(level) = thinking_level_from_name(level_name) else {
2233                add_error_message(
2234                    &ctx.chat,
2235                    &format!(
2236                        "Unknown thinking level \"{level_name}\". Valid: {}",
2237                        crate::args::VALID_THINKING_LEVELS.join(", ")
2238                    ),
2239                );
2240                ctx.tui.request_render(false);
2241                return;
2242            };
2243            let lane = ctx.lane.clone();
2244            let footer = ctx.state.footer.clone();
2245            tokio::spawn(async move {
2246                let _ = lane.set_thinking_level(level).await;
2247            });
2248            footer.set_thinking_level(Some(thinking_level_name(level)));
2249            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2250            ctx.tui.request_render(false);
2251            return;
2252        }
2253        open_thinking_selector(
2254            &ctx.state,
2255            &ctx.editor_container,
2256            &ctx.editor,
2257            &ctx.tui,
2258            &ctx.lane,
2259            &ctx.model_catalog,
2260            &ctx.lane_model_id,
2261            &ctx.chat,
2262        );
2263    }
2264}
2265
2266struct ToolsCommand;
2267impl SlashCommand for ToolsCommand {
2268    fn name(&self) -> &'static str {
2269        "/tools"
2270    }
2271    fn description(&self) -> &'static str {
2272        "Toggle tools on/off"
2273    }
2274    fn execute(&self, ctx: &CommandContext, _args: &str) {
2275        open_tools_selector(
2276            &ctx.state,
2277            &ctx.editor_container,
2278            &ctx.editor,
2279            &ctx.tui,
2280            &ctx.lane,
2281            &ctx.chat,
2282        );
2283    }
2284}
2285
2286struct ImagesCommand;
2287impl SlashCommand for ImagesCommand {
2288    fn name(&self) -> &'static str {
2289        "/images"
2290    }
2291    fn description(&self) -> &'static str {
2292        "Toggle inline images"
2293    }
2294    fn execute(&self, ctx: &CommandContext, _args: &str) {
2295        open_images_selector(
2296            &ctx.state,
2297            &ctx.editor_container,
2298            &ctx.editor,
2299            &ctx.tui,
2300            &ctx.chat,
2301        );
2302    }
2303}
2304
2305struct SessionCommand;
2306impl SlashCommand for SessionCommand {
2307    fn name(&self) -> &'static str {
2308        "/session"
2309    }
2310    fn aliases(&self) -> &'static [&'static str] {
2311        &["/resume"]
2312    }
2313    fn description(&self) -> &'static str {
2314        "List saved sessions"
2315    }
2316    fn execute(&self, ctx: &CommandContext, _args: &str) {
2317        open_session_selector(
2318            &ctx.state,
2319            &ctx.editor_container,
2320            &ctx.editor,
2321            &ctx.tui,
2322            &ctx.cwd,
2323            &ctx.tx,
2324        );
2325    }
2326}
2327
2328struct ThemeCommand;
2329impl SlashCommand for ThemeCommand {
2330    fn name(&self) -> &'static str {
2331        "/theme"
2332    }
2333    fn description(&self) -> &'static str {
2334        "Choose a theme (selector)"
2335    }
2336    fn execute(&self, ctx: &CommandContext, args: &str) {
2337        let name = args.trim().to_ascii_lowercase();
2338        if !name.is_empty() {
2339            // /theme <name> — direct apply + persist (matches /settings Theme).
2340            let preset = match name.as_str() {
2341                "light" => ThemePreset::Light,
2342                "monochrome" => ThemePreset::Monochrome,
2343                "dark" => ThemePreset::Dark,
2344                _ => {
2345                    add_error_message(
2346                        &ctx.chat,
2347                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2348                    );
2349                    ctx.tui.request_render(false);
2350                    return;
2351                }
2352            };
2353            apply_theme_preset(preset);
2354            let mut settings = crate::settings::load_settings().unwrap_or_default();
2355            settings.theme = Some(name.clone());
2356            let _ = crate::settings::save_settings(&settings);
2357            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2358            ctx.tui.request_render(false);
2359            ctx.tui.render_now(true);
2360            return;
2361        }
2362        open_theme_selector(
2363            &ctx.state,
2364            &ctx.editor_container,
2365            &ctx.editor,
2366            &ctx.tui,
2367            &ctx.cwd,
2368            &ctx.package_resources,
2369        );
2370    }
2371}
2372
2373struct CompactCommand;
2374impl SlashCommand for CompactCommand {
2375    fn name(&self) -> &'static str {
2376        "/compact"
2377    }
2378    fn description(&self) -> &'static str {
2379        "Compact the conversation"
2380    }
2381    fn execute(&self, ctx: &CommandContext, _args: &str) {
2382        let _ = ctx.tx.send(TuiMessage::Compact);
2383    }
2384}
2385
2386struct CopyCommand;
2387impl SlashCommand for CopyCommand {
2388    fn name(&self) -> &'static str {
2389        "/copy"
2390    }
2391    fn description(&self) -> &'static str {
2392        "Copy last reply to clipboard"
2393    }
2394    fn execute(&self, ctx: &CommandContext, _args: &str) {
2395        let _ = ctx.tx.send(TuiMessage::Copy);
2396    }
2397}
2398
2399struct ExportCommand;
2400impl SlashCommand for ExportCommand {
2401    fn name(&self) -> &'static str {
2402        "/export"
2403    }
2404    fn description(&self) -> &'static str {
2405        "Export session to a markdown file"
2406    }
2407    fn execute(&self, ctx: &CommandContext, _args: &str) {
2408        let _ = ctx.tx.send(TuiMessage::ExportSession);
2409    }
2410}
2411
2412struct ForkCommand;
2413impl SlashCommand for ForkCommand {
2414    fn name(&self) -> &'static str {
2415        "/fork"
2416    }
2417    fn description(&self) -> &'static str {
2418        "Fork the session into a new one"
2419    }
2420    fn execute(&self, ctx: &CommandContext, _args: &str) {
2421        let _ = ctx.tx.send(TuiMessage::ForkSession);
2422    }
2423}
2424
2425/// `/clone` is the native Pi spelling for duplicating the current session.
2426/// Reuse the same durable fork path as `/fork`; both create a child session
2427/// and rebind the live harness to it.
2428struct CloneCommand;
2429impl SlashCommand for CloneCommand {
2430    fn name(&self) -> &'static str {
2431        "/clone"
2432    }
2433    fn description(&self) -> &'static str {
2434        "Duplicate the current session"
2435    }
2436    fn execute(&self, ctx: &CommandContext, _args: &str) {
2437        let _ = ctx.tx.send(TuiMessage::ForkSession);
2438    }
2439}
2440
2441struct TreeCommand;
2442impl SlashCommand for TreeCommand {
2443    fn name(&self) -> &'static str {
2444        "/tree"
2445    }
2446    fn description(&self) -> &'static str {
2447        "Navigate the current session tree"
2448    }
2449    fn execute(&self, ctx: &CommandContext, _args: &str) {
2450        let _ = ctx.tx.send(TuiMessage::OpenTree);
2451    }
2452}
2453
2454struct LoginCommand;
2455impl SlashCommand for LoginCommand {
2456    fn name(&self) -> &'static str {
2457        "/login"
2458    }
2459    fn description(&self) -> &'static str {
2460        "Save an Anthropic API key"
2461    }
2462    fn execute(&self, ctx: &CommandContext, args: &str) {
2463        let key = args.trim();
2464        if key.is_empty() {
2465            add_note_message(&ctx.chat, "Usage: /login <api-key>");
2466        } else {
2467            let result = crate::config::upsert_credential(
2468                "anthropic",
2469                crate::config::Credential::ApiKey {
2470                    key: Some(key.to_string()),
2471                    env: None,
2472                },
2473            );
2474            match result {
2475                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2476                Err(error) => {
2477                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2478                }
2479            }
2480        }
2481        ctx.tui.request_render(false);
2482    }
2483}
2484
2485struct LogoutCommand;
2486impl SlashCommand for LogoutCommand {
2487    fn name(&self) -> &'static str {
2488        "/logout"
2489    }
2490    fn description(&self) -> &'static str {
2491        "Remove saved Anthropic credentials"
2492    }
2493    fn execute(&self, ctx: &CommandContext, _args: &str) {
2494        match crate::config::delete_credential("anthropic") {
2495            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2496            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2497            Err(error) => {
2498                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2499            }
2500        }
2501        ctx.tui.request_render(false);
2502    }
2503}
2504
2505fn set_project_trust_for_command(
2506    cwd: &std::path::Path,
2507    value: Option<bool>,
2508) -> Result<(), crate::config::ConfigError> {
2509    crate::config::set_project_trust(cwd, value)
2510}
2511
2512struct TrustCommand;
2513impl SlashCommand for TrustCommand {
2514    fn name(&self) -> &'static str {
2515        "/trust"
2516    }
2517    fn description(&self) -> &'static str {
2518        "Trust the current project"
2519    }
2520    fn execute(&self, ctx: &CommandContext, args: &str) {
2521        let value = match args.trim().to_ascii_lowercase().as_str() {
2522            "" | "yes" | "y" | "true" => Some(true),
2523            "no" | "n" | "false" => Some(false),
2524            "clear" | "reset" | "none" => None,
2525            _ => {
2526                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2527                ctx.tui.request_render(false);
2528                return;
2529            }
2530        };
2531        match set_project_trust_for_command(&ctx.cwd, value) {
2532            Ok(()) => {
2533                let label = match value {
2534                    Some(true) => "trusted",
2535                    Some(false) => "untrusted",
2536                    None => "trust decision cleared",
2537                };
2538                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2539            }
2540            Err(error) => add_error_message(
2541                &ctx.chat,
2542                &format!("Could not save trust decision: {error}"),
2543            ),
2544        }
2545        ctx.tui.request_render(false);
2546    }
2547}
2548
2549struct NameCommand;
2550impl SlashCommand for NameCommand {
2551    fn name(&self) -> &'static str {
2552        "/name"
2553    }
2554    fn description(&self) -> &'static str {
2555        "Set session display name"
2556    }
2557    fn execute(&self, ctx: &CommandContext, args: &str) {
2558        let name = args.trim();
2559        if name.is_empty() {
2560            add_note_message(
2561                &ctx.chat,
2562                "Usage: /name <display name> — sets the current session's name.",
2563            );
2564            ctx.tui.request_render(false);
2565            return;
2566        }
2567        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2568    }
2569}
2570
2571struct ImportCommand;
2572impl SlashCommand for ImportCommand {
2573    fn name(&self) -> &'static str {
2574        "/import"
2575    }
2576    fn description(&self) -> &'static str {
2577        "Import a session file (path)"
2578    }
2579    fn execute(&self, ctx: &CommandContext, args: &str) {
2580        let path = args.trim();
2581        if path.is_empty() {
2582            add_note_message(
2583                &ctx.chat,
2584                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2585            );
2586            ctx.tui.request_render(false);
2587            return;
2588        }
2589        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2590    }
2591}
2592
2593struct SettingsCommand;
2594impl SlashCommand for SettingsCommand {
2595    fn name(&self) -> &'static str {
2596        "/settings"
2597    }
2598    fn description(&self) -> &'static str {
2599        "Open settings menu"
2600    }
2601    fn execute(&self, ctx: &CommandContext, _args: &str) {
2602        open_settings_selector(
2603            &ctx.state,
2604            &ctx.editor_container,
2605            &ctx.editor,
2606            &ctx.tui,
2607            &ctx.lane,
2608            &ctx.model_catalog,
2609            &ctx.lane_model_id,
2610            &ctx.chat,
2611            &ctx.cwd,
2612            &ctx.package_resources,
2613        );
2614    }
2615}
2616
2617struct ScopedModelsCommand;
2618impl SlashCommand for ScopedModelsCommand {
2619    fn name(&self) -> &'static str {
2620        "/scoped-models"
2621    }
2622    fn description(&self) -> &'static str {
2623        "Choose models for Ctrl+M cycling"
2624    }
2625    fn execute(&self, ctx: &CommandContext, _args: &str) {
2626        open_scoped_models_selector(
2627            &ctx.state,
2628            &ctx.editor_container,
2629            &ctx.editor,
2630            &ctx.tui,
2631            &ctx.model_catalog,
2632            &ctx.chat,
2633        );
2634    }
2635}
2636
2637struct ShareCommand;
2638impl SlashCommand for ShareCommand {
2639    fn name(&self) -> &'static str {
2640        "/share"
2641    }
2642    fn description(&self) -> &'static str {
2643        "Share session (gist via gh, or clipboard)"
2644    }
2645    fn execute(&self, ctx: &CommandContext, _args: &str) {
2646        let _ = ctx.tx.send(TuiMessage::ShareSession);
2647    }
2648}
2649
2650struct ArminCommand;
2651impl SlashCommand for ArminCommand {
2652    fn name(&self) -> &'static str {
2653        "/armin"
2654    }
2655    fn description(&self) -> &'static str {
2656        "??? (easter egg)"
2657    }
2658    fn execute(&self, ctx: &CommandContext, _args: &str) {
2659        crate::extras::add_armin(&ctx.chat);
2660        ctx.tui.request_render(false);
2661    }
2662}
2663
2664struct EarendilCommand;
2665impl SlashCommand for EarendilCommand {
2666    fn name(&self) -> &'static str {
2667        "/earendil"
2668    }
2669    fn description(&self) -> &'static str {
2670        "Announcement"
2671    }
2672    fn execute(&self, ctx: &CommandContext, _args: &str) {
2673        crate::extras::add_earendil(&ctx.chat);
2674        ctx.tui.request_render(false);
2675    }
2676}
2677
2678/// `/context` — lists discovered context files, skills, and prompt templates.
2679/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2680/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2681struct ContextCommand;
2682impl SlashCommand for ContextCommand {
2683    fn name(&self) -> &'static str {
2684        "/context"
2685    }
2686    fn visible(&self) -> bool {
2687        false
2688    }
2689    fn execute(&self, ctx: &CommandContext, _args: &str) {
2690        show_context_panel(&ctx.chat, &ctx.resources);
2691        ctx.tui.request_render(false);
2692    }
2693}
2694
2695/// `/reload` — re-run extension + resource discovery into the LIVE harness
2696/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2697/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2698/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2699/// The command itself runs on the blocking submit thread, so it can't drive
2700/// the async `reload_extension_resources` routine directly — it signals the main
2701/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2702/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2703/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2704/// self-unmapping race a synchronous plugin-initiated reload would have.)
2705struct ReloadCommand;
2706impl SlashCommand for ReloadCommand {
2707    fn name(&self) -> &'static str {
2708        "/reload"
2709    }
2710    fn description(&self) -> &'static str {
2711        "Reload extensions, skills, prompts"
2712    }
2713    fn execute(&self, ctx: &CommandContext, _args: &str) {
2714        // Signal the main loop. It owns the `&AgentHarness` borrow the
2715        // `reload_extension_resources` routine needs (the blocking submit thread
2716        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
2717        add_note_message(&ctx.chat, "Reloading extensions + resources…");
2718        ctx.tui.request_render(false);
2719        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2720    }
2721}
2722
2723/// Build the full command registry: active built-ins first (so they win on a
2724/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
2725/// commands are merged in separately by the autocomplete builder (they dispatch
2726/// via template expansion, not this registry).
2727fn build_builtin_registry() -> CommandRegistry {
2728    let mut r = CommandRegistry::new();
2729    r.register(Arc::new(HelpCommand));
2730    r.register(Arc::new(ClearChatCommand));
2731    r.register(Arc::new(ExitCommand));
2732    r.register(Arc::new(VersionCommand));
2733    r.register(Arc::new(ChangelogCommand));
2734    r.register(Arc::new(ModelCommand));
2735    r.register(Arc::new(ThinkingCommand));
2736    r.register(Arc::new(ToolsCommand));
2737    r.register(Arc::new(ImagesCommand));
2738    r.register(Arc::new(SessionCommand));
2739    r.register(Arc::new(ThemeCommand));
2740    r.register(Arc::new(CompactCommand));
2741    r.register(Arc::new(CopyCommand));
2742    r.register(Arc::new(HotkeysCommand));
2743    r.register(Arc::new(ArminCommand));
2744    r.register(Arc::new(EarendilCommand));
2745    r.register(Arc::new(ContextCommand));
2746    // Recognized but inert in v1 (one struct backs them all). The TS builtins
2747    // out of v1 scope; each carries a description so autocomplete surfaces its
2748    // existence even though running it reports "not supported".
2749    r.register(Arc::new(NameCommand));
2750    r.register(Arc::new(SettingsCommand));
2751    r.register(Arc::new(ScopedModelsCommand));
2752    r.register(Arc::new(ExportCommand));
2753    r.register(Arc::new(ImportCommand));
2754    r.register(Arc::new(ShareCommand));
2755    r.register(Arc::new(ForkCommand));
2756    r.register(Arc::new(CloneCommand));
2757    r.register(Arc::new(TreeCommand));
2758    r.register(Arc::new(TrustCommand));
2759    r.register(Arc::new(LoginCommand));
2760    r.register(Arc::new(LogoutCommand));
2761    r.register(Arc::new(ReloadCommand));
2762    r
2763}
2764
2765fn register_extension_commands(
2766    registry: &mut CommandRegistry,
2767    session: crate::session::ExtensionSessionCell,
2768) {
2769    let commands = session
2770        .lock()
2771        .ok()
2772        .and_then(|s| s.snapshot_arc())
2773        .map(|snap| snap.commands().to_vec())
2774        .unwrap_or_default();
2775    for command in commands {
2776        let name = if command.name.starts_with('/') {
2777            command.name.clone()
2778        } else {
2779            format!("/{}", command.name)
2780        };
2781        if registry.find(&name).is_some() {
2782            continue;
2783        }
2784        registry.register(Arc::new(ExtensionCommand {
2785            name,
2786            description: command.description,
2787            session: session.clone(),
2788        }));
2789    }
2790}
2791
2792fn register_js_extension_commands(
2793    registry: &mut CommandRegistry,
2794    session: Option<crate::js_extensions::JsExtensionSession>,
2795) {
2796    let Some(session) = session else {
2797        return;
2798    };
2799    for command in &session.commands {
2800        let name = if command.starts_with('/') {
2801            command.clone()
2802        } else {
2803            format!("/{command}")
2804        };
2805        if registry.find(&name).is_none() {
2806            registry.register(Arc::new(JsExtensionCommand {
2807                name,
2808                session: session.clone(),
2809            }));
2810        }
2811    }
2812}
2813
2814// ===========================================================================
2815// Channel + helpers
2816// ===========================================================================
2817
2818/// Message type for communication between the key/callback threads and the
2819/// main async loop.
2820enum TuiMessage {
2821    UserInput(String),
2822    OpenTree,
2823    NavigateTree(String),
2824    Exit,
2825    /// Clear the transcript (from `/clear`).
2826    ClearChat,
2827    /// Compact the conversation (from `/compact`).
2828    Compact,
2829    /// Copy the last assistant reply to the clipboard (from `/copy`).
2830    Copy,
2831    /// Hot-switch to another saved session (from the `/session` selector):
2832    /// the payload is the session id the selector's item value carried.
2833    SwitchSession(String),
2834    /// Export the current session to a markdown file (from `/export`).
2835    ExportSession,
2836    /// Fork the current session into a new one and switch to it (from `/fork`).
2837    ForkSession,
2838    /// Rename the current session (from `/name <name>`).
2839    SetSessionName(String),
2840    /// Import a JSONL session file into the session dir and switch to it
2841    /// (from `/import <path>`).
2842    ImportSession(String),
2843    /// Share the current session (`/share`): `gh gist create` when the gh CLI
2844    /// is available, otherwise copy the transcript to the clipboard.
2845    ShareSession,
2846    /// `/reload` — re-run extension + resource discovery into the live harness
2847    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
2848    /// mailbox) signal the main loop, which awaits
2849    /// `reload_extension_resources` on the async runtime.
2850    ReloadExtensions,
2851    /// Result returned after Ctrl+G edits a temporary file in an external
2852    /// editor. Handling it on the async loop keeps editor mutation single-
2853    /// threaded with the rest of the TUI state.
2854    ExternalEditorResult(Result<String, String>),
2855}
2856
2857/// Extract the concatenated text content from an assistant message (mirrors
2858/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
2859fn assistant_text(msg: &AssistantMessage) -> String {
2860    msg.content
2861        .iter()
2862        .filter_map(|c| match c {
2863            Content::Text(t) => Some(t.text.clone()),
2864            _ => None,
2865        })
2866        .collect()
2867}
2868
2869/// The user message's text (Text content or the text blocks of a Blocks
2870/// payload — images are skipped, consistent with the v1 text-only prompt path).
2871fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2872    msg.content
2873        .iter()
2874        .filter_map(|c| match c {
2875            Content::Text(t) => Some(t.text.clone()),
2876            _ => None,
2877        })
2878        .collect::<Vec<_>>()
2879        .join("\n")
2880}
2881
2882fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2883    match &msg.content {
2884        rpi_ai::types::UserContent::Text(s) => s.clone(),
2885        rpi_ai::types::UserContent::Blocks(blocks) => blocks
2886            .iter()
2887            .filter_map(|c| match c {
2888                Content::Text(t) => Some(t.text.clone()),
2889                _ => None,
2890            })
2891            .collect(),
2892    }
2893}
2894
2895/// Render the `/settings` panel: the saved settings.json values the session
2896/// honors, plus pointers to the commands that edit them (theme via `/theme`,
2897/// defaults via flags, cycle scope via `/scoped-models`). Kept for the
2898/// read-only summary; the interactive menu is [`open_settings_selector`].
2899fn show_settings_panel(chat: &Arc<Container>) {
2900    let s = crate::settings::load_settings().unwrap_or_default();
2901    let mut lines: Vec<String> = Vec::new();
2902    lines.push("⚙️  Saved settings:".into());
2903    lines.push(format!(
2904        "  Theme: {} (edit with /theme)",
2905        s.theme.as_deref().unwrap_or("(default)")
2906    ));
2907    lines.push(format!(
2908        "  Default model: {} (set at launch with --model)",
2909        s.default_model.as_deref().unwrap_or("(none)")
2910    ));
2911    lines.push(format!(
2912        "  Default thinking: {} (set at launch with --thinking)",
2913        s.default_thinking_level.as_deref().unwrap_or("(default)")
2914    ));
2915    match &s.scoped_models {
2916        Some(list) if !list.is_empty() => lines.push(format!(
2917            "  Ctrl+M cycle scope: {} (edit with /scoped-models)",
2918            list.join(", ")
2919        )),
2920        _ => lines.push("  Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
2921    }
2922    let body = lines.join("\n");
2923    container_note_block(chat, &body);
2924}
2925
2926/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
2927/// settings.json when present, otherwise every model. The current model is
2928/// always included (fallback) so cycling can never strand the user off-scope.
2929fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2930    let scoped = crate::settings::load_settings()
2931        .ok()
2932        .and_then(|s| s.scoped_models)
2933        .unwrap_or_default();
2934    if scoped.is_empty() {
2935        return catalog.to_vec();
2936    }
2937    let mut out: Vec<rpi_ai::Model> = catalog
2938        .iter()
2939        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2940        .cloned()
2941        .collect();
2942    // Never strand the user: if the current model isn't in scope, keep it.
2943    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2944        if let Some(cur) = catalog
2945            .iter()
2946            .find(|m| m.id.eq_ignore_ascii_case(current_id))
2947        {
2948            out.push(cur.clone());
2949        }
2950    }
2951    out
2952}
2953
2954/// Interactive `/settings` menu: a top-level selector over the editable
2955/// settings, each opening a sub-selector that applies the choice AND persists
2956/// it to settings.json (theme / default model / default thinking / cycle
2957/// scope). Selecting a menu item swaps the current selector for the
2958/// sub-selector (the `active_selector` slot is single, so each open replaces
2959/// the previous list); the sub-selector's cancel restores the editor.
2960fn open_settings_selector(
2961    state: &Arc<TuiState>,
2962    editor_container: &Arc<Container>,
2963    editor: &Arc<Editor>,
2964    tui: &Arc<TuiAltScreen>,
2965    lane: &Arc<dyn AgentLane>,
2966    catalog: &[rpi_ai::Model],
2967    lane_model_id: &str,
2968    chat: &Arc<Container>,
2969    cwd: &std::path::Path,
2970    package_resources: &Arc<crate::packages::PackageResources>,
2971) {
2972    let settings = crate::settings::load_settings().unwrap_or_default();
2973    let mut items: Vec<SelectItem> = Vec::new();
2974    items.push(
2975        SelectItem::new("theme", "Theme")
2976            .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
2977    );
2978    items.push(
2979        SelectItem::new("model", "Default model").with_description(
2980            &settings
2981                .default_model
2982                .clone()
2983                .unwrap_or_else(|| "(none)".into()),
2984        ),
2985    );
2986    items.push(
2987        SelectItem::new("thinking", "Default thinking").with_description(
2988            &settings
2989                .default_thinking_level
2990                .clone()
2991                .unwrap_or_else(|| "(default)".into()),
2992        ),
2993    );
2994    let scope_desc = match &settings.scoped_models {
2995        Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
2996        _ => "all models".to_string(),
2997    };
2998    items
2999        .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
3000    let list = Arc::new(SelectList::new(items, 10));
3001
3002    let state_sel = state.clone();
3003    let ec_sel = editor_container.clone();
3004    let editor_sel = editor.clone();
3005    let tui_sel = tui.clone();
3006    let lane_sel = lane.clone();
3007    let chat_sel = chat.clone();
3008    let catalog_sel = catalog.to_vec();
3009    let lane_model_sel = lane_model_id.to_string();
3010    let cwd_sel = cwd.to_path_buf();
3011    let package_resources_sel = package_resources.clone();
3012    list.on_select(Arc::new(move |item| {
3013        // Swap this menu for the sub-selector; each sub-selector saves its
3014        // choice to settings.json on select.
3015        match item.value.as_str() {
3016            "theme" => open_settings_theme_selector(
3017                &state_sel,
3018                &ec_sel,
3019                &editor_sel,
3020                &tui_sel,
3021                &chat_sel,
3022                &cwd_sel,
3023                &package_resources_sel,
3024            ),
3025            "model" => open_settings_model_selector(
3026                &state_sel,
3027                &ec_sel,
3028                &editor_sel,
3029                &tui_sel,
3030                &lane_sel,
3031                &catalog_sel,
3032                &lane_model_sel,
3033                &chat_sel,
3034            ),
3035            "thinking" => open_settings_thinking_selector(
3036                &state_sel,
3037                &ec_sel,
3038                &editor_sel,
3039                &tui_sel,
3040                &lane_sel,
3041                &catalog_sel,
3042                &lane_model_sel,
3043                &chat_sel,
3044            ),
3045            "scoped-models" => open_scoped_models_selector(
3046                &state_sel,
3047                &ec_sel,
3048                &editor_sel,
3049                &tui_sel,
3050                &catalog_sel,
3051                &chat_sel,
3052            ),
3053            _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3054        }
3055    }));
3056    let state_cancel = state.clone();
3057    let ec_cancel = editor_container.clone();
3058    let editor_cancel = editor.clone();
3059    let tui_cancel = tui.clone();
3060    list.on_cancel(Arc::new(move || {
3061        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3062    }));
3063
3064    open_selector(
3065        state,
3066        editor_container,
3067        editor,
3068        tui,
3069        list,
3070        SelectorKind::Settings,
3071    );
3072}
3073
3074/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3075fn open_settings_theme_selector(
3076    state: &Arc<TuiState>,
3077    editor_container: &Arc<Container>,
3078    editor: &Arc<Editor>,
3079    tui: &Arc<TuiAltScreen>,
3080    chat: &Arc<Container>,
3081    cwd: &std::path::Path,
3082    package_resources: &Arc<crate::packages::PackageResources>,
3083) {
3084    let mut items = vec![
3085        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3086        SelectItem::new("light", "Light").with_description("Light background"),
3087        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3088    ];
3089    if state.themes_enabled {
3090        for path in package_resources.theme_files() {
3091            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3092                items.push(SelectItem::new(name, name).with_description("Package theme"));
3093            }
3094        }
3095    }
3096    let list = Arc::new(SelectList::new(items, 10));
3097
3098    let state_sel = state.clone();
3099    let ec_sel = editor_container.clone();
3100    let editor_sel = editor.clone();
3101    let tui_sel = tui.clone();
3102    let chat_sel = chat.clone();
3103    let cwd_sel = cwd.to_path_buf();
3104    let package_resources_sel = package_resources.clone();
3105    list.on_select(Arc::new(move |item| {
3106        let preset = match item.value.as_str() {
3107            "light" => Some(ThemePreset::Light),
3108            "monochrome" => Some(ThemePreset::Monochrome),
3109            "dark" => Some(ThemePreset::Dark),
3110            name => {
3111                if state_sel.themes_enabled {
3112                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3113                        &cwd_sel,
3114                        name,
3115                        &package_resources_sel,
3116                    ) {
3117                        rpi_tui::global_theme_manager().set(custom.clone());
3118                        state_sel.theme_manager.set(custom);
3119                    }
3120                }
3121                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3122                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3123                tui_sel.render_now(true);
3124                return;
3125            }
3126        };
3127        let Some(preset) = preset else { return };
3128        apply_theme_preset(preset);
3129        state_sel.theme_manager.apply_preset(preset);
3130        let mut settings = crate::settings::load_settings().unwrap_or_default();
3131        settings.theme = Some(item.value.clone());
3132        let saved = crate::settings::save_settings(&settings);
3133        add_note_message(
3134            &chat_sel,
3135            &format!(
3136                "Theme set to {} (saved{})",
3137                item.label,
3138                if saved.is_ok() { "" } else { ", not saved" },
3139            ),
3140        );
3141        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3142        tui_sel.render_now(true);
3143    }));
3144    let state_cancel = state.clone();
3145    let ec_cancel = editor_container.clone();
3146    let editor_cancel = editor.clone();
3147    let tui_cancel = tui.clone();
3148    list.on_cancel(Arc::new(move || {
3149        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3150    }));
3151
3152    open_selector(
3153        state,
3154        editor_container,
3155        editor,
3156        tui,
3157        list,
3158        SelectorKind::Settings,
3159    );
3160}
3161
3162/// Choose the default model AND persist it (`/settings` → Default model):
3163/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3164/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3165fn open_settings_model_selector(
3166    state: &Arc<TuiState>,
3167    editor_container: &Arc<Container>,
3168    editor: &Arc<Editor>,
3169    tui: &Arc<TuiAltScreen>,
3170    lane: &Arc<dyn AgentLane>,
3171    catalog: &[rpi_ai::Model],
3172    lane_model_id: &str,
3173    chat: &Arc<Container>,
3174) {
3175    let items = model_selector_items(catalog, lane_model_id);
3176    if items.is_empty() {
3177        add_note_message(chat, "No models in the catalog.");
3178        tui.request_render(false);
3179        return;
3180    }
3181    let list = Arc::new(SelectList::new(items, 10));
3182
3183    let catalog_arc = catalog.to_vec();
3184    let state_sel = state.clone();
3185    let ec_sel = editor_container.clone();
3186    let editor_sel = editor.clone();
3187    let tui_sel = tui.clone();
3188    let chat_sel = chat.clone();
3189    let lane_sel = lane.clone();
3190    list.on_select(Arc::new(move |item| {
3191        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3192            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3193            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3194            return;
3195        };
3196        state_sel.set_current_model(&model);
3197        let lane = lane_sel.clone();
3198        tokio::spawn(async move {
3199            let _ = lane.set_model(model).await;
3200        });
3201        let mut settings = crate::settings::load_settings().unwrap_or_default();
3202        settings.default_model = Some(item.value.clone());
3203        let saved = crate::settings::save_settings(&settings);
3204        add_note_message(
3205            &chat_sel,
3206            &format!(
3207                "Default model set to {} (saved{}",
3208                short_model_name(&item.value),
3209                if saved.is_ok() { ")" } else { ", not saved)" },
3210            ),
3211        );
3212        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3213    }));
3214    let state_cancel = state.clone();
3215    let ec_cancel = editor_container.clone();
3216    let editor_cancel = editor.clone();
3217    let tui_cancel = tui.clone();
3218    list.on_cancel(Arc::new(move || {
3219        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3220    }));
3221
3222    open_selector(
3223        state,
3224        editor_container,
3225        editor,
3226        tui,
3227        list,
3228        SelectorKind::Settings,
3229    );
3230}
3231
3232/// Convert the authenticated runtime catalog into selector rows. Keep the
3233/// model id as the value so `/model <id>` and the selection callback share one
3234/// lookup path, while making the provider visible for OpenAI-compatible
3235/// gateways where the same model id may exist at multiple endpoints.
3236fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3237    let mut seen = std::collections::HashSet::new();
3238    catalog
3239        .iter()
3240        .filter(|m| {
3241            seen.insert((
3242                m.api.clone(),
3243                m.provider.to_ascii_lowercase(),
3244                m.id.to_ascii_lowercase(),
3245            ))
3246        })
3247        .map(|m| {
3248            let label = if m.name.is_empty() {
3249                short_model_name(&m.id)
3250            } else {
3251                m.name.clone()
3252            };
3253            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3254                && m.provider.eq_ignore_ascii_case("anthropic")
3255            {
3256                m.id.clone()
3257            } else {
3258                format!("{}/{}", m.provider, m.id)
3259            };
3260            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3261                " (current)"
3262            } else {
3263                ""
3264            };
3265            SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
3266        })
3267        .collect()
3268}
3269
3270/// Resolve a selector input by either bare model id or the qualified
3271/// `provider/model` identity shown for gateway models. This keeps manual
3272/// `/model ...` input consistent with the rows rendered by the selector.
3273fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3274    let (provider, id) = input
3275        .split_once('/')
3276        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3277        .map_or((None, input), |(provider, id)| (Some(provider), id));
3278    catalog
3279        .iter()
3280        .find(|model| {
3281            model.id.eq_ignore_ascii_case(id)
3282                && provider.map_or(true, |provider| {
3283                    model.provider.eq_ignore_ascii_case(provider)
3284                        || (provider.eq_ignore_ascii_case("anthropic")
3285                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3286                })
3287        })
3288        .cloned()
3289}
3290
3291/// Choose the default thinking level AND persist it (`/settings` → Default
3292/// thinking): applies live via `lane.set_thinking_level` and saves
3293/// `defaultThinkingLevel` to settings.json.
3294fn open_settings_thinking_selector(
3295    state: &Arc<TuiState>,
3296    editor_container: &Arc<Container>,
3297    editor: &Arc<Editor>,
3298    tui: &Arc<TuiAltScreen>,
3299    lane: &Arc<dyn AgentLane>,
3300    catalog: &[rpi_ai::Model],
3301    lane_model_id: &str,
3302    chat: &Arc<Container>,
3303) {
3304    let model = catalog
3305        .iter()
3306        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3307    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3308        .map(|m| m.supported_thinking_levels())
3309        .unwrap_or_else(|| {
3310            use rpi_ai::types::ThinkingLevel::*;
3311            vec![Off, Minimal, Low, Medium, High]
3312        });
3313    let mut items: Vec<SelectItem> = Vec::new();
3314    for lvl in &levels {
3315        let name = thinking_level_name(*lvl);
3316        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3317    }
3318    if items.is_empty() {
3319        add_note_message(chat, "This model has no supported thinking levels.");
3320        tui.request_render(false);
3321        return;
3322    }
3323    let list = Arc::new(SelectList::new(items, 10));
3324
3325    let state_sel = state.clone();
3326    let ec_sel = editor_container.clone();
3327    let editor_sel = editor.clone();
3328    let tui_sel = tui.clone();
3329    let chat_sel = chat.clone();
3330    let lane_sel = lane.clone();
3331    list.on_select(Arc::new(move |item| {
3332        let Some(level) = thinking_level_from_name(&item.value) else {
3333            add_note_message(
3334                &chat_sel,
3335                &format!("Unknown thinking level: {}.", item.label),
3336            );
3337            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3338            return;
3339        };
3340        let lane = lane_sel.clone();
3341        let footer_sel = state_sel.footer.clone();
3342        tokio::spawn(async move {
3343            let _ = lane.set_thinking_level(level).await;
3344        });
3345        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3346        let mut settings = crate::settings::load_settings().unwrap_or_default();
3347        settings.default_thinking_level = Some(item.value.clone());
3348        let saved = crate::settings::save_settings(&settings);
3349        add_note_message(
3350            &chat_sel,
3351            &format!(
3352                "Default thinking set to {} (saved{}",
3353                item.label,
3354                if saved.is_ok() { ")" } else { ", not saved)" },
3355            ),
3356        );
3357        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3358    }));
3359    let state_cancel = state.clone();
3360    let ec_cancel = editor_container.clone();
3361    let editor_cancel = editor.clone();
3362    let tui_cancel = tui.clone();
3363    list.on_cancel(Arc::new(move || {
3364        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3365    }));
3366
3367    open_selector(
3368        state,
3369        editor_container,
3370        editor,
3371        tui,
3372        list,
3373        SelectorKind::Settings,
3374    );
3375}
3376
3377/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3378/// item toggles it in the in-progress set (the selector stays open); Esc saves
3379/// the set to settings.json and closes. The active scoped set is echoed after
3380/// each toggle so the user sees the current selection.
3381fn open_scoped_models_selector(
3382    state: &Arc<TuiState>,
3383    editor_container: &Arc<Container>,
3384    editor: &Arc<Editor>,
3385    tui: &Arc<TuiAltScreen>,
3386    catalog: &[rpi_ai::Model],
3387    chat: &Arc<Container>,
3388) {
3389    if catalog.is_empty() {
3390        add_note_message(chat, "No models in the catalog.");
3391        tui.request_render(false);
3392        return;
3393    }
3394    // Seed the edit set from the saved scoped models.
3395    let seed: Vec<String> = crate::settings::load_settings()
3396        .ok()
3397        .and_then(|s| s.scoped_models)
3398        .unwrap_or_default();
3399    *state.scoped_edit.lock().unwrap() = Some(seed);
3400
3401    let mut items: Vec<SelectItem> = Vec::new();
3402    for m in catalog {
3403        items.push(SelectItem::new(&m.id, &m.id));
3404    }
3405    let list = Arc::new(SelectList::new(items, 10));
3406
3407    let state_sel = state.clone();
3408    let chat_sel = chat.clone();
3409    let tui_sel = tui.clone();
3410    list.on_select(Arc::new(move |item| {
3411        // Toggle the model in the in-progress set; the selector stays open.
3412        let mut set = state_sel.scoped_edit.lock().unwrap();
3413        let set = set.get_or_insert_with(Vec::new);
3414        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3415            set.remove(pos);
3416            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3417        } else {
3418            set.push(item.value.clone());
3419            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3420        }
3421        tui_sel.request_render(false);
3422    }));
3423    let state_cancel = state.clone();
3424    let ec_cancel = editor_container.clone();
3425    let editor_cancel = editor.clone();
3426    let tui_cancel = tui.clone();
3427    let chat_cancel = chat.clone();
3428    list.on_cancel(Arc::new(move || {
3429        // Save the edited set to settings.json and close.
3430        let set = state_cancel
3431            .scoped_edit
3432            .lock()
3433            .unwrap()
3434            .take()
3435            .unwrap_or_default();
3436        let mut settings = crate::settings::load_settings().unwrap_or_default();
3437        settings.scoped_models = if set.is_empty() {
3438            None
3439        } else {
3440            Some(set.clone())
3441        };
3442        match crate::settings::save_settings(&settings) {
3443            Ok(()) => {
3444                if set.is_empty() {
3445                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3446                } else {
3447                    add_note_message(
3448                        &chat_cancel,
3449                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3450                    );
3451                }
3452            }
3453            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3454        }
3455        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3456    }));
3457
3458    open_selector(
3459        state,
3460        editor_container,
3461        editor,
3462        tui,
3463        list,
3464        SelectorKind::ScopedModels,
3465    );
3466}
3467
3468/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3469/// PATH, create a gist of the exported markdown; otherwise fall back to the
3470/// clipboard (best-effort) and note the local path.
3471async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3472    use std::process::Stdio;
3473
3474    // Reuse the export builder for the transcript text.
3475    let tree = harness.session().view("main");
3476    let entries = match tree
3477        .find_entries(&EntryQuery {
3478            entry_type: None,
3479            custom_type: None,
3480            // Exports append entries top-to-bottom, so use chronological order
3481            // instead of the session query default (newest-first).
3482            order: Some(EntryOrder::OldestFirst),
3483            limit: None,
3484            cursor: None,
3485        })
3486        .await
3487    {
3488        Ok(e) => e,
3489        Err(e) => {
3490            add_error_message(chat, &format!("Could not read session: {e}"));
3491            return;
3492        }
3493    };
3494    let mut md = String::from("# Session\n\n");
3495    for e in entries {
3496        let Entry::Message(me) = e else { continue };
3497        match &me.message {
3498            AgentMessage::User(u) => {
3499                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3500            }
3501            AgentMessage::Assistant(a) => {
3502                let text = assistant_text(a);
3503                if !text.is_empty() {
3504                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3505                }
3506            }
3507            _ => {}
3508        }
3509    }
3510
3511    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3512    let gh = std::process::Command::new("gh")
3513        .arg("gist")
3514        .arg("create")
3515        .arg("--filename")
3516        .arg("session.md")
3517        .arg("-")
3518        .stdin(Stdio::piped())
3519        .stdout(Stdio::piped())
3520        .stderr(Stdio::null())
3521        .spawn();
3522    if let Ok(mut child) = gh {
3523        use std::io::Write;
3524        if let Some(mut stdin) = child.stdin.take() {
3525            let _ = stdin.write_all(md.as_bytes());
3526            let _ = stdin.flush();
3527        }
3528        let out = child.wait_with_output().ok();
3529        if let Some(out) = out {
3530            if out.status.success() {
3531                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3532                add_note_message(chat, &format!("Shared session: {url}"));
3533                return;
3534            }
3535        }
3536        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3537    } else {
3538        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3539    }
3540    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3541    if copy_to_clipboard(&md) {
3542        add_note_message(chat, "Session transcript copied to the clipboard.");
3543    } else {
3544        add_note_message(
3545            chat,
3546            "Clipboard unavailable — use /export to write the transcript to a file.",
3547        );
3548    }
3549}
3550
3551/// Export the current session to a markdown transcript file. Writes
3552/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
3553/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
3554/// Best-effort: failures surface as a chat note.
3555/// Export the current session to a markdown transcript file. Writes
3556/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
3557/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
3558/// Best-effort: failures surface as a chat note.
3559async fn export_session(harness: &AgentHarness, chat: &Arc<Container>, cwd: &std::path::Path) {
3560    let tree = harness.session().view("main");
3561    let entries = match tree
3562        .find_entries(&EntryQuery {
3563            entry_type: None,
3564            custom_type: None,
3565            // Keep exported entries in the same chronological order shown in
3566            // the transcript; the storage default is newest-first.
3567            order: Some(EntryOrder::OldestFirst),
3568            limit: None,
3569            cursor: None,
3570        })
3571        .await
3572    {
3573        Ok(e) => e,
3574        Err(e) => {
3575            add_error_message(chat, &format!("Could not read session: {e}"));
3576            return;
3577        }
3578    };
3579    let name = tree.get_name().await.ok().flatten().unwrap_or_default();
3580    let id = tree
3581        .get_leaf_id()
3582        .await
3583        .ok()
3584        .flatten()
3585        .unwrap_or_else(|| "session".to_string());
3586    let mut md = String::from("# Session\n\n");
3587    for e in entries {
3588        let Entry::Message(me) = e else { continue };
3589        match &me.message {
3590            AgentMessage::User(u) => {
3591                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3592            }
3593            AgentMessage::Assistant(a) => {
3594                let text = assistant_text(a);
3595                if !text.is_empty() {
3596                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3597                }
3598            }
3599            _ => {}
3600        }
3601    }
3602    let file_name = if name.is_empty() {
3603        format!("{id}.md")
3604    } else {
3605        format!("{name}.md")
3606    };
3607    let path = cwd.join(&file_name);
3608    match std::fs::write(&path, md) {
3609        Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
3610        Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
3611    }
3612}
3613
3614/// Fork the current session into a new JSONL session and switch to it (TS
3615/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3616/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3617/// the harness backing and renders the (empty-ish) fork transcript.
3618/// Hot-switch the harness to another saved session: abort any in-flight run,
3619/// open the target session file, swap the durable backing, and re-render the
3620/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3621/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3622/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3623async fn switch_to_session(
3624    harness: &AgentHarness,
3625    lane: &Arc<dyn AgentLane>,
3626    id: &str,
3627    cwd: &std::path::Path,
3628    chat: &Arc<Container>,
3629    state: &Arc<TuiState>,
3630) -> bool {
3631    if *state.status.lock().unwrap() == RunStatus::Working {
3632        state.set_status(RunStatus::Aborting);
3633        let _ = lane.abort().await;
3634    }
3635    let cwd_str = cwd.to_string_lossy().to_string();
3636    match crate::session::open_session_by_id(id, &cwd_str).await {
3637        Ok(new_session) => {
3638            let _ = harness.set_session(new_session).await;
3639            chat.clear();
3640            add_welcome_message(chat);
3641            render_session_history(
3642                harness,
3643                chat,
3644                state.markdown_transformer(),
3645                Some(state.extension_session.clone()),
3646            )
3647            .await;
3648            state.set_status(RunStatus::Idle);
3649            add_note_message(chat, &format!("Switched to session {id}."));
3650            true
3651        }
3652        Err(e) => {
3653            state.set_status(RunStatus::Idle);
3654            add_error_message(chat, &format!("Could not open session {id}: {e}"));
3655            false
3656        }
3657    }
3658}
3659
3660/// `/import <path>`: copy a JSONL session file into the default session dir,
3661/// then hot-switch to it (the file name becomes its id — matching the
3662/// selector/`open_session_by_id` containment rules).
3663async fn import_session(
3664    harness: &AgentHarness,
3665    lane: &Arc<dyn AgentLane>,
3666    path: &str,
3667    cwd: &std::path::Path,
3668    chat: &Arc<Container>,
3669    state: &Arc<TuiState>,
3670) {
3671    use std::path::Path as FsPath;
3672
3673    let src = FsPath::new(path);
3674    if !src.is_file() {
3675        add_error_message(chat, &format!("Import source not found: {path}"));
3676        return;
3677    }
3678    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3679        add_error_message(chat, "Import source has no file name.");
3680        return;
3681    };
3682    if !fname.ends_with(".jsonl") {
3683        add_error_message(chat, "Import source must be a .jsonl session file.");
3684        return;
3685    }
3686    let dir = crate::session::default_session_dir(cwd);
3687    if let Err(e) = std::fs::create_dir_all(&dir) {
3688        add_error_message(chat, &format!("Could not create session dir: {e}"));
3689        return;
3690    }
3691    let dest = dir.join(fname);
3692    match std::fs::copy(src, &dest) {
3693        Ok(_) => {
3694            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3695            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3696                add_note_message(chat, &format!("Imported session from {path}"));
3697            }
3698        }
3699        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3700    }
3701}
3702
3703async fn fork_session(
3704    harness: &AgentHarness,
3705    cwd: &std::path::Path,
3706    chat: &Arc<Container>,
3707    state: &Arc<TuiState>,
3708) {
3709    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3710    use rpi_tools::FileSystem;
3711
3712    let cwd_str = cwd.to_string_lossy().to_string();
3713    let dir = crate::session::default_session_dir(cwd);
3714    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3715    let fs: Arc<dyn FileSystem> = env.clone();
3716    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3717        fs,
3718        sessions_root: dir.to_string_lossy().into_owned(),
3719        clock: Arc::new(rpi_harness::session::memory::SystemClock),
3720        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3721    });
3722    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
3723    // it from the session list by the current session's id.
3724    let id = harness.session().storage().metadata().id.clone();
3725    let metas = match crate::session::list_session_metadata(&cwd_str).await {
3726        Ok(m) => m,
3727        Err(e) => {
3728            add_error_message(chat, &format!("Could not list sessions: {e}"));
3729            return;
3730        }
3731    };
3732    let Some(source) = metas.iter().find(|m| m.id == id) else {
3733        add_error_message(chat, &format!("Current session {id} not found on disk."));
3734        return;
3735    };
3736    let fork_storage = match repo
3737        .fork_typed(
3738            source,
3739            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3740                id: None,
3741                parent_session_id: Some(source.id.clone()),
3742                cwd: cwd_str.clone(),
3743                metadata: None,
3744            },
3745            &rpi_harness::session::types::ForkOptions::default(),
3746        )
3747        .await
3748    {
3749        Ok(s) => s,
3750        Err(e) => {
3751            add_error_message(chat, &format!("Could not fork session: {e}"));
3752            return;
3753        }
3754    };
3755    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3756    let _ = harness.set_session(new_session).await;
3757    chat.clear();
3758    add_welcome_message(chat);
3759    render_session_history(
3760        harness,
3761        chat,
3762        state.markdown_transformer(),
3763        Some(state.extension_session.clone()),
3764    )
3765    .await;
3766    state.set_status(RunStatus::Idle);
3767    add_note_message(chat, "Forked into a new session.");
3768}
3769
3770/// Render the restored session's prior transcript (user + assistant messages)
3771/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
3772/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
3773/// any session read failure just starts with an empty transcript.
3774///
3775/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
3776/// the identity path. Each restored assistant component installs it so replayed
3777/// history renders through the same `register_markdown_transformer` handlers
3778/// the live stream does.
3779async fn render_session_history(
3780    harness: &AgentHarness,
3781    chat: &Arc<Container>,
3782    transformer: Option<MarkdownTransformer>,
3783    extension_session: Option<crate::session::ExtensionSessionCell>,
3784) {
3785    let tree = harness.session().view("main");
3786    let entries = match tree
3787        .find_entries(&EntryQuery {
3788            entry_type: None,
3789            custom_type: None,
3790            // Session queries default to newest-first for selectors and
3791            // pagination. The transcript appends children top-to-bottom, so
3792            // restored history must explicitly be chronological.
3793            order: Some(EntryOrder::OldestFirst),
3794            limit: None,
3795            cursor: None,
3796        })
3797        .await
3798    {
3799        Ok(e) => e,
3800        Err(_) => return,
3801    };
3802    let mut rendered_any = false;
3803    for e in entries {
3804        match e {
3805            Entry::Message(me) => match &me.message {
3806                AgentMessage::User(u) => {
3807                    add_user_message(chat, &user_message_text(u));
3808                    rendered_any = true;
3809                }
3810                AgentMessage::Assistant(a) => {
3811                    let comp = Arc::new(AssistantMessageComponent::new(
3812                        AssistantMessageOptions::default(),
3813                    ));
3814                    if let Some(t) = &transformer {
3815                        comp.set_markdown_transformer(Some(t.clone()));
3816                    }
3817                    comp.update_blocks(&assistant_blocks(a));
3818                    chat.add_child(comp);
3819                    // Single trailing spacer: the next transcript entry (user or
3820                    // assistant) follows one blank line below.
3821                    chat.add_child(Arc::new(Spacer::new(1)));
3822                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3823                        add_note_message(chat, &text);
3824                    }
3825                    if let Some(error) = assistant_error_text(a) {
3826                        add_error_message(chat, &error);
3827                    }
3828                    rendered_any = true;
3829                }
3830                AgentMessage::ToolResult(result) => {
3831                    // Tool results are persisted as separate message entries,
3832                    // not as part of the assistant text. Restore them as
3833                    // completed tool panels so resumed sessions show the
3834                    // command output as well as the user's prompts.
3835                    let comp = Arc::new(ToolExecutionComponent::new(
3836                        &result.tool_name,
3837                        "",
3838                    ));
3839                    comp.set_result(&tool_result_message_text(result), result.is_error);
3840                    chat.add_child(comp);
3841                    chat.add_child(Arc::new(Spacer::new(1)));
3842                    rendered_any = true;
3843                }
3844                AgentMessage::Custom(custom) => {
3845                    if let Some(session) = &extension_session {
3846                        if let Some(component) = extension_message_component(
3847                            session,
3848                            &custom.role,
3849                            &serde_json::json!({
3850                                "customType": custom.role,
3851                                "content": custom.content,
3852                                "details": custom.data,
3853                            }),
3854                            transformer.clone(),
3855                        ) {
3856                            chat.add_child(component);
3857                            chat.add_child(Arc::new(Spacer::new(1)));
3858                            rendered_any = true;
3859                            continue;
3860                        }
3861                    }
3862                    add_note_message(chat, &custom_message_fallback(&custom));
3863                    rendered_any = true;
3864                }
3865            },
3866            Entry::Compaction(compaction) => {
3867                add_note_message(
3868                    chat,
3869                    &format!(
3870                        "Compacted {} tokens: {}",
3871                        compaction.tokens_before, compaction.summary
3872                    ),
3873                );
3874                rendered_any = true;
3875            }
3876            Entry::BranchSummary(summary) => {
3877                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3878                rendered_any = true;
3879            }
3880            Entry::Custom(custom) => {
3881                let rendered = extension_session.as_ref().and_then(|session| {
3882                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
3883                });
3884                if let Some(component) = rendered {
3885                    chat.add_child(component);
3886                    chat.add_child(Arc::new(Spacer::new(1)));
3887                    rendered_any = true;
3888                } else if let Some(text) =
3889                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3890                {
3891                    add_note_message(chat, &text);
3892                    rendered_any = true;
3893                }
3894            }
3895            Entry::ModelChange(change) => {
3896                add_note_message(
3897                    chat,
3898                    &format!("Model changed to {}:{}", change.provider, change.model_id),
3899                );
3900                rendered_any = true;
3901            }
3902            Entry::ThinkingLevel(change) => {
3903                add_note_message(
3904                    chat,
3905                    &format!("Thinking level: {:?}", change.thinking_level),
3906                );
3907                rendered_any = true;
3908            }
3909            Entry::ActiveTools(change) => {
3910                add_note_message(
3911                    chat,
3912                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
3913                );
3914                rendered_any = true;
3915            }
3916        }
3917    }
3918    if rendered_any {
3919        // No trailing spacer here — each entry already adds its own trailing
3920        // Spacer(1), so an extra would double the bottom gap.
3921    }
3922}
3923
3924fn invoke_extension_renderer(
3925    session: &crate::session::ExtensionSessionCell,
3926    kind: rpi_extensions::RegisteredRendererKind,
3927    payload: &serde_json::Value,
3928) -> Option<serde_json::Value> {
3929    let snapshot = session.lock().ok()?.snapshot_arc()?;
3930    let input = serde_json::to_string(payload).ok()?;
3931    for renderer in snapshot.renderers_of(kind) {
3932        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3933            let mut out = rpi_plugin_sdk::StbString::empty();
3934            let rc = (renderer.render_fn)(
3935                rpi_plugin_sdk::StbStringRef::from_str(&input),
3936                &mut out as *mut rpi_plugin_sdk::StbString,
3937                renderer.user_data,
3938            );
3939            let text = if rc == 0 {
3940                Some(out.to_string_lossy())
3941            } else {
3942                None
3943            };
3944            out.free_with(Some(renderer.plugin_free_string));
3945            text
3946        }))
3947        .ok()
3948        .flatten();
3949        let Some(text) = outcome else { continue };
3950        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3951            return Some(value);
3952        }
3953    }
3954    None
3955}
3956
3957fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3958    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3959        let text = lines
3960            .iter()
3961            .filter_map(|line| line.as_str())
3962            .collect::<Vec<_>>()
3963            .join("\n");
3964        return Some(Arc::new(Text::new(text, 0, 0)));
3965    }
3966    let text = value.get("text").and_then(|v| v.as_str())?;
3967    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3968        let component = Arc::new(AssistantMessageComponent::new(
3969            AssistantMessageOptions::default(),
3970        ));
3971        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3972        Some(component)
3973    } else {
3974        Some(Arc::new(Text::new(text, 0, 0)))
3975    }
3976}
3977
3978fn extension_message_component(
3979    session: &crate::session::ExtensionSessionCell,
3980    custom_type: &str,
3981    payload: &serde_json::Value,
3982    transformer: Option<MarkdownTransformer>,
3983) -> Option<Arc<dyn rpi_tui::Component>> {
3984    let value = invoke_extension_renderer(
3985        session,
3986        rpi_extensions::RegisteredRendererKind::Message,
3987        payload,
3988    )?;
3989    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3990        let text = value.get("text").and_then(|v| v.as_str())?;
3991        let component = Arc::new(AssistantMessageComponent::new(
3992            AssistantMessageOptions::default(),
3993        ));
3994        if let Some(transformer) = transformer {
3995            component.set_markdown_transformer(Some(transformer));
3996        }
3997        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3998        return Some(component);
3999    }
4000    extension_text_component(&value)
4001        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4002}
4003
4004/// Render usage from a completed assistant message through the registered
4005/// message renderers. Hosts without a token-usage renderer return `None`.
4006fn extension_usage_text(
4007    session: Option<&crate::session::ExtensionSessionCell>,
4008    usage: &rpi_ai::types::Usage,
4009) -> Option<String> {
4010    let session = session?;
4011    let payload = serde_json::json!({
4012        "customType": "token-usage",
4013        "usage": usage,
4014    });
4015    let value = invoke_extension_renderer(
4016        session,
4017        rpi_extensions::RegisteredRendererKind::Message,
4018        &payload,
4019    )?;
4020    value
4021        .get("text")
4022        .and_then(|value| value.as_str())
4023        .filter(|text| !text.trim().is_empty())
4024        .map(ToOwned::to_owned)
4025}
4026
4027fn extension_entry_component(
4028    session: &crate::session::ExtensionSessionCell,
4029    custom_type: &str,
4030    data: Option<serde_json::Value>,
4031) -> Option<Arc<dyn rpi_tui::Component>> {
4032    let payload = serde_json::json!({
4033        "customType": custom_type,
4034        "data": data,
4035    });
4036    let value = invoke_extension_renderer(
4037        session,
4038        rpi_extensions::RegisteredRendererKind::Entry,
4039        &payload,
4040    )?;
4041    extension_text_component(&value)
4042}
4043
4044/// Project an assistant message's content into the provider-free
4045/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4046/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4047/// are rendered by their own components in the transcript.
4048/// Whether startup intentionally opened a session that already has history.
4049fn launch_restores_history(args: &Args) -> bool {
4050    args.continue_session
4051        || args.resume
4052        || args.session.is_some()
4053        || args.session_id.is_some()
4054        || args.fork.is_some()
4055}
4056
4057fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4058    msg.content
4059        .iter()
4060        .filter_map(|c| match c {
4061            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4062            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4063            Content::Image(image) => base64::engine::general_purpose::STANDARD
4064                .decode(&image.data)
4065                .ok()
4066                .filter(|data| !data.is_empty())
4067                .map(AssistantBlock::Image),
4068            _ => None,
4069        })
4070        .collect()
4071}
4072
4073fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4074    let content = custom
4075        .content
4076        .iter()
4077        .filter_map(|item| match item {
4078            Content::Text(text) => Some(text.text.as_str()),
4079            _ => None,
4080        })
4081        .collect::<Vec<_>>()
4082        .join("\n");
4083    if content.is_empty() {
4084        format!("{}: {}", custom.role, custom.data)
4085    } else {
4086        format!("{}: {}", custom.role, content)
4087    }
4088}
4089
4090/// The name displayed for a model id (last path segment / after the final
4091/// `:`), to keep the footer compact.
4092fn short_model_name(id: &str) -> String {
4093    id.rsplit([':', '/'])
4094        .next()
4095        .filter(|s| !s.is_empty())
4096        .unwrap_or(id)
4097        .to_string()
4098}
4099
4100// ===========================================================================
4101// Streaming run status
4102// ===========================================================================
4103
4104/// The live status of the agent run, fed to the footer + status slot.
4105#[derive(Clone, Copy, PartialEq, Eq)]
4106enum RunStatus {
4107    Idle,
4108    Working,
4109    Aborting,
4110}
4111
4112/// Which selector overlay (if any) is currently swapped into the editor slot.
4113#[derive(Clone, Copy, PartialEq, Eq)]
4114enum SelectorKind {
4115    /// `/model` — available models (live switch via `lane.set_model`).
4116    Model,
4117    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4118    Thinking,
4119    /// `/tools` — toggle builtin tools on/off.
4120    Tools,
4121    /// `/images` — toggle inline image rendering.
4122    Images,
4123    /// `/session` — browse and switch saved JSONL sessions.
4124    Session,
4125    /// `/theme` — dark / light / monochrome presets applied live.
4126    Theme,
4127    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
4128    ScopedModels,
4129    /// `/settings` — interactive settings menu (and its sub-selectors).
4130    Settings,
4131    /// `/tree` — navigate to an existing entry in the current session.
4132    Tree,
4133    /// Extension-provided selector; uses the same keyboard contract.
4134    Extension,
4135}
4136
4137/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4138/// and the render-tick task.
4139struct TuiState {
4140    /// The in-flight streaming assistant message (cleared on finalize).
4141    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4142    /// Tool-execution components keyed by `tool_call_id`.
4143    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4144    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4145    /// generic tool map so bash output streams into a `BashExecutionComponent`
4146    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4147    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4148    /// Whether package/custom themes may be selected in this session.
4149    themes_enabled: bool,
4150    /// Persisted display preference toggled by Ctrl+T.
4151    hide_thinking: std::sync::Mutex<bool>,
4152    /// Global tool-output expansion preference toggled by Ctrl+O.
4153    tool_outputs_expanded: std::sync::Mutex<bool>,
4154    /// Whether the native-style terminal progress indicator is enabled.
4155    show_terminal_progress: bool,
4156    /// Run status for the status indicator + interrupt routing.
4157    status: std::sync::Mutex<RunStatus>,
4158    /// Cancellation signal for the short phase that starts the persistent JS
4159    /// host and runs `before_agent_start`. The key thread can trigger this
4160    /// directly while the async message loop is awaiting the blocking worker.
4161    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4162    /// The footer, updated live by the drain task.
4163    footer: Arc<FooterComponent>,
4164    /// The status-container (status slot in the dock) — cleared/filled with a
4165    /// loader while a run is active.
4166    status_container: Arc<Container>,
4167    /// The chat transcript container.
4168    chat_container: Arc<Container>,
4169    /// The active loader shown while `Working`.
4170    loader: Arc<Loader>,
4171    /// The last finalized assistant text (for `/copy`). Updated by the drain
4172    /// task on `MessageEnd` / `AgentEnd`.
4173    last_assistant_text: std::sync::Mutex<String>,
4174    /// The active selector overlay, swapped into the editor slot. `Some` while
4175    /// a selector is open; the key loop routes to it first and restores the
4176    /// editor on done/cancel.
4177    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
4178    /// Extension-provided editor currently occupying the input slot.
4179    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4180    /// Single-line input currently occupying the input slot for an extension.
4181    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4182    /// Callback used to resolve an extension dialog with a cancellation action.
4183    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4184    /// The autocomplete manager (slash + @file providers) consulted on every
4185    /// editor keystroke.
4186    autocomplete: AutocompleteManager,
4187    /// The container rendered above the editor holding the live autocomplete
4188    /// suggestion list (cleared when there are no suggestions).
4189    autocomplete_container: Arc<Container>,
4190    /// Maximum number of autocomplete rows rendered above the editor.
4191    autocomplete_max_visible: usize,
4192    /// Images queued from clipboard paste and attached to the next prompt.
4193    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4194    /// The owned theme manager — `/theme` applies presets here. The global
4195    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4196    /// only way to apply a preset at runtime.
4197    theme_manager: Arc<ThemeManager>,
4198    /// The alt-screen handle, held so `set_status` can reflect run state in the
4199    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4200    /// that never call `set_status` with a title.
4201    tui: Option<Arc<TuiAltScreen>>,
4202    /// The model id currently shown in the footer + used as the Ctrl+M
4203    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
4204    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4205    current_model_id: std::sync::Mutex<String>,
4206    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4207    /// even though image wiring is minimal this pass — the flag is consulted
4208    /// where images would be shown and echoed back by `/images`.
4209    show_images: std::sync::Mutex<bool>,
4210    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4211    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4212    history: std::sync::Mutex<Vec<String>>,
4213    /// Browse index while recalling history: -1 = not browsing, 0 = most
4214    /// recent, 1 = older, … Reset to -1 on every submit.
4215    history_index: std::sync::Mutex<isize>,
4216    /// The editor text captured when entering browse mode, restored when the
4217    /// user navigates back past the newest entry (TS `historyDraft`).
4218    history_draft: std::sync::Mutex<Option<String>>,
4219    /// The previous turn's input token count, used by the cache-miss notice:
4220    /// a large input that reads nothing from cache after an established prefix
4221    /// means the prefix was re-billed (simplified `maybeShowCacheMissNotice`).
4222    last_input_tokens: std::sync::Mutex<i64>,
4223    /// The in-progress scoped-models selection while the `/scoped-models`
4224    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4225    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4226    /// B5e: the live assistant-markdown transformer, built from the current
4227    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4228    /// when no markdown transformers are registered (identity render path).
4229    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4230    /// closure no-ops once its snapshot's `active` flag flips false) and
4231    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4232    /// transform takes effect on the visible streaming message immediately.
4233    /// New assistant components pick up whatever closure is current at
4234    /// construction time via [`install_markdown_transformer`].
4235    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4236    /// Live extension registry used by message/entry renderer dispatch.
4237    extension_session: crate::session::ExtensionSessionCell,
4238}
4239
4240/// How many submitted messages are kept for ↑ recall (mirrors the TS
4241/// editor's 100-entry cap).
4242const HISTORY_LIMIT: usize = 100;
4243
4244/// A turn with at least this many input tokens is worth a cache-miss notice
4245/// when nothing was read from cache (matches the TS 20k threshold).
4246const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
4247
4248/// Keep a few rows of overlap so page scrolling preserves visual context,
4249/// matching the upstream fullscreen viewport behavior.
4250const PAGE_SCROLL_OVERLAP: usize = 4;
4251
4252/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4253/// transcript one physical row at a time. Three lines stays precise while
4254/// avoiding the sluggish feel of the previous implementation.
4255const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4256
4257/// Parse the compact key notation used by native Pi settings (for example
4258/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4259fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4260    let mut modifiers = KeyModifiers::NONE;
4261    let mut key = None;
4262    for part in value.trim().to_ascii_lowercase().split('+') {
4263        match part {
4264            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4265            "shift" => modifiers |= KeyModifiers::SHIFT,
4266            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4267            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4268            part if !part.is_empty() => key = Some(part.to_string()),
4269            _ => {}
4270        }
4271    }
4272    let key = key?;
4273    let code = match key.as_str() {
4274        "esc" | "escape" => KeyCode::Esc,
4275        "enter" | "return" => KeyCode::Enter,
4276        "tab" => {
4277            if modifiers.contains(KeyModifiers::SHIFT) {
4278                return Some(rpi_tui::KeyCombo::new(
4279                    KeyCode::BackTab,
4280                    modifiers & !KeyModifiers::SHIFT,
4281                ));
4282            }
4283            KeyCode::Tab
4284        }
4285        "backspace" | "back" => KeyCode::Backspace,
4286        "delete" | "del" => KeyCode::Delete,
4287        "up" | "arrowup" => KeyCode::Up,
4288        "down" | "arrowdown" => KeyCode::Down,
4289        "left" | "arrowleft" => KeyCode::Left,
4290        "right" | "arrowright" => KeyCode::Right,
4291        "home" => KeyCode::Home,
4292        "end" => KeyCode::End,
4293        "pageup" | "page-up" => KeyCode::PageUp,
4294        "pagedown" | "page-down" => KeyCode::PageDown,
4295        "space" => KeyCode::Char(' '),
4296        "f1" => KeyCode::F(1),
4297        "f2" => KeyCode::F(2),
4298        "f3" => KeyCode::F(3),
4299        "f4" => KeyCode::F(4),
4300        "f5" => KeyCode::F(5),
4301        "f6" => KeyCode::F(6),
4302        "f7" => KeyCode::F(7),
4303        "f8" => KeyCode::F(8),
4304        "f9" => KeyCode::F(9),
4305        "f10" => KeyCode::F(10),
4306        "f11" => KeyCode::F(11),
4307        "f12" => KeyCode::F(12),
4308        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4309        _ => return None,
4310    };
4311    Some(rpi_tui::KeyCombo::new(code, modifiers))
4312}
4313
4314fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4315    let mut bindings = rpi_tui::Keybindings::new();
4316    let settings = crate::settings::load_settings().unwrap_or_default();
4317    let Some(overrides) = settings.keybindings else {
4318        rpi_tui::set_keybindings(bindings.clone());
4319        return Arc::new(bindings);
4320    };
4321    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4322        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4323        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4324        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4325        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4326        (
4327            "app.model.cycleForward",
4328            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4329        ),
4330        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4331        (
4332            "app.thinking.toggle",
4333            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4334        ),
4335        (
4336            "app.editor.external",
4337            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4338        ),
4339        (
4340            "app.thinking.cycle",
4341            rpi_tui::keybindings::keys::THINKING_CYCLE,
4342        ),
4343        (
4344            "app.clipboard.pasteImage",
4345            rpi_tui::keybindings::keys::PASTE_IMAGE,
4346        ),
4347    ];
4348    for (name, id) in known {
4349        let Some(value) = overrides.get(*name) else {
4350            continue;
4351        };
4352        let values: Vec<String> = match value {
4353            serde_json::Value::String(value) => vec![value.clone()],
4354            serde_json::Value::Array(values) => values
4355                .iter()
4356                .filter_map(|v| v.as_str().map(str::to_string))
4357                .collect(),
4358            serde_json::Value::Null => Vec::new(),
4359            _ => continue,
4360        };
4361        let combos: Vec<_> = values
4362            .iter()
4363            .filter_map(|value| parse_configured_key(value))
4364            .collect();
4365        if values.is_empty() || !combos.is_empty() {
4366            bindings.set(id, combos);
4367        }
4368    }
4369    rpi_tui::set_keybindings(bindings.clone());
4370    Arc::new(bindings)
4371}
4372
4373fn keybinding_matches(
4374    bindings: &rpi_tui::Keybindings,
4375    event: &crossterm::event::KeyEvent,
4376    id: rpi_tui::KeybindingId,
4377) -> bool {
4378    if bindings.matches(event, id) {
4379        return true;
4380    }
4381    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4382    // plus Shift on others. Treat both forms as the same configured action.
4383    if event.code == KeyCode::BackTab {
4384        let normalized =
4385            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4386        bindings.matches(&normalized, id)
4387    } else {
4388        false
4389    }
4390}
4391
4392fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4393    last.is_some_and(|previous| {
4394        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4395    })
4396}
4397
4398fn transcript_page_size(viewport_height: usize) -> i32 {
4399    viewport_height
4400        .saturating_sub(PAGE_SCROLL_OVERLAP)
4401        .max(1)
4402        .min(i32::MAX as usize) as i32
4403}
4404
4405fn should_dispatch_key(kind: KeyEventKind) -> bool {
4406    kind != KeyEventKind::Release
4407}
4408
4409/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4410fn format_tokens(n: i64) -> String {
4411    if n >= 1_000_000 {
4412        format!("{:.1}M", n as f64 / 1_000_000.0)
4413    } else if n >= 1_000 {
4414        format!("{:.1}K", n as f64 / 1_000.0)
4415    } else {
4416        n.to_string()
4417    }
4418}
4419
4420/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4421/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4422/// resets the browse state so a fresh prompt never resumes mid-history.
4423fn push_history(state: &Arc<TuiState>, text: &str) {
4424    let trimmed = text.trim().to_string();
4425    if trimmed.is_empty() {
4426        return;
4427    }
4428    let mut history = state.history.lock().unwrap();
4429    if history.first() == Some(&trimmed) {
4430        return;
4431    }
4432    history.insert(0, trimmed);
4433    history.truncate(HISTORY_LIMIT);
4434    *state.history_index.lock().unwrap() = -1;
4435    *state.history_draft.lock().unwrap() = None;
4436}
4437
4438/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
4439/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
4440/// current editor text as the draft; navigating back past the newest entry
4441/// restores that draft.
4442fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4443    let history = state.history.lock().unwrap();
4444    if history.is_empty() {
4445        return;
4446    }
4447    let mut index = state.history_index.lock().unwrap();
4448    let new_index = *index - direction as isize;
4449    if new_index < -1 || new_index >= history.len() as isize {
4450        return;
4451    }
4452    if *index == -1 && new_index >= 0 {
4453        // Entering browse mode: stash the current input.
4454        *state.history_draft.lock().unwrap() = Some(editor.get_text());
4455    }
4456    *index = new_index;
4457    if new_index == -1 {
4458        // Exited browse mode: restore the draft (or clear if there was none).
4459        let draft = state.history_draft.lock().unwrap().take();
4460        match draft {
4461            Some(d) => {
4462                let len = d.len();
4463                editor.set_text(&d);
4464                editor.set_cursor(0, len);
4465            }
4466            None => editor.set_text(""),
4467        }
4468    } else {
4469        let text = history[new_index as usize].clone();
4470        let len = text.len();
4471        editor.set_text(&text);
4472        editor.set_cursor(0, len);
4473    }
4474}
4475
4476impl TuiState {
4477    fn begin_js_preparation(&self) -> CancellationToken {
4478        let cancellation = CancellationToken::new();
4479        if let Some(previous) = self
4480            .js_preparation_cancel
4481            .lock()
4482            .unwrap()
4483            .replace(cancellation.clone())
4484        {
4485            previous.cancel();
4486        }
4487        cancellation
4488    }
4489
4490    fn finish_js_preparation(&self) {
4491        self.js_preparation_cancel.lock().unwrap().take();
4492    }
4493
4494    fn cancel_js_preparation(&self) -> bool {
4495        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4496        if let Some(cancellation) = cancellation {
4497            cancellation.cancel();
4498            true
4499        } else {
4500            false
4501        }
4502    }
4503
4504    fn set_status(&self, status: RunStatus) {
4505        *self.status.lock().unwrap() = status;
4506        self.apply_status(status);
4507    }
4508
4509    /// Atomically reserve the single interactive run slot. The editor callback
4510    /// runs on a different thread from the async prompt loop, so checking and
4511    /// setting in separate steps would allow rapid Enter presses to queue more
4512    /// than one operation.
4513    fn try_start_working(&self) -> bool {
4514        let mut status = self.status.lock().unwrap();
4515        if *status != RunStatus::Idle {
4516            return false;
4517        }
4518        *status = RunStatus::Working;
4519        drop(status);
4520        self.apply_status(RunStatus::Working);
4521        true
4522    }
4523
4524    fn apply_status(&self, status: RunStatus) {
4525        match status {
4526            RunStatus::Working => {
4527                // The status dock already shows the active loader. Keep the
4528                // footer focused on the model and shortcuts instead of
4529                // repeating a second `Working…` at the bottom of the TUI.
4530                self.footer.set_status("");
4531                // Reflect the in-flight turn in the terminal window/tab title
4532                // (OSC 2). No-op when `tui` is absent (unit tests).
4533                if let Some(tui) = &self.tui {
4534                    tui.set_title("rpi — working");
4535                }
4536                self.status_container.clear();
4537                if self.show_terminal_progress {
4538                    self.loader.start();
4539                    self.status_container.add_child(self.loader.clone());
4540                }
4541            }
4542            RunStatus::Aborting => {
4543                self.footer.set_status("Aborting…");
4544                // Do not leave a frozen "Working" spinner on screen after the
4545                // render tick intentionally stops advancing in this state.
4546                self.loader.stop();
4547                self.status_container.clear();
4548            }
4549            RunStatus::Idle => {
4550                self.footer.set_status("");
4551                if let Some(tui) = &self.tui {
4552                    tui.set_title("rpi");
4553                }
4554                self.loader.stop();
4555                self.status_container.clear();
4556            }
4557        }
4558    }
4559
4560    /// The bash panel has its own `Running...` spinner. Keep the global
4561    /// `Working...` loader out of the status slot while any bash tool is active
4562    /// so the same operation is not presented as two simultaneous loaders.
4563    fn sync_working_loader_with_bash(&self) {
4564        if *self.status.lock().unwrap() != RunStatus::Working {
4565            return;
4566        }
4567
4568        self.status_container.clear();
4569        if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4570            self.status_container.add_child(self.loader.clone());
4571        }
4572    }
4573
4574    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4575        *self.status.lock().unwrap() = RunStatus::Working;
4576        self.footer.set_status("");
4577        if let Some(tui) = &self.tui {
4578            tui.set_title("rpi — retrying");
4579        }
4580        self.loader.stop();
4581        self.status_container.clear();
4582        self.status_container
4583            .add_child(Arc::new(StatusIndicator::retry(
4584                attempt,
4585                max_retries,
4586                std::time::Duration::from_millis(delay_ms),
4587            )));
4588    }
4589
4590    /// Whether a selector overlay is currently open (routes keys to it first).
4591    fn selector_open(&self) -> bool {
4592        self.active_selector.lock().unwrap().is_some()
4593    }
4594
4595    fn extension_editor_open(&self) -> bool {
4596        self.active_extension_editor.lock().unwrap().is_some()
4597    }
4598
4599    fn extension_input_open(&self) -> bool {
4600        self.active_extension_input.lock().unwrap().is_some()
4601    }
4602
4603    fn extension_dialog_open(&self) -> bool {
4604        self.extension_editor_open() || self.extension_input_open()
4605    }
4606
4607    fn set_hide_thinking(&self, hide: bool) {
4608        *self.hide_thinking.lock().unwrap() = hide;
4609        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4610            comp.set_hide_thinking(hide);
4611        }
4612    }
4613
4614    fn hide_thinking(&self) -> bool {
4615        *self.hide_thinking.lock().unwrap()
4616    }
4617
4618    fn toggle_thinking(&self) -> bool {
4619        let next = !self.hide_thinking();
4620        self.set_hide_thinking(next);
4621        next
4622    }
4623
4624    fn toggle_tool_outputs(&self) -> bool {
4625        let next = !*self.tool_outputs_expanded.lock().unwrap();
4626        *self.tool_outputs_expanded.lock().unwrap() = next;
4627        for comp in self.tool_components.lock().unwrap().values() {
4628            comp.set_expanded(next);
4629        }
4630        for comp in self.bash_components.lock().unwrap().values() {
4631            comp.set_expanded(next);
4632        }
4633        next
4634    }
4635
4636    /// The model id currently tracked as active (footer + Ctrl+M anchor).
4637    fn current_model_id(&self) -> String {
4638        self.current_model_id.lock().unwrap().clone()
4639    }
4640
4641    /// Update the tracked model id + footer label after a switch (live or
4642    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
4643    fn set_current_model(&self, model: &rpi_ai::Model) {
4644        *self.current_model_id.lock().unwrap() = model.id.clone();
4645        self.footer.set_model(&short_model_name(&model.id));
4646    }
4647
4648    /// B5e: read a clone of the current assistant-markdown transformer (if any).
4649    /// New assistant components call this at construction so they render with
4650    /// whatever plugin `register_markdown_transformer` handlers are live.
4651    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4652        self.markdown_transformer.lock().unwrap().clone()
4653    }
4654
4655    /// B5e: swap the live transformer. Used at startup (install the first
4656    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
4657    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
4658    /// transform should take effect on the VISIBLE streaming message too, so
4659    /// this re-installs on the in-flight `current_assistant` component — its
4660    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
4661    /// `None` clears the transform (identity), e.g. a reload that unregisters
4662    /// every markdown transformer.
4663    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4664        *self.markdown_transformer.lock().unwrap() = transformer.clone();
4665        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4666            comp.set_markdown_transformer(transformer);
4667        }
4668    }
4669
4670    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4671        self.pending_images.lock().unwrap().push(image);
4672    }
4673
4674    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4675        std::mem::take(&mut *self.pending_images.lock().unwrap())
4676    }
4677}
4678
4679// ===========================================================================
4680// interactive_tui — the entry point
4681// ===========================================================================
4682
4683#[derive(Debug, PartialEq)]
4684struct TuiStartupSettings {
4685    editor_padding_x: usize,
4686    autocomplete_max_visible: usize,
4687    hide_thinking: bool,
4688    quiet_startup: bool,
4689    show_terminal_progress: bool,
4690}
4691
4692fn preferred_project_setting<T>(
4693    project_settings: &[crate::settings::Settings],
4694    field: impl Fn(&crate::settings::Settings) -> Option<T>,
4695) -> Option<T> {
4696    project_settings.iter().find_map(field)
4697}
4698
4699fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4700    verbose || !quiet_startup
4701}
4702
4703fn git_only_update_resources(
4704    mut resources: crate::packages::PackageResources,
4705) -> crate::packages::PackageResources {
4706    resources
4707        .packages
4708        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4709    resources
4710}
4711
4712/// Resolve TUI-only settings with native Pi's project-over-global precedence.
4713/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
4714/// wins even when the value is `false` or zero.
4715fn resolve_tui_startup_settings(
4716    global: &crate::settings::Settings,
4717    project_settings: &[crate::settings::Settings],
4718    project_trusted: bool,
4719) -> TuiStartupSettings {
4720    let project_settings = if project_trusted {
4721        project_settings
4722    } else {
4723        &[]
4724    };
4725    let editor_padding_x =
4726        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4727            .or(global.editor_padding_x)
4728            .unwrap_or(1)
4729            .min(16);
4730    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4731        settings.autocomplete_max_visible
4732    })
4733    .or(global.autocomplete_max_visible)
4734    .unwrap_or(5)
4735    .clamp(1, 20);
4736    let hide_thinking =
4737        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4738            .or(global.hide_thinking_block)
4739            .unwrap_or(false);
4740    let quiet_startup =
4741        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4742            .or(global.quiet_startup)
4743            .unwrap_or(false);
4744    let show_terminal_progress =
4745        preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4746            .or(global.show_terminal_progress)
4747            .unwrap_or(true);
4748
4749    TuiStartupSettings {
4750        editor_padding_x,
4751        autocomplete_max_visible,
4752        hide_thinking,
4753        quiet_startup,
4754        show_terminal_progress,
4755    }
4756}
4757
4758/// TUI-based interactive mode.
4759///
4760/// `event_rx` carries the live `AgentEvent` stream (installed by
4761/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
4762/// fn), it falls back to a blocking, await-final-text path.
4763///
4764/// `model_catalog` is the read-only catalog the `/model` selector displays.
4765///
4766/// This implementation mirrors the TypeScript `InteractiveMode` class:
4767/// build the layout root once, drain `AgentEvent`s into UI mutations that
4768/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
4769/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
4770/// autocomplete are layered on via the editor-container swap pattern.
4771pub async fn interactive_tui(
4772    harness: &AgentHarness,
4773    event_rx: Option<broadcast::Receiver<AgentEvent>>,
4774    args: &Args,
4775    model_catalog: Vec<rpi_ai::Model>,
4776    initial: Option<String>,
4777    extra_messages: &[String],
4778    initial_images: Vec<rpi_ai::types::ImageContent>,
4779    theme: Option<&str>,
4780    no_themes: bool,
4781    reload_context: &crate::session::ReloadContext,
4782) -> i32 {
4783    let lane: Arc<dyn AgentLane> = harness.lane("main");
4784    // Reuse the startup snapshot captured before provider/session setup. An
4785    // extension may mutate the process cwd while loading; that must not change
4786    // which project settings or package update roots this TUI observes.
4787    let cwd = reload_context.cwd.clone();
4788    let saved_settings = crate::settings::load_settings().unwrap_or_default();
4789    let project_trusted = reload_context.project_trusted;
4790    let project_settings = if project_trusted {
4791        crate::settings::load_project_settings(&cwd)
4792    } else {
4793        Vec::new()
4794    };
4795    let tui_settings =
4796        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4797    let editor_padding_x = tui_settings.editor_padding_x;
4798    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4799    let hide_thinking = tui_settings.hide_thinking;
4800    let show_terminal_progress = tui_settings.show_terminal_progress;
4801    let quiet_startup = tui_settings.quiet_startup;
4802    let update_checks_enabled =
4803        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4804
4805    // Resolve the active model once, up front. The full id feeds the TuiState
4806    // tracking field + the selectors/key loop (which run on a blocking thread
4807    // and can't await `lane.get_model()`); the short name feeds the footer.
4808    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4809    let model_name = short_model_name(&lane_model_id);
4810
4811    // Snapshot startup capabilities for the welcome screen. Both accessors
4812    // return defensive clones, so rendering this summary does not retain a
4813    // harness lock or trigger a second resource scan.
4814    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4815    // AgentHarness uses an empty active-name list as the default "all tools"
4816    // state. Do not expose that implementation sentinel as `Tools (0) none`
4817    // in the welcome banner (it is especially visible on the first prompt).
4818    if active_tool_names.is_empty() {
4819        active_tool_names = harness
4820            .get_tools()
4821            .await
4822            .unwrap_or_default()
4823            .into_iter()
4824            .map(|tool| tool.tool.schema().name.clone())
4825            .collect();
4826    }
4827    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4828    let skill_names: Vec<String> = resources_snapshot
4829        .skills
4830        .as_deref()
4831        .unwrap_or(&[])
4832        .iter()
4833        .map(|skill| skill.name.clone())
4834        .collect();
4835
4836    // Resolve package resources for @file autocomplete + session discovery.
4837    let package_resources = reload_context.package_resources.clone();
4838
4839    // Channel between the key/callback threads and the main async loop.
4840    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4841
4842    // Apply the saved theme before constructing transcript components. Some
4843    // components keep styled text, so doing this after the welcome banner left
4844    // the first screen in the dark palette until it was rebuilt.
4845    let theme_manager = Arc::new(ThemeManager::new());
4846    if let Some(preset) = match theme {
4847        Some("light") => Some(ThemePreset::Light),
4848        Some("monochrome") => Some(ThemePreset::Monochrome),
4849        Some("dark") => Some(ThemePreset::Dark),
4850        _ => None,
4851    } {
4852        apply_theme_preset(preset);
4853        theme_manager.apply_preset(preset);
4854    } else if !no_themes {
4855        if let Some(name) = theme {
4856            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4857                Ok(custom) => {
4858                    rpi_tui::global_theme_manager().set(custom.clone());
4859                    theme_manager.set(custom);
4860                }
4861                Err(error) => {
4862                    eprintln!("warning: could not load package theme `{name}`: {error}");
4863                }
4864            }
4865        }
4866    }
4867
4868    // ---- TUI + containers ----
4869    let terminal = Box::new(ProcessTerminal::new());
4870    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4871    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4872    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
4873    // session as interactive so a detached/headless host fails loudly instead
4874    // of returning a fabricated answer. The mailbox is session-scoped (held in
4875    // `ReloadContext`) so in-flight prompts survive a `/reload`.
4876    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
4877    ask_user_bridge.attach();
4878    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4879
4880    if let Some(js) = &reload_context.js_extension_session {
4881        if let Err(error) = js.install_ui_runtime(tui.clone()) {
4882            eprintln!("warning: could not enable JS custom UI bridge: {error}");
4883        } else if let Some(js_active) = js.active_tools() {
4884            // Apply the discovery-time JS subset while preserving Rust
4885            // built-ins already active in the harness lane. The real TUI
4886            // lifecycle reconciliation runs after the key worker starts below.
4887            let js_names = js.tool_names();
4888            let mut active = lane.get_active_tools().await.unwrap_or_default();
4889            active.retain(|name| {
4890                crate::session::tool_name_allowed(name, args)
4891                    && !js_names.iter().any(|js_name| js_name == name)
4892            });
4893            active.extend(js_active.into_iter().filter(|name| {
4894                js_names.iter().any(|js_name| js_name == name)
4895                    && crate::session::tool_name_allowed(name, args)
4896            }));
4897            active = crate::session::filter_active_tool_names(active, args);
4898            let _ = lane.set_active_tools(active).await;
4899            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4900        }
4901    }
4902
4903    let chat_container = Arc::new(Container::new());
4904    if let Some(js) = &reload_context.js_extension_session {
4905        // Tool contexts do not have a command-result envelope. Route
4906        // `ctx.ui.notify()` through the live transcript so notifications from
4907        // tools such as ask_user_question are visible immediately.
4908        let chat_notify = chat_container.clone();
4909        let tui_notify = tui.clone();
4910        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4911            if action != "ui.notify" {
4912                return Err(format!("unsupported capability: {action}"));
4913            }
4914            let message = args
4915                .get("message")
4916                .and_then(serde_json::Value::as_str)
4917                .unwrap_or_default();
4918            if !message.is_empty() {
4919                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4920                    add_error_message(&chat_notify, message);
4921                } else {
4922                    add_note_message(&chat_notify, message);
4923                }
4924                tui_notify.request_render(false);
4925            }
4926            Ok(serde_json::json!(true))
4927        })) {
4928            if args.verbose {
4929                eprintln!("warning: could not enable JS notification bridge: {error}");
4930            }
4931        }
4932    }
4933    if should_show_startup_listing(args.verbose, quiet_startup) {
4934        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
4935    }
4936
4937    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
4938    // banner + the earendil announcement once, then write the sentinel. The TS
4939    // original is a multi-step dialog (theme picker + analytics opt-in); this
4940    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
4941    // analytics deferred — no telemetry wiring). See `extras.rs`.
4942    crate::extras::maybe_first_time_setup(&chat_container);
4943
4944    // A --continue/--resume/--session launch opens on an existing JSONL
4945    // session — render its prior user/assistant transcript so the user sees
4946    // where they left off (tool executions are skipped: their live display
4947    // belongs to the current run, and replaying old results would be noise).
4948    let initial_transformer = build_markdown_transformer(
4949        reload_context
4950            .extension_session
4951            .lock()
4952            .unwrap()
4953            .snapshot_arc(),
4954    );
4955    // A normal launch creates a fresh session and must not replay records from
4956    // another/project harness. Only explicit restore/fork modes render prior
4957    // conversation history. This fixes stale prompts appearing every startup.
4958    if launch_restores_history(args) {
4959        render_session_history(
4960            &harness,
4961            &chat_container,
4962            initial_transformer.clone(),
4963            Some(reload_context.extension_session.clone()),
4964        )
4965        .await;
4966    }
4967
4968    // `document_container` wraps the welcome header + chat so the scrollview
4969    // follows the whole transcript (mirrors TS `documentContainer`).
4970    let document_container = Arc::new(Container::new());
4971    document_container.add_child(chat_container.clone());
4972
4973    let scroll_view = Arc::new(ScrollView::new(
4974        document_container.clone(),
4975        ScrollViewOptions {
4976            follow: FollowMode::End,
4977            primary: true,
4978            overscroll: OverscrollMode::Chain,
4979            // Native pi keeps transcript chrome out of the way. Our Auto mode
4980            // has no hide timer yet and therefore became effectively permanent
4981            // after the first wheel event, unlike the upstream experience.
4982            scrollbar: ScrollbarMode::Hidden,
4983            ..Default::default()
4984        },
4985    ));
4986
4987    // ---- Editor ----
4988    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
4989    // editor renders full-width `─` top/bottom borders with padding-only lines
4990    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
4991    let keybindings = configured_keybindings();
4992    let editor = Arc::new(Editor::new(
4993        EditorOptions {
4994            padding_x: editor_padding_x,
4995            autocomplete_max_visible,
4996            ..Default::default()
4997        },
4998        EditorStyle::default(),
4999        keybindings.clone(),
5000    ));
5001
5002    // ---- Footer + status ----
5003    let footer = Arc::new(FooterComponent::new());
5004    footer.set_model(&model_name);
5005    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");
5006
5007    let status_container = Arc::new(Container::new());
5008    let loader = Arc::new(Loader::with_text("Working…"));
5009
5010    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5011    // Prompt templates discovered at session build (Part A2) are surfaced as
5012    // `/`-prefixed entries alongside the built-in slash commands: typing
5013    // `/<name>` in the editor expands the template (mirrors pi
5014    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5015    // the template's frontmatter description (or a fallback) so the autocomplete
5016    // popover shows what each template does.
5017    //
5018    // We snapshot the full resources once (skills + prompt-templates): the
5019    // autocomplete builder consumes the templates, and the `/context` command
5020    // (fired from the blocking submit handler, which can't `.await`) reads the
5021    // snapshot to render the discovered-resources panel without touching the
5022    // harness async accessor.
5023    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5024        .prompt_templates
5025        .clone()
5026        .unwrap_or_default()
5027        .iter()
5028        .map(|t| SlashCommandEntry {
5029            name: format!("/{}", t.name),
5030            description: t
5031                .description
5032                .clone()
5033                .unwrap_or_else(|| "Expand prompt template".to_string()),
5034        })
5035        .collect();
5036    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5037        Arc::new(resources_snapshot);
5038    // Build the built-in command registry once — the single source of truth for
5039    // both dispatch and the built-in autocomplete entries. The discovered
5040    // prompt-template commands are merged into the autocomplete list separately
5041    // (they dispatch via template expansion, not the registry); built-ins come
5042    // first so they win on a fuzzy tie.
5043    let mut command_registry = build_builtin_registry();
5044    register_extension_commands(
5045        &mut command_registry,
5046        reload_context.extension_session.clone(),
5047    );
5048    register_js_extension_commands(
5049        &mut command_registry,
5050        reload_context.js_extension_session.clone(),
5051    );
5052    let registry = Arc::new(command_registry);
5053    let mut all_slash_commands = registry.visible_entries();
5054    all_slash_commands.extend(template_slash_commands);
5055    let autocomplete = AutocompleteManager::new();
5056    {
5057        let mut combined = CombinedAutocompleteProvider::new();
5058        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5059            all_slash_commands,
5060        )));
5061        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5062            cwd.clone(),
5063        )));
5064        autocomplete.set_provider(Arc::new(combined));
5065    }
5066    let autocomplete_container = Arc::new(Container::new());
5067
5068    let state = Arc::new(TuiState {
5069        current_assistant: std::sync::Mutex::new(None),
5070        tool_components: std::sync::Mutex::new(HashMap::new()),
5071        bash_components: std::sync::Mutex::new(HashMap::new()),
5072        themes_enabled: !no_themes,
5073        hide_thinking: std::sync::Mutex::new(hide_thinking),
5074        tool_outputs_expanded: std::sync::Mutex::new(false),
5075        show_terminal_progress,
5076        status: std::sync::Mutex::new(RunStatus::Idle),
5077        js_preparation_cancel: std::sync::Mutex::new(None),
5078        footer: footer.clone(),
5079        status_container: status_container.clone(),
5080        chat_container: chat_container.clone(),
5081        loader: loader.clone(),
5082        last_assistant_text: std::sync::Mutex::new(String::new()),
5083        active_selector: std::sync::Mutex::new(None),
5084        active_extension_editor: std::sync::Mutex::new(None),
5085        active_extension_input: std::sync::Mutex::new(None),
5086        active_extension_cancel: std::sync::Mutex::new(None),
5087        autocomplete,
5088        autocomplete_container: autocomplete_container.clone(),
5089        autocomplete_max_visible,
5090        pending_images: std::sync::Mutex::new(Vec::new()),
5091        theme_manager,
5092        tui: Some(tui.clone()),
5093        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5094        show_images: std::sync::Mutex::new(true),
5095        history: std::sync::Mutex::new(Vec::new()),
5096        history_index: std::sync::Mutex::new(-1),
5097        history_draft: std::sync::Mutex::new(None),
5098        last_input_tokens: std::sync::Mutex::new(0),
5099        scoped_edit: std::sync::Mutex::new(None),
5100        markdown_transformer: std::sync::Mutex::new(initial_transformer),
5101        extension_session: reload_context.extension_session.clone(),
5102    });
5103
5104    // Capture the model catalog + cwd for the selector builders + the key loop
5105    // (the callbacks fire on blocking threads and need owned data).
5106    let model_catalog_arc = Arc::new(model_catalog.clone());
5107    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5108
5109    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5110    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5111    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5112    //
5113    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5114    // it at zero height and grows it to fill the space the dock does not need
5115    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5116    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5117    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5118    // never clip the input panel below its minimum.
5119    let editor_container = Arc::new(Container::new());
5120    editor_container.add_child(editor.clone());
5121
5122    let dock = Arc::new(VStack::from_children(vec![
5123        StackChild::Entry(StackEntry::new(status_container.clone())),
5124        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5125        StackChild::Entry(
5126            StackEntry::new(editor_container.clone())
5127                .shrink(0)
5128                .min_size(3),
5129        ),
5130        StackChild::Entry(StackEntry::new(footer.clone())),
5131    ]));
5132
5133    let root = VStack::from_children(vec![
5134        StackChild::Entry(
5135            StackEntry::new(scroll_view.clone())
5136                .basis(0)
5137                .grow(1)
5138                .shrink(1)
5139                .min_size(1),
5140        ),
5141        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5142    ]);
5143
5144    tui.set_layout_root(Some(Arc::new(root)));
5145    tui.set_focus(Some(editor.clone()));
5146    editor.set_focused(true);
5147
5148    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5149    //
5150    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5151    // the old version made individually) + the registry, then routes `/`-text
5152    // through `dispatch_slash` and sends plain text directly. Each command's
5153    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5154    // chat mutation) — the handler itself stays a thin router.
5155    //
5156    // One `CommandContext` is built and cloned for both the submit handler and
5157    // the key loop (Ctrl+L routes `/model` through the same registry); all
5158    // fields are `Arc`/cheap, so the clones are free.
5159    let ctx = CommandContext {
5160        chat: chat_container.clone(),
5161        tui: tui.clone(),
5162        tx: tx.clone(),
5163        state: state.clone(),
5164        editor: editor.clone(),
5165        editor_container: editor_container.clone(),
5166        lane: lane.clone(),
5167        model_catalog: model_catalog_arc.clone(),
5168        lane_model_id: lane_model_id.clone(),
5169        cwd: cwd.clone(),
5170        package_resources: package_resources.clone(),
5171        resources: resources_arc.clone(),
5172        reload_context: Arc::new(reload_context.clone()),
5173    };
5174
5175    if let Some(js) = &reload_context.js_extension_session {
5176        let bridge = js_dialog_bridge.clone();
5177        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5178            bridge.handle_runtime_request(action, args)
5179        })) {
5180            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5181        }
5182    }
5183
5184    let ctx_for_cb = ctx.clone();
5185    let registry_for_cb = registry.clone();
5186    editor.on_submit(Arc::new(move |text: &str| {
5187        let text = text.trim();
5188        if text.is_empty() {
5189            return;
5190        }
5191
5192        if text.starts_with('/') {
5193            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
5194            return;
5195        }
5196
5197        let run_status = *ctx_for_cb.state.status.lock().unwrap();
5198        if run_status != RunStatus::Idle {
5199            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5200            let lane = ctx_for_cb.lane.clone();
5201            let chat = ctx_for_cb.chat.clone();
5202            let tui = ctx_for_cb.tui.clone();
5203            tokio::spawn(async move {
5204                // Queue immediately while the agent loop is still running.
5205                // Routing this through the TUI's main channel delayed it until
5206                // `prompt_text()` returned, after the loop's drain points had
5207                // passed, so the queued message appeared to disappear.
5208                // Keep using steering while an abort is settling: the native
5209                // loop drains steering after the current tool batch, including
5210                // a cancelled batch, so the message is retained in context.
5211                // `next_run` is intentionally reserved for an explicit future
5212                // run and would otherwise sit pending with no automatic wakeup.
5213                let result = match lane.steer(message.clone()).await {
5214                    Ok(result) => Ok(result),
5215                    // Ctrl+C can finish the run between the status snapshot
5216                    // and this spawned enqueue task. Preserve the user's
5217                    // message for the next explicit run instead of dropping it
5218                    // on the idle-race.
5219                    Err(_) => lane.next_run(message).await,
5220                };
5221                if let Err(error) = result {
5222                    add_error_message(&chat, &format!("Could not queue message: {error}"));
5223                    tui.request_render(false);
5224                }
5225            });
5226            // A queued prompt is still the user's message. Render it through
5227            // the same transcript path as an immediately sent prompt rather
5228            // than as a system-note row, so its bubble/markdown presentation
5229            // stays consistent while the agent is busy.
5230            add_user_message(&ctx_for_cb.chat, text);
5231            ctx_for_cb.tui.request_render(false);
5232            return;
5233        }
5234
5235        if !ctx_for_cb.state.try_start_working() {
5236            return;
5237        }
5238
5239        add_user_message(&ctx_for_cb.chat, text);
5240        // A new prompt starts a fresh interaction at the tail even when the
5241        // user had scrolled up to inspect older output.
5242        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5243            scroll.scroll_to_end();
5244        }
5245        ctx_for_cb.tui.request_render(false);
5246        // Remember the message for ↑ recall (slash commands are not part of
5247        // the replayable message history).
5248        push_history(&ctx_for_cb.state, text);
5249        if ctx_for_cb
5250            .tx
5251            .send(TuiMessage::UserInput(text.to_string()))
5252            .is_err()
5253        {
5254            ctx_for_cb.state.set_status(RunStatus::Idle);
5255        }
5256    }));
5257
5258    tui.start_readerless();
5259
5260    // Consume local self-update results even when network checks are disabled.
5261    // Keep package discovery separate so package managers and Git remotes
5262    // cannot delay an rpi update notice or a prior helper failure.
5263    let mut update_check_handles = Vec::new();
5264    let rpi_chat = chat_container.clone();
5265    let rpi_tui = tui.clone();
5266    update_check_handles.push(tokio::spawn(async move {
5267        let report = crate::updates::check_rpi_startup().await;
5268        if !report.is_empty() {
5269            add_update_notices(&rpi_chat, &report);
5270        }
5271        rpi_tui.request_render(false);
5272    }));
5273
5274    if update_checks_enabled {
5275        let update_args = args.clone();
5276        let update_cwd = cwd.clone();
5277        let update_project_trusted = project_trusted;
5278        let chat = chat_container.clone();
5279        let tui = tui.clone();
5280        update_check_handles.push(tokio::spawn(async move {
5281            let resources = crate::session::package_resources_for_update_check(
5282                &update_args,
5283                &update_cwd,
5284                update_project_trusted,
5285            );
5286            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
5287            {
5288                Ok(npm_command) => {
5289                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
5290                        Some(&resources),
5291                        &npm_command,
5292                        update_project_trusted.then_some(update_cwd.as_path()),
5293                    )
5294                    .await
5295                }
5296                Err(error) => {
5297                    add_error_message(
5298                        &chat,
5299                        &format!("npm package update checks disabled: {error}"),
5300                    );
5301                    let git_resources = git_only_update_resources(resources);
5302                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
5303                }
5304            };
5305            if !report.is_empty() {
5306                add_update_notices(&chat, &report);
5307            }
5308            tui.request_render(false);
5309        }));
5310    }
5311
5312    // ---- Streaming drain task ----
5313    let drain_handle = if let Some(rx) = event_rx {
5314        let tui_drain = tui.clone();
5315        let state_drain = state.clone();
5316        let chat_drain = chat_container.clone();
5317        Some(tokio::spawn(async move {
5318            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
5319        }))
5320    } else {
5321        None
5322    };
5323
5324    // ---- B5d: plugin→TUI reload bridge ----
5325    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
5326    // (its cdylib would be unmapped while the call frame is still on the stack).
5327    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
5328    // (an `UnboundedSender<()>`); this task drains those signals and forwards
5329    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
5330    // `reload_extension_resources` routine asynchronously. The mailbox is the
5331    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
5332    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
5333    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5334    reload_context.mailbox.install(reload_sig_tx);
5335    let reload_tx = tx.clone();
5336    let reload_bridge_handle = tokio::spawn(async move {
5337        while reload_sig_rx.recv().await.is_some() {
5338            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5339                break; // main loop gone — stop forwarding
5340            }
5341        }
5342    });
5343
5344    // ---- Render-tick task (advances the loader spinner while Working) ----
5345    //
5346    // The `Loader` only advances its frame on render; without a periodic
5347    // `request_render` the spinner visibly freezes between events.
5348    let tui_tick = tui.clone();
5349    let state_tick = state.clone();
5350    let tick_handle = tokio::spawn(async move {
5351        // 80ms — pi's loader DEFAULT_INTERVAL_MS (the spinner would visibly
5352        // stutter at the old 120ms).
5353        let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
5354        interval.tick().await; // discard immediate
5355        loop {
5356            interval.tick().await;
5357            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5358            if working {
5359                if state_tick.bash_components.lock().unwrap().is_empty() {
5360                    // Only the dock loader animates. Keep the already-rendered
5361                    // transcript instead of rebuilding a long history at 12.5
5362                    // frames per second.
5363                    tui_tick.request_render_reusing_scroll_content();
5364                } else {
5365                    // A running bash panel owns a loader inside the transcript.
5366                    tui_tick.request_render(false);
5367                }
5368            }
5369        }
5370    });
5371
5372    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
5373    let running = Arc::new(std::sync::Mutex::new(true));
5374    let running_key = running.clone();
5375    let tx_for_key = tx.clone();
5376    let tui_for_key = tui.clone();
5377    let editor_for_key = editor.clone();
5378    let scroll_for_key = scroll_view.clone();
5379    let lane_for_key = lane.clone();
5380    let state_for_key = state.clone();
5381    let model_catalog_for_key = model_catalog_arc.clone();
5382    let js_for_key = reload_context.js_extension_session.clone();
5383    let js_dialog_for_key = js_dialog_bridge.clone();
5384    let ask_user_for_key = ask_user_bridge.clone();
5385    // Ctrl+L routes through the same registry as `/model` (one path, not two),
5386    // so the key loop needs the same `CommandContext` + registry the submit
5387    // handler uses. All fields are `Arc`/cheap, so this clone is free.
5388    let ctx_for_key = ctx.clone();
5389    let registry_for_key = registry.clone();
5390    let keybindings_for_key = keybindings.clone();
5391    let double_escape_action = crate::settings::load_settings()
5392        .ok()
5393        .and_then(|settings| settings.double_escape_action)
5394        .unwrap_or_else(|| "tree".to_string())
5395        .to_ascii_lowercase();
5396
5397    let key_handle = tokio::task::spawn_blocking(move || {
5398        let mut last_escape_time = None;
5399        loop {
5400            if !*running_key.lock().unwrap() {
5401                break;
5402            }
5403            if !state_for_key.selector_open()
5404                && !state_for_key.extension_dialog_open()
5405                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
5406            {
5407                if let Some(request) = js_dialog_for_key.take_pending() {
5408                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
5409                } else if let Some(request) = ask_user_for_key.take_pending() {
5410                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5411                }
5412            }
5413            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
5414            // `event::read()` blocks indefinitely. Poll first so shutdown can
5415            // stop and join this worker even when no further key arrives.
5416            match crossterm::event::poll(std::time::Duration::from_millis(50)) {
5417                Ok(true) => {}
5418                Ok(false) => continue,
5419                Err(_) => {
5420                    state_for_key.cancel_js_preparation();
5421                    let _ = tx_for_key.send(TuiMessage::Exit);
5422                    break;
5423                }
5424            }
5425            let Ok(ev) = crossterm::event::read() else {
5426                state_for_key.cancel_js_preparation();
5427                let _ = tx_for_key.send(TuiMessage::Exit);
5428                break;
5429            };
5430            // `Event::Resize` is delivered as its own event (not a Key). With
5431            // `start_readerless` there is no competing terminal-reader thread to
5432            // handle it, so refresh the cached terminal size here and force a
5433            // full redraw so the constrained layout re-fits the new dimensions.
5434            if let Event::Resize(_cols, _rows) = ev {
5435                tui_for_key.refresh_size();
5436                if let Some(js) = &js_for_key {
5437                    if js.custom_active() {
5438                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
5439                        tui_for_key.request_render(false);
5440                    }
5441                }
5442                continue;
5443            }
5444            // Mouse wheel scrolls the transcript (pi supports wheel
5445            // scrolling). Previously every non-Key event was dropped, so a
5446            // wheel had zero effect — "滚动还是不行".
5447            if let Event::Mouse(m) = ev {
5448                use crossterm::event::MouseEventKind;
5449                match m.kind {
5450                    MouseEventKind::ScrollUp => {
5451                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
5452                        if scroll_for_key.scroll_by(delta) != delta {
5453                            tui_for_key.request_render_reusing_scroll_content();
5454                        }
5455                    }
5456                    MouseEventKind::ScrollDown => {
5457                        let delta = MOUSE_WHEEL_SCROLL_LINES;
5458                        if scroll_for_key.scroll_by(delta) != delta {
5459                            tui_for_key.request_render_reusing_scroll_content();
5460                        }
5461                    }
5462                    _ => {}
5463                }
5464                continue;
5465            }
5466            let Event::Key(key) = ev else {
5467                if let Event::Paste(text) = ev {
5468                    let candidate = text.trim().trim_matches(['\"', '\'']);
5469                    let path = std::path::PathBuf::from(candidate);
5470                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5471                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5472                            add_image_preview(&state_for_key.chat_container, &image);
5473                            state_for_key.queue_image(image);
5474                            add_note_message(
5475                                &state_for_key.chat_container,
5476                                "Dropped image attached to the next prompt.",
5477                            );
5478                            tui_for_key.request_render(false);
5479                            continue;
5480                        }
5481                    }
5482                    editor_for_key.insert(&text);
5483                    refresh_autocomplete(&state_for_key, &editor_for_key);
5484                    tui_for_key.request_render_reusing_scroll_content();
5485                }
5486                continue;
5487            };
5488            // Drop releases but preserve Repeat so holding arrows, Backspace,
5489            // PageUp, etc. behaves naturally. Windows emits Press + Release
5490            // for a tap; terminals with keyboard enhancement may additionally
5491            // emit Repeat while a key is held.
5492            if !should_dispatch_key(key.kind) {
5493                continue;
5494            }
5495
5496            // Prompt preparation runs on a blocking worker before the agent
5497            // lane owns the turn. Cancel it directly: an abort queued only to
5498            // the lane cannot wake a JS factory or lifecycle hook that never
5499            // resolves. This check precedes custom/dialog routing because
5500            // those components may themselves have been opened by the hook.
5501            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5502                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5503                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5504            if prompt_abort && state_for_key.cancel_js_preparation() {
5505                state_for_key.set_status(RunStatus::Aborting);
5506                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5507                    close_extension_editor(
5508                        &state_for_key,
5509                        &ctx_for_key.editor_container,
5510                        &editor_for_key,
5511                        &tui_for_key,
5512                    );
5513                }
5514                js_dialog_for_key.cancel_open_requests();
5515                tui_for_key.set_render_suspended(false);
5516                tui_for_key.request_render(false);
5517                continue;
5518            }
5519
5520            if let Some(js) = &js_for_key {
5521                if js.custom_active() {
5522                    let visible = js.custom_accepts_input();
5523                    let data = key_event_to_input(key);
5524                    if !data.is_empty() {
5525                        // A visible custom owns the whole key stream, so its
5526                        // acknowledgement is unnecessary and would add a
5527                        // synchronous round-trip to every keystroke. Hidden
5528                        // overlays need the consume result to decide whether the
5529                        // outer editor should see the key.
5530                        if visible {
5531                            let _ = js.send_custom_input(&data);
5532                            continue;
5533                        }
5534                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5535                        // A hidden component only keeps raw listeners alive (for
5536                        // example ask_user_question's reopen shortcut); an
5537                        // unconsumed key continues through the outer editor.
5538                        if consumed {
5539                            tui_for_key.request_render_reusing_scroll_content();
5540                            continue;
5541                        }
5542                    }
5543                }
5544            }
5545
5546            // Ctrl+C cancels an open selector before it reaches the global
5547            // abort/exit handler. Route through Esc so selector callbacks run.
5548            if key.modifiers == KeyModifiers::CONTROL
5549                && key.code == KeyCode::Char('c')
5550                && state_for_key.selector_open()
5551            {
5552                let selector = state_for_key
5553                    .active_selector
5554                    .lock()
5555                    .unwrap()
5556                    .clone()
5557                    .expect("selector_open guaranteed Some")
5558                    .0;
5559                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5560                tui_for_key.request_render_reusing_scroll_content();
5561                continue;
5562            }
5563
5564            // Extension dialogs own the input slot while awaiting a result.
5565            // Esc and Ctrl+C both resolve the pending command with cancel;
5566            // all other keys go to the active native editor/input widget.
5567            if state_for_key.extension_dialog_open() {
5568                let cancel = key.code == KeyCode::Esc
5569                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5570                if cancel {
5571                    if !run_extension_cancel(&state_for_key) {
5572                        close_extension_editor(
5573                            &state_for_key,
5574                            &ctx_for_key.editor_container,
5575                            &editor_for_key,
5576                            &tui_for_key,
5577                        );
5578                    }
5579                } else if let Some(extension_editor) = state_for_key
5580                    .active_extension_editor
5581                    .lock()
5582                    .unwrap()
5583                    .clone()
5584                {
5585                    extension_editor.handle_key(key);
5586                } else if let Some(extension_input) =
5587                    state_for_key.active_extension_input.lock().unwrap().clone()
5588                {
5589                    extension_input.handle_key(key);
5590                }
5591                tui_for_key.request_render_reusing_scroll_content();
5592                continue;
5593            }
5594
5595            // 0. Ctrl+C: copy the selection when the editor has one (pi
5596            //    `tui.input.copy`); otherwise abort an active run, or exit
5597            //    when idle. Open selectors and extension dialogs are handled
5598            //    above so their cancellation callbacks get first chance.
5599            if keybinding_matches(
5600                &keybindings_for_key,
5601                &key,
5602                rpi_tui::keybindings::keys::CLEAR,
5603            ) {
5604                if !state_for_key.selector_open() && editor_for_key.has_selection() {
5605                    editor_for_key.copy_selection();
5606                    continue;
5607                }
5608                let status = *state_for_key.status.lock().unwrap();
5609                match status {
5610                    RunStatus::Working => {
5611                        state_for_key.set_status(RunStatus::Aborting);
5612                        // Drop any outstanding ask_user prompt from this turn.
5613                        ask_user_for_key.cancel_all();
5614                        let lane = lane_for_key.clone();
5615                        tokio::spawn(async move {
5616                            let _ = lane.abort().await;
5617                        });
5618                    }
5619                    // A held Ctrl+C can emit Repeat immediately after Press.
5620                    // Keep waiting for the in-flight cancellation instead of
5621                    // treating that repeat as a request to exit the process.
5622                    RunStatus::Aborting => {}
5623                    RunStatus::Idle => {
5624                        let _ = tx_for_key.send(TuiMessage::Exit);
5625                    }
5626                }
5627                continue;
5628            }
5629
5630            // 1. A selector overlay is open → route to it first. Only Esc
5631            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
5632            //    escape to the selector; on done/cancel the selector callbacks
5633            //    restore the editor and clear `active_selector`.
5634            if state_for_key.selector_open() {
5635                // Esc always cancels the selector (even with modifiers off).
5636                // Route through `SelectList::handle_key(Esc)` so the list's
5637                // `on_cancel` fires (the `/scoped-models` toggle selector saves
5638                // its edits there) — the old shortcut called `close_selector`
5639                // directly and skipped the callback.
5640                if key.code == KeyCode::Esc {
5641                    let (selector, _kind) = state_for_key
5642                        .active_selector
5643                        .lock()
5644                        .unwrap()
5645                        .clone()
5646                        .expect("selector_open guaranteed Some");
5647                    selector.handle_key(key);
5648                    continue;
5649                }
5650                let (selector, _kind) = state_for_key
5651                    .active_selector
5652                    .lock()
5653                    .unwrap()
5654                    .clone()
5655                    .expect("selector_open guaranteed Some");
5656                selector.handle_key(key);
5657                tui_for_key.request_render_reusing_scroll_content();
5658                continue;
5659            }
5660
5661            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
5662            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
5663            //     editor. With a run active, abort it first (same as Ctrl+C)
5664            //     so the key is never a no-op while a stuck command runs.
5665            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5666                let status = *state_for_key.status.lock().unwrap();
5667                match status {
5668                    RunStatus::Working => {
5669                        state_for_key.set_status(RunStatus::Aborting);
5670                        ask_user_for_key.cancel_all();
5671                        let lane = lane_for_key.clone();
5672                        tokio::spawn(async move {
5673                            let _ = lane.abort().await;
5674                        });
5675                        continue;
5676                    }
5677                    RunStatus::Aborting => continue,
5678                    RunStatus::Idle => {}
5679                }
5680                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5681                    // Editor holds text — delete the char forward (pi parity).
5682                    editor_for_key.handle_key(key);
5683                    refresh_autocomplete(&state_for_key, &editor_for_key);
5684                    tui_for_key.request_render_reusing_scroll_content();
5685                    continue;
5686                }
5687                let _ = tx_for_key.send(TuiMessage::Exit);
5688                continue;
5689            }
5690
5691            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
5692            //     selector is open Esc already cancelled it above; when idle,
5693            //     Esc falls through to the editor (no-op-ish). Only fire while
5694            //     Working so an idle Esc doesn't abort a non-existent run.
5695            if keybinding_matches(
5696                &keybindings_for_key,
5697                &key,
5698                rpi_tui::keybindings::keys::INTERRUPT,
5699            ) {
5700                let status = *state_for_key.status.lock().unwrap();
5701                if status == RunStatus::Working {
5702                    state_for_key.set_status(RunStatus::Aborting);
5703                    ask_user_for_key.cancel_all();
5704                    let lane = lane_for_key.clone();
5705                    tokio::spawn(async move {
5706                        let _ = lane.abort().await;
5707                    });
5708                    continue;
5709                }
5710                if status == RunStatus::Idle
5711                    && editor_for_key.get_text().trim().is_empty()
5712                    && double_escape_action != "none"
5713                {
5714                    let now = std::time::Instant::now();
5715                    if double_escape_trigger(last_escape_time, now) {
5716                        last_escape_time = None;
5717                        match double_escape_action.as_str() {
5718                            "tree" => {
5719                                let _ = tx_for_key.send(TuiMessage::OpenTree);
5720                            }
5721                            "fork" => {
5722                                let _ = tx_for_key.send(TuiMessage::ForkSession);
5723                            }
5724                            _ => {}
5725                        }
5726                    } else {
5727                        last_escape_time = Some(now);
5728                    }
5729                }
5730                continue;
5731            }
5732
5733            // 2c. Ctrl+G: edit the current draft in the user's external
5734            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
5735            if keybinding_matches(
5736                &keybindings_for_key,
5737                &key,
5738                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5739            ) {
5740                launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5741                continue;
5742            }
5743
5744            // 2d. Ctrl+O: toggle all tool output panels between compact and
5745            // expanded rendering (native Pi's global output toggle).
5746            if keybinding_matches(
5747                &keybindings_for_key,
5748                &key,
5749                rpi_tui::keybindings::keys::TOOLS_EXPAND,
5750            ) {
5751                state_for_key.toggle_tool_outputs();
5752                tui_for_key.request_render(false);
5753                continue;
5754            }
5755
5756            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
5757            if keybinding_matches(
5758                &keybindings_for_key,
5759                &key,
5760                rpi_tui::keybindings::keys::THINKING_TOGGLE,
5761            ) {
5762                state_for_key.toggle_thinking();
5763                tui_for_key.request_render(false);
5764                continue;
5765            }
5766
5767            // 2f. Ctrl+M: cycle to the next model in the catalog after the one
5768            //     currently tracked in `current_model_id`, apply it live via
5769            //     `lane.set_model` (takes effect on the next user message — the
5770            //     in-flight run's config is already snapshotted), and update the
5771            //     footer. `set_model` is async so it runs on a spawned task.
5772            if keybinding_matches(
5773                &keybindings_for_key,
5774                &key,
5775                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5776            ) {
5777                let current = state_for_key.current_model_id();
5778                // Cycle within the `/scoped-models` set (settings.json) when
5779                // configured; otherwise the full catalog.
5780                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
5781                if let Some(next) = cycle_next_model(&scope, &current) {
5782                    state_for_key.set_current_model(&next);
5783                    let lane = lane_for_key.clone();
5784                    tokio::spawn(async move {
5785                        let _ = lane.set_model(next).await;
5786                    });
5787                    tui_for_key.request_render_reusing_scroll_content();
5788                }
5789                continue;
5790            }
5791
5792            // 2f. Shift+Tab / BackTab: cycle the current model's supported
5793            // thinking levels, matching native Pi's thinking-level shortcut.
5794            if keybinding_matches(
5795                &keybindings_for_key,
5796                &key,
5797                rpi_tui::keybindings::keys::THINKING_CYCLE,
5798            ) {
5799                let lane = lane_for_key.clone();
5800                let catalog = model_catalog_for_key.clone();
5801                let state = state_for_key.clone();
5802                tokio::spawn(async move {
5803                    let Ok(current_model) = lane.get_model().await else {
5804                        return;
5805                    };
5806                    let levels = catalog
5807                        .iter()
5808                        .find(|model| {
5809                            model.provider == current_model.provider && model.id == current_model.id
5810                        })
5811                        .map(|model| model.supported_thinking_levels())
5812                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5813                    if levels.is_empty() {
5814                        return;
5815                    }
5816                    let current = lane
5817                        .get_thinking_level()
5818                        .await
5819                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5820                    let next = levels
5821                        .iter()
5822                        .position(|level| *level == current)
5823                        .map(|index| levels[(index + 1) % levels.len()])
5824                        .unwrap_or(levels[0]);
5825                    if lane.set_thinking_level(next).await.is_ok() {
5826                        state
5827                            .footer
5828                            .set_thinking_level(Some(thinking_level_name(next)));
5829                        state.tui.as_ref().map(|tui| tui.request_render(false));
5830                    }
5831                });
5832                continue;
5833            }
5834
5835            // Ctrl+V (or a configured paste-image key) keeps normal text yank
5836            // behavior when the clipboard has no bitmap, but queues an image
5837            // for the next prompt when one is available.
5838            if keybinding_matches(
5839                &keybindings_for_key,
5840                &key,
5841                rpi_tui::keybindings::keys::PASTE_IMAGE,
5842            ) {
5843                match read_clipboard_image() {
5844                    Ok(Some(image)) => {
5845                        add_image_preview(&state_for_key.chat_container, &image);
5846                        state_for_key.queue_image(image);
5847                        add_note_message(
5848                            &state_for_key.chat_container,
5849                            "Clipboard image attached to the next prompt.",
5850                        );
5851                        tui_for_key.request_render(false);
5852                        continue;
5853                    }
5854                    Ok(None) | Err(_) => {}
5855                }
5856            }
5857
5858            // 3. Ctrl+L: open the model selector. Routed through the `/model`
5859            //    command so the hotkey and the slash command share one path
5860            //    (TS binds Ctrl+L to model-select).
5861            if keybinding_matches(
5862                &keybindings_for_key,
5863                &key,
5864                rpi_tui::keybindings::keys::MODEL_SELECT,
5865            ) {
5866                if let Some(cmd) = registry_for_key.find("/model") {
5867                    cmd.execute(&ctx_for_key, "");
5868                }
5869                continue;
5870            }
5871
5872            // 4. Tab: accept the top autocomplete suggestion (if any).
5873            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5874                if accept_top_suggestion(&state_for_key, &editor_for_key) {
5875                    tui_for_key.request_render_reusing_scroll_content();
5876                }
5877                continue;
5878            }
5879
5880            // 5. Global transcript scroll. PageUp/PageDown use the actual
5881            // viewport height with four rows of overlap (upstream behavior),
5882            // while Home/End jump to the transcript boundaries.
5883            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
5884                let delta = -transcript_page_size(scroll_for_key.viewport_height());
5885                if scroll_for_key.scroll_by(delta) != delta {
5886                    tui_for_key.request_render_reusing_scroll_content();
5887                }
5888                continue;
5889            }
5890            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
5891                let delta = transcript_page_size(scroll_for_key.viewport_height());
5892                if scroll_for_key.scroll_by(delta) != delta {
5893                    tui_for_key.request_render_reusing_scroll_content();
5894                }
5895                continue;
5896            }
5897            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5898                scroll_for_key.scroll_to_start();
5899                tui_for_key.request_render_reusing_scroll_content();
5900                continue;
5901            }
5902            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5903                scroll_for_key.scroll_to_end();
5904                tui_for_key.request_render_reusing_scroll_content();
5905                continue;
5906            }
5907
5908            // 5b. ↑/↓ browse submitted-message history when the editor is
5909            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
5910            //     without the surprise of replacing typed text. When the
5911            //     editor holds content, ↑/↓ fall through to cursor movement
5912            //     (typing "hello", pressing ↑ at the start, must never swap
5913            //     the draft for a history entry — reported as "text
5914            //     disappeared"). Once browsing, ↓ walks back and restores the
5915            //     draft.
5916            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5917                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5918                if editor_for_key.get_text().is_empty() || browsing {
5919                    navigate_history(&state_for_key, &editor_for_key, -1);
5920                    tui_for_key.request_render_reusing_scroll_content();
5921                    continue;
5922                }
5923            }
5924            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5925                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5926                if editor_for_key.get_text().is_empty() || browsing {
5927                    navigate_history(&state_for_key, &editor_for_key, 1);
5928                    tui_for_key.request_render_reusing_scroll_content();
5929                    continue;
5930                }
5931            }
5932
5933            // Alt+Enter queues a follow-up while a run is active. It is
5934            // handled here because Editor treats only a bare Enter as submit;
5935            // idle Alt+Enter keeps the normal prompt behavior.
5936            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5937                let prompt = editor_for_key.get_text().trim().to_string();
5938                if prompt.is_empty() {
5939                    continue;
5940                }
5941                editor_for_key.clear();
5942                let status = *state_for_key.status.lock().unwrap();
5943                if status == RunStatus::Idle {
5944                    if state_for_key.try_start_working() {
5945                        add_user_message(&state_for_key.chat_container, &prompt);
5946                        push_history(&state_for_key, &prompt);
5947                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5948                    }
5949                } else {
5950                    // Follow-ups are user prompts too; retain the normal
5951                    // submitted-message rendering while they wait in queue.
5952                    add_user_message(&state_for_key.chat_container, &prompt);
5953                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
5954                    let lane = lane_for_key.clone();
5955                    let chat = state_for_key.chat_container.clone();
5956                    let tui = tui_for_key.clone();
5957                    tokio::spawn(async move {
5958                        if let Err(error) = lane.follow_up(message).await {
5959                            add_error_message(&chat, &format!("Could not queue message: {error}"));
5960                            tui.request_render(false);
5961                        }
5962                    });
5963                }
5964                tui_for_key.request_render(false);
5965                continue;
5966            }
5967
5968            // 6. Otherwise forward to the editor + refresh autocomplete.
5969            editor_for_key.handle_key(key);
5970            refresh_autocomplete(&state_for_key, &editor_for_key);
5971            tui_for_key.request_render_reusing_scroll_content();
5972        }
5973    });
5974
5975    // ---- Initial prompts (run before reading from the channel) ----
5976    let mut prompts: Vec<String> = Vec::new();
5977    if let Some(init) = initial {
5978        prompts.push(init);
5979    }
5980    for m in extra_messages {
5981        prompts.push(m.clone());
5982    }
5983    let mut images = initial_images;
5984    for prompt in prompts {
5985        if !*running.lock().unwrap() {
5986            break;
5987        }
5988        add_user_message(&chat_container, &prompt);
5989        tui.request_render(false);
5990        run_prompt_streaming(
5991            &lane,
5992            &prompt,
5993            &tui,
5994            &state,
5995            drain_handle.is_some(),
5996            reload_context.js_extension_session.as_ref(),
5997            &js_dialog_bridge,
5998            args,
5999            std::mem::take(&mut images),
6000        )
6001        .await;
6002    }
6003
6004    // ---- Main loop: process submitted input + lifecycle messages ----
6005    loop {
6006        if !*running.lock().unwrap() {
6007            break;
6008        }
6009        match rx.recv().await {
6010            Some(TuiMessage::UserInput(prompt)) => {
6011                // Clear the editor so the next prompt starts fresh (the submit
6012                // handler runs on the blocking key thread and can't mutate the
6013                // editor state safely there; clearing here, on the async loop,
6014                // keeps it on one thread).
6015                editor.clear();
6016                let prompt_images = state.take_pending_images();
6017                if !prompt_images.is_empty() {
6018                    add_note_message(
6019                        &chat_container,
6020                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6021                    );
6022                }
6023                run_prompt_streaming(
6024                    &lane,
6025                    &prompt,
6026                    &tui,
6027                    &state,
6028                    drain_handle.is_some(),
6029                    reload_context.js_extension_session.as_ref(),
6030                    &js_dialog_bridge,
6031                    args,
6032                    prompt_images,
6033                )
6034                .await;
6035            }
6036            Some(TuiMessage::ExternalEditorResult(result)) => {
6037                match result {
6038                    Ok(text) => {
6039                        let cursor = text.chars().count();
6040                        editor.set_text(&text);
6041                        editor.set_cursor(0, cursor);
6042                        add_note_message(&chat_container, "Draft updated from external editor.");
6043                    }
6044                    Err(error) => add_error_message(&chat_container, &error),
6045                }
6046                tui.request_render(false);
6047            }
6048            Some(TuiMessage::OpenTree) => {
6049                if *state.status.lock().unwrap() != RunStatus::Idle {
6050                    add_note_message(
6051                        &chat_container,
6052                        "Wait for the current run to finish before opening the tree.",
6053                    );
6054                    tui.request_render(false);
6055                } else {
6056                    open_tree_selector(
6057                        &harness,
6058                        &state,
6059                        &editor_container,
6060                        &editor,
6061                        &tui,
6062                        &chat_container,
6063                        &tx,
6064                    )
6065                    .await;
6066                }
6067            }
6068            Some(TuiMessage::NavigateTree(entry_id)) => {
6069                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6070                    Ok(result) => match result.outcome {
6071                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6072                            chat_container.clear();
6073                            add_welcome_message(&chat_container);
6074                            render_session_history(
6075                                &harness,
6076                                &chat_container,
6077                                state.markdown_transformer(),
6078                                Some(state.extension_session.clone()),
6079                            )
6080                            .await;
6081                            add_note_message(
6082                                &chat_container,
6083                                "Moved to the selected session entry.",
6084                            );
6085                        }
6086                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6087                            add_error_message(&chat_container, &error.message);
6088                        }
6089                        _ => add_note_message(
6090                            &chat_container,
6091                            "The selected entry could not be opened.",
6092                        ),
6093                    },
6094                    Err(error) => add_error_message(
6095                        &chat_container,
6096                        &format!("Could not navigate session tree: {error}"),
6097                    ),
6098                }
6099                tui.request_render(false);
6100            }
6101            Some(TuiMessage::ClearChat) => {
6102                chat_container.clear();
6103                add_welcome_message(&chat_container);
6104                tui.request_render(false);
6105            }
6106            Some(TuiMessage::Compact) => {
6107                run_compact(&lane, &tui, &state).await;
6108            }
6109            Some(TuiMessage::Copy) => {
6110                copy_last_assistant(&state, &chat_container);
6111                tui.request_render(false);
6112            }
6113            Some(TuiMessage::Exit) => {
6114                *running.lock().unwrap() = false;
6115                break;
6116            }
6117            Some(TuiMessage::SwitchSession(id)) => {
6118                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6119                tui.request_render(false);
6120            }
6121            Some(TuiMessage::ImportSession(path)) => {
6122                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6123                tui.request_render(false);
6124            }
6125            Some(TuiMessage::ShareSession) => {
6126                share_session(&harness, &chat_container).await;
6127                tui.request_render(false);
6128            }
6129            Some(TuiMessage::SetSessionName(name)) => {
6130                let outcome = harness.session().set_name(Some(&name)).await;
6131                match outcome {
6132                    Ok(_) => add_note_message(
6133                        &chat_container,
6134                        &format!("Session renamed to \"{name}\"."),
6135                    ),
6136                    Err(e) => add_error_message(
6137                        &chat_container,
6138                        &format!("Could not rename session: {e}"),
6139                    ),
6140                }
6141                tui.request_render(false);
6142            }
6143            Some(TuiMessage::ExportSession) => {
6144                export_session(&harness, &chat_container, &cwd).await;
6145                tui.request_render(false);
6146            }
6147            Some(TuiMessage::ForkSession) => {
6148                fork_session(&harness, &cwd, &chat_container, &state).await;
6149                tui.request_render(false);
6150            }
6151            Some(TuiMessage::ReloadExtensions) => {
6152                // B5d: drive the shared reload routine on the async runtime,
6153                // then surface the outcome. `reload_context` was passed into
6154                // `interactive_tui` and is the same `Arc<ReloadContext>` the
6155                // `ReloadCommand` + the plugin mailbox both route through —
6156                // clone the `Arc` out so the borrow of `harness` (the main
6157                // loop's `&AgentHarness`) lives across the await.
6158                let reload_ctx = ctx.reload_context.clone();
6159                add_note_message(&chat_container, "Reloading extensions + resources…");
6160                tui.request_render(false);
6161                let outcome =
6162                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6163                // B5e: the reload swapped a fresh `ExtensionSession` into the
6164                // context's cell. Rebuild the markdown transformer from that
6165                // fresh snapshot and install it on the in-flight streaming
6166                // component (so a reloaded plugin's transformer takes effect on
6167                // the visible message immediately) + future components (they
6168                // read `state.markdown_transformer()` at construction). The old
6169                // closure no-ops once its snapshot's `active` flag flips false
6170                // (reload already did that before the swap).
6171                let fresh_transformer = build_markdown_transformer(
6172                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6173                );
6174                state.set_markdown_transformer_with_reinstall(fresh_transformer);
6175                if outcome.had_warnings {
6176                    add_error_message(
6177                        &chat_container,
6178                        &format!(
6179                            "{} (with warnings — see stderr for details).",
6180                            outcome.summary
6181                        ),
6182                    );
6183                } else {
6184                    add_note_message(&chat_container, &outcome.summary);
6185                }
6186                tui.request_render(false);
6187            }
6188            None => break,
6189        }
6190    }
6191
6192    // ---- Shutdown ----
6193    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
6194    // bridge before joining the key worker and restoring the terminal.
6195    js_dialog_bridge.cancel_all();
6196    ask_user_bridge.shutdown();
6197    *running.lock().unwrap() = false;
6198    for handle in update_check_handles {
6199        handle.abort();
6200        let _ = handle.await;
6201    }
6202    // A hidden custom input listener can leave the key worker blocked on a
6203    // synchronous Node response. Stop the host first so transport shutdown
6204    // wakes that request before we join the worker.
6205    if let Some(js) = &reload_context.js_extension_session {
6206        js.shutdown();
6207    }
6208    // The input worker checks `running` at least every 50ms. Join it before
6209    // restoring cooked mode so no late event read races terminal cleanup.
6210    let _ = key_handle.await;
6211    tick_handle.abort();
6212    if let Some(handle) = drain_handle {
6213        handle.abort();
6214    }
6215    // Drop the reload bridge: clearing the mailbox closes the signal channel,
6216    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
6217    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
6218    reload_context.mailbox.clear();
6219    reload_bridge_handle.abort();
6220    tui.stop(Default::default());
6221    println!("\nGoodbye!");
6222    let _ = args;
6223
6224    0
6225}
6226
6227// ===========================================================================
6228// Run a single prompt (streaming or blocking)
6229// ===========================================================================
6230
6231/// Prepare the session's persistent Node host immediately before a real prompt
6232/// enters the agent loop. The first call starts the lazy host; later calls run
6233/// `before_agent_start` again on that host so each prompt sees current state.
6234/// Keeping startup here leaves an idle TUI free of a Node child while still
6235/// giving the lifecycle hook the fully installed UI bridge.
6236async fn ensure_js_runtime_before_prompt(
6237    js: Option<&crate::js_extensions::JsExtensionSession>,
6238    lane: &Arc<dyn AgentLane>,
6239    state: &Arc<TuiState>,
6240    dialog_bridge: &JsDialogBridge,
6241    args: &Args,
6242) -> bool {
6243    let Some(js) = js else {
6244        return true;
6245    };
6246    let cancellation = state.begin_js_preparation();
6247    let worker_cancellation = cancellation.clone();
6248    let js_for_start = js.clone();
6249    let mut worker = tokio::task::spawn_blocking(move || {
6250        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6251    });
6252    let result = tokio::select! {
6253        result = &mut worker => result,
6254        _ = cancellation.cancelled() => {
6255            dialog_bridge.cancel_open_requests();
6256            let js_for_cancel = js.clone();
6257            let _ = tokio::task::spawn_blocking(move || {
6258                js_for_cancel.cancel_prompt_preparation();
6259            }).await;
6260            worker.await
6261        }
6262    };
6263    let was_cancelled = cancellation.is_cancelled();
6264    if was_cancelled {
6265        dialog_bridge.cancel_open_requests();
6266        let js_for_cancel = js.clone();
6267        let _ = tokio::task::spawn_blocking(move || {
6268            js_for_cancel.cancel_prompt_preparation();
6269        })
6270        .await;
6271        state.finish_js_preparation();
6272        dialog_bridge.reopen();
6273        return false;
6274    }
6275    state.finish_js_preparation();
6276    match result {
6277        Ok(Ok(())) => {
6278            // The lifecycle hook can change the JS-only active tool set once
6279            // it sees the real TUI context. Merge that subset with the Rust
6280            // built-ins while applying the command-line tool policy.
6281            if let Some(js_active) = js.active_tools() {
6282                let js_names = js.tool_names();
6283                let mut active = lane.get_active_tools().await.unwrap_or_default();
6284                active.retain(|name| {
6285                    crate::session::tool_name_allowed(name, args)
6286                        && !js_names.iter().any(|js_name| js_name == name)
6287                });
6288                active.extend(js_active.into_iter().filter(|name| {
6289                    js_names.iter().any(|js_name| js_name == name)
6290                        && crate::session::tool_name_allowed(name, args)
6291                }));
6292                active = crate::session::filter_active_tool_names(active, args);
6293                let _ = lane.set_active_tools(active).await;
6294            }
6295        }
6296        Ok(Err(error)) => {
6297            if args.verbose {
6298                eprintln!("warning: could not start JS extension runtime: {error}");
6299            }
6300        }
6301        Err(error) => {
6302            if args.verbose {
6303                eprintln!("warning: JS extension runtime worker failed: {error}");
6304            }
6305        }
6306    }
6307    true
6308}
6309
6310fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6311    std::thread::spawn(move || {
6312        let file = match tempfile::Builder::new()
6313            .prefix("rpi-draft-")
6314            .suffix(".md")
6315            .tempfile()
6316        {
6317            Ok(file) => file,
6318            Err(error) => {
6319                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6320                    "Could not create editor file: {error}"
6321                ))));
6322                return;
6323            }
6324        };
6325        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6326            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6327                "Could not write editor file: {error}"
6328            ))));
6329            return;
6330        }
6331        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6332            .ok()
6333            .filter(|value| !value.trim().is_empty())
6334            .or_else(|| std::env::var("VISUAL").ok())
6335            .or_else(|| std::env::var("EDITOR").ok())
6336            .unwrap_or_else(|| {
6337                if cfg!(windows) {
6338                    "notepad".to_string()
6339                } else {
6340                    "nano".to_string()
6341                }
6342            });
6343        let status = std::process::Command::new(&editor)
6344            .arg(file.path())
6345            .status();
6346        let result = match status {
6347            Ok(status) if status.success() => std::fs::read_to_string(file.path())
6348                .map_err(|error| format!("Could not read editor file: {error}")),
6349            Ok(status) => Err(format!("External editor exited with {status}")),
6350            Err(error) => Err(format!(
6351                "Could not launch external editor `{editor}`: {error}"
6352            )),
6353        };
6354        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6355    });
6356}
6357
6358/// Apply the authoritative run result when it wins the race with the async
6359/// event drain, then detach the live component from further partial updates.
6360fn reconcile_streamed_assistant_completion(
6361    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6362    last_assistant_text: &Mutex<String>,
6363    final_message: Option<&AssistantMessage>,
6364) {
6365    let final_blocks = final_message.map(assistant_blocks);
6366    let final_text = final_message.map(assistant_text);
6367    let component = current_assistant.lock().unwrap().take();
6368
6369    if let Some(component) = component {
6370        if let Some(blocks) = final_blocks.as_deref() {
6371            component.update_blocks(blocks);
6372        }
6373        component.set_streaming(false);
6374    }
6375
6376    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6377        *last_assistant_text.lock().unwrap() = text;
6378    }
6379}
6380
6381/// Drive a single prompt through the lane. When `streaming` is true, the
6382/// `AgentEvent` drain task renders the response live and the completed outcome
6383/// reconciles its final snapshot. When false (no `event_rx`), this falls back
6384/// to the blocking await-final-text path.
6385async fn run_prompt_streaming(
6386    lane: &Arc<dyn AgentLane>,
6387    prompt: &str,
6388    tui: &Arc<TuiAltScreen>,
6389    state: &Arc<TuiState>,
6390    streaming: bool,
6391    js: Option<&crate::js_extensions::JsExtensionSession>,
6392    dialog_bridge: &JsDialogBridge,
6393    args: &Args,
6394    images: Vec<rpi_ai::types::ImageContent>,
6395) {
6396    // The persistent Node host is intentionally started at the first real
6397    // prompt. By this point the TUI key worker and all UI/runtime handlers are
6398    // live, so a `before_agent_start` hook may safely open a native dialog. A
6399    // session with no prompt never starts Node merely to render its welcome
6400    // screen; JS commands/tools still trigger the same lazy ensure path.
6401    // Preparation is part of the active turn. Mark it working before Node can
6402    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
6403    // semantics for initial argv prompts as well as editor submissions.
6404    state.set_status(RunStatus::Working);
6405    tui.request_render(false);
6406    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6407        state.set_status(RunStatus::Idle);
6408        tui.request_render(false);
6409        return;
6410    }
6411
6412    let outcome = lane.prompt_text(prompt, images).await;
6413
6414    if streaming {
6415        // Broadcast delivery is asynchronous: the harness result can resolve
6416        // before the drain task processes MessageEnd. Reconcile from the
6417        // authoritative outcome before detaching the component so the final
6418        // streamed tail cannot be left at an earlier partial snapshot.
6419        let final_message = match &outcome {
6420            Ok(result) => match &result.outcome {
6421                HarnessRunOutcome::Completed { final_message, .. }
6422                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6423                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6424                HarnessRunOutcome::Suspended { .. } => None,
6425            },
6426            Err(_) => None,
6427        };
6428        reconcile_streamed_assistant_completion(
6429            &state.current_assistant,
6430            &state.last_assistant_text,
6431            final_message,
6432        );
6433    }
6434
6435    state.set_status(RunStatus::Idle);
6436
6437    match outcome {
6438        Ok(result) => match &result.outcome {
6439            HarnessRunOutcome::Failed {
6440                error,
6441                final_message,
6442                ..
6443            } => {
6444                // Only add an error line if the stream did NOT already render
6445                // an assistant message for it (drain task leaves
6446                // current_assistant Some only on an abrupt end).
6447                // A final assistant error is emitted by the event drain only
6448                // in streaming mode. In regular mode there is no drain task,
6449                // so suppressing this branch merely hides 405/auth/network
6450                // diagnostics from the user.
6451                let already_rendered = streaming && final_message.is_some();
6452                if !already_rendered {
6453                    let msg = final_message
6454                        .as_ref()
6455                        .and_then(|m| m.error_message.clone())
6456                        .unwrap_or_else(|| format!("{error:?}"));
6457                    add_error_message(&state.chat_container, &msg);
6458                }
6459            }
6460            HarnessRunOutcome::Suspended { .. } => {
6461                add_error_message(
6462                    &state.chat_container,
6463                    "Run suspended (deferred) — resume is not supported in v1.",
6464                );
6465            }
6466            HarnessRunOutcome::Aborted { final_message, .. } => {
6467                // Aborted runs render their own partial/final message via the
6468                // stream; only add a note on the blocking fallback path.
6469                if !streaming {
6470                    add_error_message(&state.chat_container, "Request aborted.");
6471                    let _ = final_message; // (rendered by the stream in streaming mode)
6472                }
6473            }
6474            HarnessRunOutcome::Completed { final_message, .. } => {
6475                if !streaming {
6476                    let text = assistant_text(final_message);
6477                    if !text.is_empty() {
6478                        add_assistant_message_blocking(
6479                            &state.chat_container,
6480                            &text,
6481                            state.markdown_transformer(),
6482                        );
6483                        *state.last_assistant_text.lock().unwrap() = text;
6484                    }
6485                }
6486            }
6487        },
6488        Err(e) => {
6489            add_error_message(&state.chat_container, &e.to_string());
6490        }
6491    }
6492
6493    tui.request_render(false);
6494}
6495
6496/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
6497/// Reports the outcome as a transcript note; v1's compaction summarizes the
6498/// session in place, so no streaming display is wired (compaction emits no
6499/// `AgentEvent`s — only the harness bus `RunEnd`).
6500async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6501    state.set_status(RunStatus::Working);
6502    tui.request_render(false);
6503    match lane.compact(None).await {
6504        Ok(_) => {
6505            add_note_message(&state.chat_container, "Conversation compacted.");
6506        }
6507        Err(e) => {
6508            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6509        }
6510    }
6511    state.set_status(RunStatus::Idle);
6512    tui.request_render(false);
6513}
6514
6515/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
6516/// when no clipboard is available (or the `clipboard` feature is off), prints a
6517/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
6518fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6519    let text = state.last_assistant_text.lock().unwrap().clone();
6520    if text.is_empty() {
6521        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6522        return;
6523    }
6524    if copy_to_clipboard(&text) {
6525        add_note_message(chat, "Copied last reply to the clipboard.");
6526    } else {
6527        // Clipboard unavailable — print the text to the transcript so the user
6528        // can select/copy it manually (degrades gracefully in headless envs).
6529        let preview: String = text.chars().take(200).collect();
6530        add_note_message(
6531            chat,
6532            &format!(
6533                "Clipboard unavailable. Last reply: {preview}{}",
6534                if text.chars().count() > 200 {
6535                    "…"
6536                } else {
6537                    ""
6538                }
6539            ),
6540        );
6541    }
6542}
6543
6544/// Best-effort clipboard write. Enabled only with the `clipboard` feature
6545/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
6546#[cfg(feature = "clipboard")]
6547fn copy_to_clipboard(text: &str) -> bool {
6548    match arboard::Clipboard::new() {
6549        Ok(mut cb) => cb.set_text(text).is_ok(),
6550        Err(_) => false,
6551    }
6552}
6553
6554#[cfg(not(feature = "clipboard"))]
6555fn copy_to_clipboard(_text: &str) -> bool {
6556    false
6557}
6558
6559/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
6560/// `ImageContent` contract. The optional clipboard feature keeps headless
6561/// builds free of platform clipboard dependencies.
6562#[cfg(feature = "clipboard")]
6563fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6564    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6565    let image = match clipboard.get_image() {
6566        Ok(image) => image,
6567        Err(_) => return Ok(None),
6568    };
6569    let width =
6570        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6571    let height =
6572        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6573    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6574        return Err("clipboard image dimensions are outside the supported range".into());
6575    }
6576    let mut bytes = Vec::new();
6577    {
6578        let mut encoder = png::Encoder::new(&mut bytes, width, height);
6579        encoder.set_color(png::ColorType::Rgba);
6580        encoder.set_depth(png::BitDepth::Eight);
6581        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6582        writer
6583            .write_image_data(&image.bytes)
6584            .map_err(|e| e.to_string())?;
6585    }
6586    Ok(Some(rpi_ai::types::ImageContent {
6587        kind: rpi_ai::types::ImageContentType,
6588        data: base64::engine::general_purpose::STANDARD.encode(bytes),
6589        mime_type: "image/png".into(),
6590    }))
6591}
6592
6593fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6594    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6595        let mut options = ImageOptions::default();
6596        options.width = Some(48);
6597        options.alt_text = Some("Attached image".into());
6598        chat.add_child(Arc::new(Image::from_data(bytes, options)));
6599        chat.add_child(Arc::new(Spacer::new(1)));
6600    }
6601}
6602
6603#[cfg(not(feature = "clipboard"))]
6604fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6605    Ok(None)
6606}
6607
6608/// Blocking fallback (no `event_rx`): render the final assistant text as a
6609/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
6610/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
6611/// the identity path. The blocking path only fires when `event_rx` is absent,
6612/// so it shares the same transformer the streaming path installs on its
6613/// components.
6614fn add_assistant_message_blocking(
6615    container: &Arc<Container>,
6616    text: &str,
6617    transformer: Option<MarkdownTransformer>,
6618) {
6619    if text.is_empty() {
6620        return;
6621    }
6622    let msg = Arc::new(AssistantMessageComponent::new(
6623        AssistantMessageOptions::default(),
6624    ));
6625    if let Some(t) = &transformer {
6626        msg.set_markdown_transformer(Some(t.clone()));
6627    }
6628    msg.update_text(text);
6629    container.add_child(msg);
6630    container.add_child(Arc::new(Spacer::new(1)));
6631}
6632
6633// ===========================================================================
6634// AgentEvent drain task — the streaming core
6635// ===========================================================================
6636
6637/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
6638/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
6639/// lifetime of the TUI.
6640async fn drain_agent_events(
6641    mut rx: broadcast::Receiver<AgentEvent>,
6642    tui: Arc<TuiAltScreen>,
6643    state: Arc<TuiState>,
6644    chat: Arc<Container>,
6645) {
6646    loop {
6647        match rx.recv().await {
6648            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6649            Err(broadcast::error::RecvError::Lagged(_)) => {
6650                // We dropped some intermediate deltas; the next MessageUpdate/
6651                // MessageEnd carries a full partial snapshot so the UI re-syncs.
6652                continue;
6653            }
6654            Err(broadcast::error::RecvError::Closed) => break,
6655        }
6656    }
6657}
6658
6659/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
6660/// (`interactive-mode.ts:3068-3396`).
6661async fn handle_agent_event(
6662    event: AgentEvent,
6663    tui: &Arc<TuiAltScreen>,
6664    state: &Arc<TuiState>,
6665    chat: &Arc<Container>,
6666) {
6667    match event {
6668        AgentEvent::AgentStart => {
6669            state.set_status(RunStatus::Working);
6670            tui.request_render(false);
6671        }
6672
6673        AgentEvent::AgentEnd { .. } => {
6674            // Finalize any still-streaming assistant message.
6675            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6676                comp.set_streaming(false);
6677            }
6678            state.set_status(RunStatus::Idle);
6679            tui.request_render(false);
6680        }
6681
6682        AgentEvent::RetryScheduled {
6683            attempt,
6684            max_retries,
6685            delay_ms,
6686            ..
6687        } => {
6688            state.show_retry(attempt, max_retries, delay_ms);
6689            tui.request_render(false);
6690        }
6691
6692        AgentEvent::TurnStart => {
6693            // A new turn: reset the streaming-assistant guard so the next
6694            // MessageStart creates a fresh component.
6695            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6696                comp.set_streaming(false);
6697            }
6698        }
6699
6700        AgentEvent::TurnEnd {
6701            message,
6702            tool_results,
6703        } => {
6704            // Finalize the assistant message for this turn.
6705            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6706                if let AgentMessage::Assistant(a) = &message {
6707                    comp.update_blocks(&assistant_blocks(a));
6708                }
6709                comp.set_streaming(false);
6710            }
6711            // Any tool results whose components were never ended by a
6712            // ToolExecutionEnd get a static rendering here (best-effort). The
6713            // normal path removes the component via ToolExecutionEnd; this is
6714            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
6715            let tools = state.tool_components.lock().unwrap();
6716            for tr in &tool_results {
6717                if tools.contains_key(&tr.tool_call_id) {
6718                    // Will be removed below via ToolExecutionEnd in the normal
6719                    // path; leave as-is if still present.
6720                    let _ = tr;
6721                }
6722            }
6723            drop(tools);
6724            tui.request_render(false);
6725        }
6726
6727        AgentEvent::MessageStart { message } => match message {
6728            AgentMessage::Assistant(a) => {
6729                let comp = Arc::new(AssistantMessageComponent::new(
6730                    AssistantMessageOptions::default(),
6731                ));
6732                // B5e: install the live markdown transformer so the plugin's
6733                // `register_markdown_transformer` handlers apply from the very
6734                // first streamed delta. `set_streaming` before the transform
6735                // install is fine (transform fires on `update_blocks`, below).
6736                if let Some(t) = state.markdown_transformer() {
6737                    comp.set_markdown_transformer(Some(t));
6738                }
6739                comp.set_hide_thinking(state.hide_thinking());
6740                comp.set_streaming(true);
6741                // Render text AND thinking blocks in order (the old path fed
6742                // only the concatenated text, so thinking blocks never showed).
6743                comp.update_blocks(&assistant_blocks(&a));
6744                chat.add_child(comp.clone());
6745                // Spacer(1) separates this assistant turn from the next entry;
6746                // the component itself adds no leading spacer.
6747                chat.add_child(Arc::new(Spacer::new(1)));
6748                *state.current_assistant.lock().unwrap() = Some(comp);
6749                tui.request_render(false);
6750            }
6751            AgentMessage::Custom(custom) => {
6752                let payload = serde_json::json!({
6753                    "customType": custom.role,
6754                    "content": custom.content,
6755                    "details": custom.data,
6756                    "expanded": false,
6757                    "outputPad": 1,
6758                });
6759                if let Some(component) = extension_message_component(
6760                    &state.extension_session,
6761                    &custom.role,
6762                    &payload,
6763                    state.markdown_transformer(),
6764                ) {
6765                    chat.add_child(component);
6766                    chat.add_child(Arc::new(Spacer::new(1)));
6767                    tui.request_render(false);
6768                } else {
6769                    add_note_message(chat, &custom_message_fallback(&custom));
6770                    tui.request_render(false);
6771                }
6772            }
6773            // User / ToolResult / Custom starts are echoed at submit time or
6774            // via the tool-execution components; ignore user/tool dupes.
6775            _ => {}
6776        },
6777
6778        AgentEvent::MessageUpdate {
6779            message,
6780            assistant_message_event,
6781        } => {
6782            if let AgentMessage::Assistant(a) = &message {
6783                let text = assistant_text(a);
6784                let mut saw_bash_tool_call = false;
6785                // Scan content for finalized tool calls → proactively create
6786                // tool components (TS shows the tool as soon as the assistant
6787                // emits the ToolCall; ToolExecutionStart coalesces if it
6788                // already exists).
6789                for c in &a.content {
6790                    if let Content::ToolCall(tc) = c {
6791                        // Streaming providers may expose a placeholder tool
6792                        // call before its name has arrived. It is not a real
6793                        // tool panel and must not leave an empty first row.
6794                        if tc.name.trim().is_empty() {
6795                            continue;
6796                        }
6797                        if tc.name == "bash" {
6798                            // Bash has a dedicated component. Create it here as
6799                            // well as on ToolExecutionStart because the tool
6800                            // call can become visible in a MessageUpdate first.
6801                            // Keeping it in the bash map lets Start coalesce
6802                            // with this panel instead of appending a second one.
6803                            let command = tc
6804                                .arguments
6805                                .get("command")
6806                                .and_then(|v| v.as_str())
6807                                .unwrap_or("");
6808                            // Streaming tool-call arguments may still be `{}`
6809                            // here. Do not create a running bash panel until
6810                            // the lifecycle start event provides the command;
6811                            // otherwise the spinner renders first and the
6812                            // actual `$ command` header appears one frame
6813                            // later.
6814                            if command.trim().is_empty() {
6815                                continue;
6816                            }
6817                            saw_bash_tool_call = true;
6818                            let mut bash = state.bash_components.lock().unwrap();
6819                            if let Some(existing) = bash.get(&tc.id) {
6820                                // Tool-call arguments arrive incrementally.
6821                                // Refresh the running panel from every full
6822                                // assistant snapshot instead of leaving its
6823                                // header on the first partial command.
6824                                existing.set_command(command);
6825                            } else {
6826                                let comp = Arc::new(BashExecutionComponent::new(command));
6827                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6828                                chat.add_child(comp.clone());
6829                                bash.insert(tc.id.clone(), comp);
6830                            }
6831                        } else {
6832                            let mut tools = state.tool_components.lock().unwrap();
6833                            let display_args = if is_ask_user_tool(&tc.name) {
6834                                ask_user_args_display(&tc.arguments)
6835                            } else {
6836                                tc.arguments.to_string()
6837                            };
6838                            if let Some(existing) = tools.get(&tc.id) {
6839                                // Regular tool arguments stream incrementally
6840                                // too, so refresh their live header as soon as
6841                                // a more complete snapshot arrives.
6842                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
6843                                    existing.set_args(&display_args);
6844                                }
6845                            } else {
6846                                let comp = Arc::new(ToolExecutionComponent::new(
6847                                    &tc.name,
6848                                    &display_args,
6849                                ));
6850                                if is_ask_user_tool(&tc.name) {
6851                                    comp.set_display_title("ASK USER");
6852                                }
6853                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6854                                comp.set_running();
6855                                chat.add_child(comp.clone());
6856                                tools.insert(tc.id.clone(), comp);
6857                            }
6858                        }
6859                    }
6860                }
6861                // MessageUpdate can expose the finalized bash call before
6862                // ToolExecutionStart arrives. Hide the global `Working…`
6863                // loader immediately when creating that bash panel; otherwise
6864                // it briefly appears alongside the panel's `Running…` spinner.
6865                if saw_bash_tool_call {
6866                    state.sync_working_loader_with_bash();
6867                }
6868                let _ = assistant_message_event; // snapshot already applied via `a`
6869                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6870                    // Stream the full block list (text + thinking) each update
6871                    // so thinking blocks render live as they arrive.
6872                    comp.update_blocks(&assistant_blocks(a));
6873                }
6874                *state.last_assistant_text.lock().unwrap() = text;
6875                tui.request_render(false);
6876            }
6877        }
6878
6879        AgentEvent::MessageEnd { message } => {
6880            if let AgentMessage::Assistant(a) = &message {
6881                let text = assistant_text(a);
6882                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6883                    comp.update_blocks(&assistant_blocks(a));
6884                    comp.set_streaming(false);
6885                }
6886                // Cache the finalized text for `/copy`.
6887                if !text.is_empty() {
6888                    *state.last_assistant_text.lock().unwrap() = text;
6889                }
6890                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
6891                // the previous turn's input established a cacheable prefix; a
6892                // large input this turn that read nothing from cache means the
6893                // prefix was re-billed. No cost display — v1 has no per-run
6894                // cost tracking here.
6895                let usage = &a.usage;
6896                let prev_input = *state.last_input_tokens.lock().unwrap();
6897                if prev_input > 0
6898                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6899                    && usage.cache_read == 0
6900                {
6901                    add_note_message(
6902                        &state.chat_container,
6903                        &format!(
6904                            "Cache miss: {} tokens re-billed",
6905                            format_tokens(usage.input)
6906                        ),
6907                    );
6908                }
6909                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6910                    add_note_message(chat, &text);
6911                }
6912                // Error assistant messages carry the provider diagnostic in
6913                // `error_message`, not in text content. The assistant
6914                // component is empty for these messages, so surface the
6915                // diagnostic as a visible error row in the transcript.
6916                if let Some(error) = assistant_error_text(a) {
6917                    add_error_message(chat, &error);
6918                }
6919                *state.last_input_tokens.lock().unwrap() = usage.input;
6920            }
6921            tui.request_render(false);
6922        }
6923
6924        AgentEvent::ToolExecutionStart {
6925            tool_call_id,
6926            tool_name,
6927            args,
6928        } => {
6929            // Ignore placeholder lifecycle events emitted before the
6930            // provider has supplied a tool name.
6931            if tool_name.trim().is_empty() {
6932                return;
6933            }
6934            if tool_name == "bash" {
6935                // Bash streams into a dedicated BashExecutionComponent (command
6936                // header + live preview + exit/truncation status) rather than a
6937                // generic ToolExecutionComponent. The command comes from the
6938                // `command` field of the bash tool args.
6939                let command = args
6940                    .get("command")
6941                    .and_then(|v| v.as_str())
6942                    .unwrap_or("")
6943                    .to_string();
6944                // Bash arguments can still be `{}` when the lifecycle event
6945                // races the streamed tool-call argument finalization. Defer
6946                // the panel until a later start event carries the command.
6947                if command.trim().is_empty() {
6948                    return;
6949                }
6950                let mut bash_map = state.bash_components.lock().unwrap();
6951                if let Some(existing) = bash_map.get(&tool_call_id) {
6952                    // A ToolExecutionUpdate already created the panel (fast
6953                    // command — Update can arrive before Start); backfill the
6954                    // command header instead of adding a SECOND panel, which
6955                    // used to stack an empty "$ " box above the real one.
6956                    existing.set_command(&command);
6957                } else {
6958                    let comp = Arc::new(BashExecutionComponent::new(command));
6959                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6960                    chat.add_child(comp.clone());
6961                    bash_map.insert(tool_call_id.clone(), comp);
6962                }
6963            } else {
6964                let _comp = {
6965                    let mut tools = state.tool_components.lock().unwrap();
6966                    if let Some(existing) = tools.get(&tool_call_id) {
6967                        if is_ask_user_tool(&tool_name) {
6968                            existing.set_display_title("ASK USER");
6969                            existing.set_args(&ask_user_args_display(&args));
6970                        } else {
6971                            existing.set_args(&args.to_string());
6972                        }
6973                        existing.clone()
6974                    } else {
6975                        let display_args = if is_ask_user_tool(&tool_name) {
6976                            ask_user_args_display(&args)
6977                        } else {
6978                            args.to_string()
6979                        };
6980                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
6981                        if is_ask_user_tool(&tool_name) {
6982                            comp.set_display_title("ASK USER");
6983                        }
6984                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6985                        // A `read` of a SKILL.md renders as native Pi's
6986                        // `[skill] <name>` invocation box (custom-message
6987                        // background, collapsed to one line, Ctrl+O expands the
6988                        // skill markdown) instead of a generic READ tool panel.
6989                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
6990                            comp.set_skill_name(skill);
6991                        }
6992                        comp.set_running();
6993                        chat.add_child(comp.clone());
6994                        tools.insert(tool_call_id.clone(), comp.clone());
6995                        comp
6996                    }
6997                };
6998            }
6999            state.sync_working_loader_with_bash();
7000            tui.request_render(false);
7001        }
7002
7003        AgentEvent::ToolExecutionUpdate {
7004            tool_call_id,
7005            tool_name,
7006            args,
7007            partial_result,
7008        } => {
7009            if tool_name.trim().is_empty() {
7010                return;
7011            }
7012            let partial_text = if is_ask_user_tool(&tool_name) {
7013                ask_user_progress_text(&args, &partial_result)
7014            } else {
7015                tool_result_text(&partial_result)
7016            };
7017            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7018            if tool_name == "bash" {
7019                // Append the streamed chunk to the bash component's preview.
7020                // RAW text (no single-line collapsing) — the old
7021                // `summarize_tool_result` folded every newline into a `⏎`
7022                // glyph, cramming e.g. `ls -la`'s listing onto one line.
7023                let chunk = partial_text;
7024                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7025                    // Lifecycle updates can contain a more complete args object
7026                    // than the snapshot that created this running panel.
7027                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7028                        bash.set_command(command);
7029                    }
7030                    if has_partial_payload {
7031                        bash.append_output(&chunk);
7032                    }
7033                } else if has_partial_payload {
7034                    // ToolExecutionStart is emitted before a tool can run.
7035                    // Ignore an out-of-order partial until that event gives us
7036                    // the real command, rather than showing a spinner above an
7037                    // empty `$ ` header. Normal updates are handled by the
7038                    // component created in ToolExecutionStart.
7039                }
7040            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7041                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7042                    comp.set_skill_name(skill);
7043                }
7044                if is_ask_user_tool(&tool_name) {
7045                    comp.set_display_title("ASK USER");
7046                    comp.set_args(&ask_user_args_display(&args));
7047                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7048                    comp.set_args(&args.to_string());
7049                }
7050                // Raw multi-line text — read/ls-style tools must show their
7051                // full content, not the single-line ⏎-folded summary.
7052                if has_partial_payload {
7053                    comp.set_result(&partial_text, false);
7054                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7055                }
7056            } else if has_partial_payload {
7057                // No component yet — create a running one so the partial shows.
7058                // Empty callbacks are common before ToolExecutionStart; wait
7059                // for Start so the first panel has the real arguments instead
7060                // of an empty `TOOLS` box.
7061                let display_args = if is_ask_user_tool(&tool_name) {
7062                    ask_user_args_display(&args)
7063                } else {
7064                    args.to_string()
7065                };
7066                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7067                if is_ask_user_tool(&tool_name) {
7068                    comp.set_display_title("ASK USER");
7069                }
7070                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7071                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7072                    comp.set_skill_name(skill);
7073                }
7074                comp.set_running();
7075                comp.set_result(&partial_text, false);
7076                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7077                chat.add_child(comp.clone());
7078                state
7079                    .tool_components
7080                    .lock()
7081                    .unwrap()
7082                    .insert(tool_call_id.clone(), comp.clone());
7083            }
7084            state.sync_working_loader_with_bash();
7085            tui.request_render(false);
7086        }
7087
7088        AgentEvent::ToolExecutionEnd {
7089            tool_call_id,
7090            tool_name,
7091            result,
7092            is_error,
7093        } => {
7094            if tool_name.trim().is_empty() {
7095                return;
7096            }
7097            if tool_name == "bash" {
7098                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7099                if let Some(bash) = bash {
7100                    finalize_bash(&bash, &result, is_error);
7101                } else {
7102                    // Bash ended without a Start/Update — render a finalized
7103                    // component directly from the result text.
7104                    let command = result
7105                        .details
7106                        .get("command")
7107                        .and_then(|v| v.as_str())
7108                        .unwrap_or("")
7109                        .to_string();
7110                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7111                        state.sync_working_loader_with_bash();
7112                        tui.request_render(false);
7113                        return;
7114                    }
7115                    let comp = Arc::new(BashExecutionComponent::new(command));
7116                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7117                    comp.append_output(&tool_result_text(&result));
7118                    finalize_bash(&comp, &result, is_error);
7119                    chat.add_child(comp);
7120                }
7121            } else {
7122                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7123                if let Some(comp) = comp {
7124                    let result_text = if is_ask_user_tool(&tool_name) {
7125                        ask_user_result_text(&result)
7126                    } else {
7127                        tool_result_text(&result)
7128                    };
7129                    comp.set_result(&result_text, is_error);
7130                    if !is_ask_user_tool(&tool_name) {
7131                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7132                    }
7133                } else {
7134                    // Tool ended without a Start/Update (e.g. a very fast tool):
7135                    // render a finalized component directly.
7136                    let comp = Arc::new(ToolExecutionComponent::new(
7137                        &tool_name,
7138                        &if is_ask_user_tool(&tool_name) {
7139                            ask_user_args_display(&serde_json::Value::Null)
7140                        } else {
7141                            "".to_string()
7142                        },
7143                    ));
7144                    if is_ask_user_tool(&tool_name) {
7145                        comp.set_display_title("ASK USER");
7146                    }
7147                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7148                    let result_text = if is_ask_user_tool(&tool_name) {
7149                        ask_user_result_text(&result)
7150                    } else {
7151                        tool_result_text(&result)
7152                    };
7153                    comp.set_result(&result_text, is_error);
7154                    if !is_ask_user_tool(&tool_name) {
7155                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7156                    }
7157                    chat.add_child(comp.clone());
7158                }
7159            }
7160            state.sync_working_loader_with_bash();
7161            tui.request_render(false);
7162        }
7163    }
7164}
7165
7166/// Return the diagnostic carried by a failed or aborted provider request.
7167/// Providers may omit `error_message`; keep a stable fallback so a terminal
7168/// request failure can never render as an empty transcript turn.
7169fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7170    let fallback = match message.stop_reason {
7171        rpi_ai::StopReason::Error => "Provider request failed.",
7172        rpi_ai::StopReason::Aborted => "Request aborted.",
7173        _ => return None,
7174    };
7175    Some(
7176        message
7177            .error_message
7178            .as_deref()
7179            .filter(|text| !text.trim().is_empty())
7180            .unwrap_or(fallback)
7181            .to_string(),
7182    )
7183}
7184
7185/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
7186/// tool result and mark the component complete. Mirrors the TS bash finalize
7187/// path; only the fields `BashExecutionComponent` needs are read.
7188fn finalize_bash(
7189    comp: &Arc<BashExecutionComponent>,
7190    result: &rpi_agent::AgentToolResult,
7191    is_error: bool,
7192) {
7193    // The exit code isn't in details directly (TS carries it elsewhere); use
7194    // `is_error` as the error signal and 0/1 as a best-effort exit code.
7195    let exit_code = if is_error { Some(1) } else { Some(0) };
7196    let truncated = result
7197        .details
7198        .get("truncation")
7199        .and_then(|t| t.get("truncated"))
7200        .and_then(|v| v.as_bool())
7201        .unwrap_or(false);
7202    let full_output_path = result
7203        .details
7204        .get("full_output_path")
7205        .and_then(|v| v.as_str())
7206        .map(|s| s.to_string());
7207    let truncation = BashTruncation {
7208        truncated,
7209        full_output_path,
7210    };
7211    // Some tool backends attach the authoritative command to result details.
7212    // Backfill it before completing so this is a fallback, never the first
7213    // opportunity for the UI to show the whole command.
7214    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7215        comp.set_command(command);
7216    }
7217    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
7218    comp.set_complete(exit_code, cancelled, truncation);
7219}
7220
7221/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
7222/// display-diff string, render it with colors and attach to the component so
7223/// the changes show in the transcript. `write` has no diff (details: Null) and
7224/// stays a plain summary.
7225fn apply_edit_diff(
7226    comp: &Arc<ToolExecutionComponent>,
7227    tool_name: &str,
7228    details: &serde_json::Value,
7229    tui: &Arc<TuiAltScreen>,
7230) {
7231    if tool_name != "edit" {
7232        return;
7233    }
7234    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7235        return;
7236    };
7237    if diff_text.is_empty() {
7238        return;
7239    }
7240    let width = tui.width();
7241    let lines = render_diff(diff_text, width);
7242    comp.set_diff(lines);
7243}
7244
7245/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
7246/// `None`. The name is the `SKILL.md` parent directory's basename (matching
7247/// native Pi's skill-file convention). Ordinary markdown/document reads
7248/// return `None` and remain regular `READ` tool panels.
7249fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7250    if tool_name != "read" {
7251        return None;
7252    }
7253    let path = args.get("path").and_then(|value| value.as_str())?;
7254    let normalized = path.replace('\\', "/");
7255    let file_name = normalized.rsplit('/').next()?;
7256    if !file_name.eq_ignore_ascii_case("SKILL.md") {
7257        return None;
7258    }
7259    normalized
7260        .trim_end_matches('/')
7261        .rsplit('/')
7262        .nth(1)
7263        .filter(|name| !name.is_empty())
7264        .map(str::to_string)
7265}
7266
7267/// Render an `AgentToolResult` as a single-line summary for the
7268/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
7269fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7270    use rpi_agent::TextContentOrImage;
7271    let mut parts: Vec<String> = Vec::new();
7272    for c in &result.content {
7273        if let TextContentOrImage::Text(t) = c {
7274            parts.push(t.text.clone());
7275        }
7276    }
7277    let joined = parts.join("\n");
7278    // Keep the tool line compact: collapse to a single line, trim length.
7279    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7280    if one_line.chars().count() > 200 {
7281        let truncated: String = one_line.chars().take(200).collect();
7282        format!("{truncated}…")
7283    } else {
7284        one_line
7285    }
7286}
7287
7288/// The raw multi-line text of a tool result (no single-line collapsing). The
7289/// bash panel needs the original line structure — the old path fed it through
7290/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
7291/// crammed e.g. `ls -la`'s whole listing onto one line.
7292fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7293    use rpi_agent::TextContentOrImage;
7294    let mut parts: Vec<String> = Vec::new();
7295    for c in &result.content {
7296        if let TextContentOrImage::Text(t) = c {
7297            parts.push(t.text.clone());
7298        }
7299    }
7300    parts.join("\n")
7301}
7302
7303/// Empty progress callbacks are valid (notably before a tool's start event),
7304/// but they do not contain anything useful to render. Defer those callbacks so
7305/// the first tool panel is created from `ToolExecutionStart` with real args.
7306fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7307    !text.trim().is_empty() || !result.details.is_null()
7308}
7309
7310/// Tool names rendered through the dedicated `ASK USER` panel instead of a
7311/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
7312fn is_ask_user_tool(tool_name: &str) -> bool {
7313    matches!(tool_name, "ask_user" | "ask_user_question")
7314}
7315
7316/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
7317/// transport JSON must never leak into the transcript.
7318fn ask_user_args_display(args: &serde_json::Value) -> String {
7319    if args.is_null() {
7320        return String::new();
7321    }
7322    if let Some(summary) = args
7323        .get("summary")
7324        .and_then(serde_json::Value::as_str)
7325        .map(str::trim)
7326        .filter(|text| !text.is_empty())
7327    {
7328        return format!("Confirmation requested: {summary}");
7329    }
7330
7331    let mut lines: Vec<String> = Vec::new();
7332    let questions: Vec<&serde_json::Value> = args
7333        .get("questions")
7334        .and_then(serde_json::Value::as_array)
7335        .filter(|items| !items.is_empty())
7336        .map(|items| items.iter().collect())
7337        .unwrap_or_else(|| vec![args]);
7338
7339    for question in questions {
7340        let prompt = question
7341            .get("question")
7342            .and_then(serde_json::Value::as_str)
7343            .or_else(|| {
7344                args.get("question")
7345                    .and_then(serde_json::Value::as_str)
7346            })
7347            .map(str::trim)
7348            .unwrap_or_default();
7349        if prompt.is_empty() {
7350            continue;
7351        }
7352        if let Some(header) = question
7353            .get("header")
7354            .and_then(serde_json::Value::as_str)
7355            .map(str::trim)
7356            .filter(|text| !text.is_empty())
7357        {
7358            lines.push(format!("{header}: {prompt}"));
7359        } else {
7360            lines.push(prompt.to_string());
7361        }
7362        let context = question
7363            .get("context")
7364            .and_then(serde_json::Value::as_str)
7365            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7366            .map(str::trim)
7367            .filter(|text| !text.is_empty());
7368        if let Some(context) = context {
7369            lines.push(format!("  {context}"));
7370        }
7371        let options = question
7372            .get("options")
7373            .and_then(serde_json::Value::as_array)
7374            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7375        if let Some(options) = options {
7376            let labels: Vec<String> = options
7377                .iter()
7378                .filter_map(|option| {
7379                    option
7380                        .get("title")
7381                        .and_then(serde_json::Value::as_str)
7382                        .or_else(|| option.as_str())
7383                        .map(str::trim)
7384                        .filter(|label| !label.is_empty())
7385                        .map(str::to_string)
7386                })
7387                .collect();
7388            if !labels.is_empty() {
7389                lines.push(format!("Choices: {}", labels.join(", ")));
7390            }
7391        }
7392        if let Some(suggest) = question
7393            .get("suggest")
7394            .and_then(serde_json::Value::as_str)
7395            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7396            .map(str::trim)
7397            .filter(|text| !text.is_empty())
7398        {
7399            lines.push(format!("Suggestions: {suggest}"));
7400        }
7401    }
7402
7403    lines.join("\n")
7404}
7405
7406/// Progress text while an ask_user tool is pending. Prefer the plugin's own
7407/// readable `content`, otherwise show the question so the panel is not blank.
7408fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7409    let text = tool_result_text(result);
7410    if !text.trim().is_empty() {
7411        return text;
7412    }
7413    let args_text = ask_user_args_display(args);
7414    if args_text.trim().is_empty() {
7415        "Waiting for your answer…".to_string()
7416    } else {
7417        args_text
7418    }
7419}
7420
7421/// Final result text for an ask_user tool. The plugin returns a human-readable
7422/// answer summary in `content`; never surface the transport JSON.
7423fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7424    let text = tool_result_text(result);
7425    if text.trim().is_empty() {
7426        "Answer recorded.".to_string()
7427    } else {
7428        text
7429    }
7430}
7431
7432// ===========================================================================
7433// Selectors — editor-container swap (TS showSelector pattern)
7434// ===========================================================================
7435
7436/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
7437/// hiding the editor while the selector is open. Records the selector in
7438/// `state.active_selector` so the key loop routes to it.
7439fn open_selector(
7440    state: &Arc<TuiState>,
7441    editor_container: &Arc<Container>,
7442    editor: &Arc<Editor>,
7443    tui: &Arc<TuiAltScreen>,
7444    list: Arc<SelectList>,
7445    kind: SelectorKind,
7446) {
7447    open_selector_with_view(
7448        state,
7449        editor_container,
7450        editor,
7451        tui,
7452        list.clone(),
7453        list,
7454        kind,
7455    );
7456}
7457
7458/// Open a selector with an optional framed view. Native extension selectors
7459/// wrap the list with a title and hint while built-in selectors keep the list
7460/// as the complete view.
7461fn open_selector_with_view<C: Component + 'static>(
7462    state: &Arc<TuiState>,
7463    editor_container: &Arc<Container>,
7464    editor: &Arc<Editor>,
7465    tui: &Arc<TuiAltScreen>,
7466    list: Arc<SelectList>,
7467    view: Arc<C>,
7468    kind: SelectorKind,
7469) {
7470    // Unfocus the editor so its cursor marker doesn't render behind the list.
7471    editor.set_focused(false);
7472    // A selector replaces the editor slot. Drop stale slash/@file
7473    // suggestions so they cannot reappear after the selector closes.
7474    state.autocomplete_container.clear();
7475    // Swap: clear the container and add the selector view.
7476    editor_container.clear();
7477    editor_container.add_child(view.clone());
7478    *state.active_selector.lock().unwrap() = Some((list, kind));
7479    let focused: Arc<dyn Component> = view;
7480    tui.set_focus(Some(focused));
7481    tui.request_render(false);
7482}
7483
7484/// Restore the editor into the `editor_container` and clear the active
7485/// selector. Called by selector `on_cancel` and the Esc handler.
7486fn close_selector(
7487    state: &Arc<TuiState>,
7488    editor_container: &Arc<Container>,
7489    editor: &Arc<Editor>,
7490    tui: &Arc<TuiAltScreen>,
7491) {
7492    editor_container.clear();
7493    editor_container.add_child(editor.clone());
7494    state.autocomplete_container.clear();
7495    editor.set_focused(true);
7496    *state.active_selector.lock().unwrap() = None;
7497    *state.active_extension_cancel.lock().unwrap() = None;
7498    tui.set_focus(Some(editor.clone()));
7499    tui.request_render(false);
7500}
7501
7502/// Build + open the `/model` selector. Items are the resolved catalog (display
7503/// label = model name; description = id), with the current model marked.
7504/// Selecting applies the model **live** via `lane.set_model` (takes effect on
7505/// the next user message — the in-flight run's config is already snapshotted),
7506/// updates the footer, and notes the next-prompt effect.
7507fn open_model_selector(
7508    state: &Arc<TuiState>,
7509    editor_container: &Arc<Container>,
7510    editor: &Arc<Editor>,
7511    tui: &Arc<TuiAltScreen>,
7512    catalog: &[rpi_ai::Model],
7513    lane: &Arc<dyn AgentLane>,
7514    lane_model_id: &str,
7515    chat: &Arc<Container>,
7516) {
7517    let items = model_selector_items(catalog, lane_model_id);
7518    if items.is_empty() {
7519        add_note_message(
7520            chat,
7521            "No models in the catalog. Use --model at startup to select one.",
7522        );
7523        tui.request_render(false);
7524        return;
7525    }
7526    let list = Arc::new(SelectList::new(items, 10));
7527
7528    // Capture the catalog + lane so the on_select closure can resolve the
7529    // chosen Model and apply it. `on_select` fires on the blocking key thread,
7530    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
7531    let catalog_arc = catalog.to_vec();
7532    let state_sel = state.clone();
7533    let ec_sel = editor_container.clone();
7534    let editor_sel = editor.clone();
7535    let tui_sel = tui.clone();
7536    let chat_sel = chat.clone();
7537    let lane_sel = lane.clone();
7538    list.on_select(Arc::new(move |item| {
7539        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7540            add_note_message(
7541                &chat_sel,
7542                &format!("Model {} not found in catalog.", item.label),
7543            );
7544            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7545            return;
7546        };
7547        state_sel.set_current_model(&model);
7548        let lane = lane_sel.clone();
7549        tokio::spawn(async move {
7550            let _ = lane.set_model(model).await;
7551        });
7552        add_note_message(
7553            &chat_sel,
7554            &format!(
7555                "Model set to {} — applies to the next message.",
7556                short_model_name(&item.value)
7557            ),
7558        );
7559        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7560    }));
7561    let state_cancel = state.clone();
7562    let ec_cancel = editor_container.clone();
7563    let editor_cancel = editor.clone();
7564    let tui_cancel = tui.clone();
7565    list.on_cancel(Arc::new(move || {
7566        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7567    }));
7568
7569    open_selector(
7570        state,
7571        editor_container,
7572        editor,
7573        tui,
7574        list,
7575        SelectorKind::Model,
7576    );
7577}
7578
7579/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
7580/// Returns `None` only when the catalog is empty or the current id isn't
7581/// found (in which case the first entry is returned — a no-op if it IS the
7582/// current). Used by the Ctrl+M model-cycle hotkey.
7583fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
7584    if catalog.is_empty() {
7585        return None;
7586    }
7587    let idx = catalog
7588        .iter()
7589        .position(|m| m.id.eq_ignore_ascii_case(current_id));
7590    match idx {
7591        Some(i) => {
7592            let next = (i + 1) % catalog.len();
7593            Some(catalog[next].clone())
7594        }
7595        None => Some(catalog[0].clone()),
7596    }
7597}
7598
7599/// Build + open the `/session` selector. Lists JSONL session files under the
7600/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
7601/// fallback). Selecting reports "restore not
7602/// implemented in v1" (existing constraint) but shows the list for
7603/// discoverability.
7604fn open_session_selector(
7605    state: &Arc<TuiState>,
7606    editor_container: &Arc<Container>,
7607    editor: &Arc<Editor>,
7608    tui: &Arc<TuiAltScreen>,
7609    cwd: &std::path::Path,
7610    tx: &mpsc::UnboundedSender<TuiMessage>,
7611) {
7612    let dir = crate::session::default_session_dir(cwd);
7613    let mut items: Vec<SelectItem> = Vec::new();
7614    if let Ok(entries) = std::fs::read_dir(&dir) {
7615        for entry in entries.flatten() {
7616            let path = entry.path();
7617            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7618                continue;
7619            }
7620            let stem = path
7621                .file_stem()
7622                .and_then(|s| s.to_str())
7623                .unwrap_or("(unnamed)")
7624                .to_string();
7625            let display = path
7626                .file_name()
7627                .and_then(|s| s.to_str())
7628                .unwrap_or(&stem)
7629                .to_string();
7630            items.push(SelectItem::new(&stem, &display));
7631        }
7632    }
7633    if items.is_empty() {
7634        add_note_message(
7635            &state.chat_container,
7636            "No saved sessions found. Sessions are created automatically in interactive mode.",
7637        );
7638        tui.request_render(false);
7639        return;
7640    }
7641    let list = Arc::new(SelectList::new(items, 10));
7642
7643    let state_sel = state.clone();
7644    let ec_sel = editor_container.clone();
7645    let editor_sel = editor.clone();
7646    let tui_sel = tui.clone();
7647    let tx_sel = tx.clone();
7648    list.on_select(Arc::new(move |item| {
7649        // Close the selector first, then ask the async loop to hot-switch:
7650        // opening the session file + swapping the harness backing is async
7651        // (repo list/open) and must not run on the blocking key thread.
7652        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7653        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
7654    }));
7655    let state_cancel = state.clone();
7656    let ec_cancel = editor_container.clone();
7657    let editor_cancel = editor.clone();
7658    let tui_cancel = tui.clone();
7659    list.on_cancel(Arc::new(move || {
7660        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7661    }));
7662
7663    open_selector(
7664        state,
7665        editor_container,
7666        editor,
7667        tui,
7668        list,
7669        SelectorKind::Session,
7670    );
7671}
7672
7673fn custom_entry_display_text(
7674    custom_type: &str,
7675    data: Option<&serde_json::Value>,
7676) -> Option<String> {
7677    let data = data?;
7678    let text = data
7679        .get("summary")
7680        .or_else(|| data.get("text"))
7681        .or_else(|| data.get("output"))
7682        .and_then(|value| value.as_str())
7683        .filter(|value| !value.trim().is_empty())?;
7684    let label = match custom_type {
7685        "compactionSummary" => "Compaction summary",
7686        "branchSummary" => "Branch summary",
7687        "bashExecution" => "Command output",
7688        other => other,
7689    };
7690    Some(format!("{label}: {text}"))
7691}
7692
7693/// Open a selector for the current session's persisted entry tree. Selecting a
7694/// message moves the main lane leaf to that entry, then the caller reloads the
7695/// visible branch from durable storage.
7696async fn open_tree_selector(
7697    harness: &AgentHarness,
7698    state: &Arc<TuiState>,
7699    editor_container: &Arc<Container>,
7700    editor: &Arc<Editor>,
7701    tui: &Arc<TuiAltScreen>,
7702    chat: &Arc<Container>,
7703    tx: &mpsc::UnboundedSender<TuiMessage>,
7704) {
7705    let entries = match harness
7706        .session()
7707        .view("main")
7708        .find_entries(&EntryQuery {
7709            order: Some(EntryOrder::OldestFirst),
7710            ..Default::default()
7711        })
7712        .await
7713    {
7714        Ok(entries) => entries,
7715        Err(error) => {
7716            add_error_message(chat, &format!("Could not read session tree: {error}"));
7717            tui.request_render(false);
7718            return;
7719        }
7720    };
7721    let current = harness.session().get_leaf_id().await.ok().flatten();
7722    let items: Vec<SelectItem> = entries
7723        .iter()
7724        .map(|entry| {
7725            let marker = if current.as_deref() == Some(entry.id()) {
7726                " (current)"
7727            } else {
7728                ""
7729            };
7730            SelectItem::new(
7731                entry.id(),
7732                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7733            )
7734            .with_description(&entry.id()[..entry.id().len().min(12)])
7735        })
7736        .collect();
7737    if items.is_empty() {
7738        add_note_message(chat, "The current session has no entries to navigate.");
7739        tui.request_render(false);
7740        return;
7741    }
7742    let list = Arc::new(SelectList::new(items, 12));
7743    let state_sel = state.clone();
7744    let ec_sel = editor_container.clone();
7745    let editor_sel = editor.clone();
7746    let tui_sel = tui.clone();
7747    let tx_sel = tx.clone();
7748    list.on_select(Arc::new(move |item| {
7749        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7750        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7751    }));
7752    let state_cancel = state.clone();
7753    let ec_cancel = editor_container.clone();
7754    let editor_cancel = editor.clone();
7755    let tui_cancel = tui.clone();
7756    list.on_cancel(Arc::new(move || {
7757        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7758    }));
7759    open_selector(
7760        state,
7761        editor_container,
7762        editor,
7763        tui,
7764        list,
7765        SelectorKind::Tree,
7766    );
7767}
7768
7769/// Build + open the `/theme` selector. Built-in presets and enabled package
7770/// themes are shown; selecting applies the theme live and re-renders.
7771fn open_theme_selector(
7772    state: &Arc<TuiState>,
7773    editor_container: &Arc<Container>,
7774    editor: &Arc<Editor>,
7775    tui: &Arc<TuiAltScreen>,
7776    cwd: &std::path::Path,
7777    package_resources: &Arc<crate::packages::PackageResources>,
7778) {
7779    let mut items = vec![
7780        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7781        SelectItem::new("light", "Light").with_description("Light background"),
7782        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7783    ];
7784    if state.themes_enabled {
7785        for path in package_resources.theme_files() {
7786            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7787                items.push(SelectItem::new(name, name).with_description("Package theme"));
7788            }
7789        }
7790    }
7791    let list = Arc::new(SelectList::new(items, 10));
7792
7793    let state_sel = state.clone();
7794    let ec_sel = editor_container.clone();
7795    let editor_sel = editor.clone();
7796    let tui_sel = tui.clone();
7797    let chat_sel = state.chat_container.clone();
7798    let cwd_sel = cwd.to_path_buf();
7799    let package_resources_sel = package_resources.clone();
7800    list.on_select(Arc::new(move |item| {
7801        let preset = match item.value.as_str() {
7802            "light" => Some(ThemePreset::Light),
7803            "monochrome" => Some(ThemePreset::Monochrome),
7804            "dark" => Some(ThemePreset::Dark),
7805            name => {
7806                if state_sel.themes_enabled {
7807                    if let Ok(custom) = crate::packages::load_theme_with_resources(
7808                        &cwd_sel,
7809                        name,
7810                        &package_resources_sel,
7811                    ) {
7812                        rpi_tui::global_theme_manager().set(custom.clone());
7813                        state_sel.theme_manager.set(custom);
7814                    }
7815                }
7816                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7817                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7818                tui_sel.render_now(true);
7819                return;
7820            }
7821        };
7822        let Some(preset) = preset else { return };
7823        apply_theme_preset(preset);
7824        state_sel.theme_manager.apply_preset(preset);
7825        // A quick accent note so the user sees the change registered even if
7826        // the terminal's own colors mask the preset difference.
7827        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7828        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7829        tui_sel.render_now(true);
7830    }));
7831    let state_cancel = state.clone();
7832    let ec_cancel = editor_container.clone();
7833    let editor_cancel = editor.clone();
7834    let tui_cancel = tui.clone();
7835    list.on_cancel(Arc::new(move || {
7836        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7837    }));
7838
7839    open_selector(
7840        state,
7841        editor_container,
7842        editor,
7843        tui,
7844        list,
7845        SelectorKind::Theme,
7846    );
7847}
7848
7849// ===========================================================================
7850// Feasible selectors — /thinking, /tools, /images
7851// ===========================================================================
7852
7853/// One-line descriptions for each thinking level, ported from
7854/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
7855fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7856    use rpi_ai::types::ThinkingLevel::*;
7857    match level {
7858        Off => "Off — No reasoning",
7859        Minimal => "Minimal — Brief reasoning (~1k tokens)",
7860        Low => "Low — Light reasoning (~1k tokens)",
7861        Medium => "Medium — Moderate reasoning (~80% of max)",
7862        High => "High — Extensive reasoning (~95% of max)",
7863        Xhigh => "Xhigh — Near-maximal reasoning",
7864        Max => "Max — Maximum reasoning",
7865    }
7866}
7867
7868/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
7869/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
7870fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7871    use rpi_ai::types::ThinkingLevel::*;
7872    match level {
7873        Off => "off",
7874        Minimal => "minimal",
7875        Low => "low",
7876        Medium => "medium",
7877        High => "high",
7878        Xhigh => "xhigh",
7879        Max => "max",
7880    }
7881}
7882
7883/// Parse a thinking-level name back to the enum (case-insensitive). Returns
7884/// `None` for an unknown name; used by the `/thinking` selector callback.
7885fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7886    use rpi_ai::types::ThinkingLevel::*;
7887    match name.to_ascii_lowercase().as_str() {
7888        "off" => Some(Off),
7889        "minimal" => Some(Minimal),
7890        "low" => Some(Low),
7891        "medium" => Some(Medium),
7892        "high" => Some(High),
7893        "xhigh" => Some(Xhigh),
7894        "max" => Some(Max),
7895        _ => None,
7896    }
7897}
7898
7899/// Build + open the `/thinking` selector. Items are the levels the current
7900/// model supports (`Model::supported_thinking_levels`), each with a
7901/// description; the current level (read beforehand via `lane.get_thinking_level`)
7902/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
7903///
7904/// `on_select` fires on the blocking key thread, so it can't await
7905/// `lane.get_thinking_level()` to know the current level — the opener resolves
7906/// it first (best-effort) and preselects; the toggle on_select just applies
7907/// whatever was picked.
7908fn open_thinking_selector(
7909    state: &Arc<TuiState>,
7910    editor_container: &Arc<Container>,
7911    editor: &Arc<Editor>,
7912    tui: &Arc<TuiAltScreen>,
7913    lane: &Arc<dyn AgentLane>,
7914    catalog: &[rpi_ai::Model],
7915    lane_model_id: &str,
7916    chat: &Arc<Container>,
7917) {
7918    // Find the current model in the catalog to read its supported levels. If
7919    // absent, fall back to all levels so the selector still opens.
7920    let model = catalog
7921        .iter()
7922        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7923    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7924        .map(|m| m.supported_thinking_levels())
7925        .unwrap_or_else(|| {
7926            use rpi_ai::types::ThinkingLevel::*;
7927            vec![Off, Minimal, Low, Medium, High]
7928        });
7929    let mut items: Vec<SelectItem> = Vec::new();
7930    for lvl in &levels {
7931        let name = thinking_level_name(*lvl);
7932        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7933    }
7934    if items.is_empty() {
7935        add_note_message(chat, "This model has no supported thinking levels.");
7936        tui.request_render(false);
7937        return;
7938    }
7939    let list = Arc::new(SelectList::new(items, 10));
7940
7941    let state_sel = state.clone();
7942    let ec_sel = editor_container.clone();
7943    let editor_sel = editor.clone();
7944    let tui_sel = tui.clone();
7945    let chat_sel = chat.clone();
7946    let lane_sel = lane.clone();
7947    list.on_select(Arc::new(move |item| {
7948        let Some(level) = thinking_level_from_name(&item.value) else {
7949            add_note_message(
7950                &chat_sel,
7951                &format!("Unknown thinking level: {}.", item.label),
7952            );
7953            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7954            return;
7955        };
7956        let lane = lane_sel.clone();
7957        let footer_sel = state_sel.footer.clone();
7958        tokio::spawn(async move {
7959            let _ = lane.set_thinking_level(level).await;
7960        });
7961        // Reflect the chosen level in the footer's model suffix (pi parity:
7962        // `model • thinking off` / `model • medium`). The shown text for the
7963        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
7964        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
7965        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
7966        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7967    }));
7968    let state_cancel = state.clone();
7969    let ec_cancel = editor_container.clone();
7970    let editor_cancel = editor.clone();
7971    let tui_cancel = tui.clone();
7972    list.on_cancel(Arc::new(move || {
7973        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7974    }));
7975
7976    open_selector(
7977        state,
7978        editor_container,
7979        editor,
7980        tui,
7981        list,
7982        SelectorKind::Thinking,
7983    );
7984}
7985
7986/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
7987/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
7988/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
7989/// — the blocking key thread can't await) and selecting a tool **toggles** it
7990/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
7991fn open_tools_selector(
7992    state: &Arc<TuiState>,
7993    editor_container: &Arc<Container>,
7994    editor: &Arc<Editor>,
7995    tui: &Arc<TuiAltScreen>,
7996    lane: &Arc<dyn AgentLane>,
7997    chat: &Arc<Container>,
7998) {
7999    // Best-effort read of the current active set. The opener runs on the async
8000    // runtime (it's called from the main loop's channel dispatch or the submit
8001    // closure that lives on the blocking thread — but `handle.block_on` is safe
8002    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
8003    let mut active = match tokio::runtime::Handle::try_current() {
8004        Ok(h) => h
8005            .block_on(async { lane.get_active_tools().await })
8006            .unwrap_or_default(),
8007        Err(_) => Vec::new(),
8008    };
8009    // An empty active set is the harness sentinel for "all registered tools"
8010    // (the selector only exposes built-ins). Expand it before rendering and
8011    // toggling so the first `/tools` visit does not show every tool as off or
8012    // accidentally reduce the active set to the one item selected.
8013    if active.is_empty() {
8014        active = crate::session::BUILTIN_TOOL_NAMES
8015            .iter()
8016            .map(|name| (*name).to_string())
8017            .collect();
8018    }
8019    let mut items: Vec<SelectItem> = Vec::new();
8020    for name in crate::session::BUILTIN_TOOL_NAMES {
8021        let on = active.iter().any(|a| a == name);
8022        let label = if on {
8023            format!("{name} (on)")
8024        } else {
8025            (*name).to_string()
8026        };
8027        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8028    }
8029    let list = Arc::new(SelectList::new(items, 10));
8030
8031    // Capture the active set so on_select can toggle without re-reading.
8032    let active_captured = active.clone();
8033    let state_sel = state.clone();
8034    let ec_sel = editor_container.clone();
8035    let editor_sel = editor.clone();
8036    let tui_sel = tui.clone();
8037    let chat_sel = chat.clone();
8038    let lane_sel = lane.clone();
8039    list.on_select(Arc::new(move |item| {
8040        let mut next = active_captured.clone();
8041        if let Some(pos) = next.iter().position(|a| a == &item.value) {
8042            next.remove(pos);
8043        } else {
8044            next.push(item.value.clone());
8045        }
8046        let on = next.iter().any(|a| a == &item.value);
8047        let lane = lane_sel.clone();
8048        let next_clone = next.clone();
8049        tokio::spawn(async move {
8050            let _ = lane.set_active_tools(next_clone).await;
8051        });
8052        let list_str = if next.is_empty() {
8053            "(none)".to_string()
8054        } else {
8055            next.join(", ")
8056        };
8057        add_note_message(
8058            &chat_sel,
8059            &format!(
8060                "{} {} — active tools: {}",
8061                item.value,
8062                if on { "enabled" } else { "disabled" },
8063                list_str
8064            ),
8065        );
8066        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8067    }));
8068    let state_cancel = state.clone();
8069    let ec_cancel = editor_container.clone();
8070    let editor_cancel = editor.clone();
8071    let tui_cancel = tui.clone();
8072    list.on_cancel(Arc::new(move || {
8073        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8074    }));
8075
8076    open_selector(
8077        state,
8078        editor_container,
8079        editor,
8080        tui,
8081        list,
8082        SelectorKind::Tools,
8083    );
8084}
8085
8086/// Build + open the `/images` selector (Yes/No). Stores the choice in
8087/// `state.show_images` and notes it. Image wiring is minimal this pass — the
8088/// flag is consulted where images would be shown and echoed back here.
8089fn open_images_selector(
8090    state: &Arc<TuiState>,
8091    editor_container: &Arc<Container>,
8092    editor: &Arc<Editor>,
8093    tui: &Arc<TuiAltScreen>,
8094    chat: &Arc<Container>,
8095) {
8096    let current = *state.show_images.lock().unwrap();
8097    let items = vec![
8098        SelectItem::new("yes", "Yes").with_description(if current {
8099            "Inline images (current)"
8100        } else {
8101            "Inline images"
8102        }),
8103        SelectItem::new("no", "No").with_description(if current {
8104            "Placeholder only"
8105        } else {
8106            "Placeholder only (current)"
8107        }),
8108    ];
8109    let list = Arc::new(SelectList::new(items, 5));
8110
8111    let state_sel = state.clone();
8112    let ec_sel = editor_container.clone();
8113    let editor_sel = editor.clone();
8114    let tui_sel = tui.clone();
8115    let chat_sel = chat.clone();
8116    list.on_select(Arc::new(move |item| {
8117        let on = item.value == "yes";
8118        *state_sel.show_images.lock().unwrap() = on;
8119        add_note_message(
8120            &chat_sel,
8121            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8122        );
8123        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8124    }));
8125    let state_cancel = state.clone();
8126    let ec_cancel = editor_container.clone();
8127    let editor_cancel = editor.clone();
8128    let tui_cancel = tui.clone();
8129    list.on_cancel(Arc::new(move || {
8130        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8131    }));
8132
8133    open_selector(
8134        state,
8135        editor_container,
8136        editor,
8137        tui,
8138        list,
8139        SelectorKind::Images,
8140    );
8141}
8142
8143// ===========================================================================
8144// Autocomplete
8145// ===========================================================================
8146
8147/// Refresh the autocomplete suggestion list from the current editor text +
8148/// cursor. Renders the suggestions into `autocomplete_container` (above the
8149/// editor) or clears it when there are none.
8150fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8151    let text = editor.get_text();
8152    let cursor = editor_cursor_offset(editor, &text);
8153    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8154    render_autocomplete(state, suggestions);
8155}
8156
8157/// Convert the editor's logical `(row, byte-column)` caret into the absolute
8158/// byte offset expected by autocomplete providers.
8159fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8160    let (row, col) = editor.cursor_position();
8161    let mut offset = 0;
8162    for (index, line) in text.split('\n').enumerate() {
8163        if index == row {
8164            return (offset + col.min(line.len())).min(text.len());
8165        }
8166        offset = offset.saturating_add(line.len() + 1);
8167    }
8168    text.len()
8169}
8170
8171/// Restore an editor caret from an absolute byte offset after autocomplete
8172/// replaces a span in a multi-line draft.
8173fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8174    let offset = offset.min(text.len());
8175    let mut consumed = 0;
8176    for (row, line) in text.split('\n').enumerate() {
8177        let end = consumed + line.len();
8178        if offset <= end {
8179            editor.set_cursor(row, offset - consumed);
8180            return;
8181        }
8182        consumed = end + 1;
8183    }
8184    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8185    editor.set_cursor(
8186        last_row,
8187        text.rsplit('\n').next().map(str::len).unwrap_or(0),
8188    );
8189}
8190
8191/// Render (or clear) the autocomplete suggestion list into the container.
8192fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8193    state.autocomplete_container.clear();
8194    let Some(sugg) = suggestions else {
8195        return;
8196    };
8197    if sugg.items.is_empty() {
8198        return;
8199    }
8200    // Build a compact list: top item marked with `→`, rest with `  `.
8201    // Cap the list so the dock doesn't swallow the transcript.
8202    let accent = state.theme_manager.get().colors.accent;
8203    let muted = state.theme_manager.get().colors.muted;
8204    for (i, item) in sugg
8205        .items
8206        .iter()
8207        .take(state.autocomplete_max_visible)
8208        .enumerate()
8209    {
8210        let prefix = if i == 0 { "→ " } else { "  " };
8211        let label = item.display_text();
8212        let line = if i == 0 {
8213            format!(
8214                "{prefix}{} {}",
8215                accent.fg(label),
8216                muted.fg(item.description.as_deref().unwrap_or(""))
8217            )
8218        } else {
8219            format!(
8220                "{prefix}{} {}",
8221                muted.fg(label),
8222                muted.fg(item.description.as_deref().unwrap_or(""))
8223            )
8224        };
8225        state
8226            .autocomplete_container
8227            .add_child(Arc::new(Text::new(line, 1, 0)));
8228    }
8229}
8230
8231/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
8232/// suggestion text, reposition the caret, and clear the suggestion list.
8233/// Returns `true` if a suggestion was accepted.
8234fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8235    let text = editor.get_text();
8236    let cursor = editor_cursor_offset(editor, &text);
8237    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8238        return false;
8239    };
8240    let Some(top) = sugg.items.first() else {
8241        return false;
8242    };
8243    // Replace the [start, end) span with the suggestion text. `start`/`end`
8244    // are byte offsets emitted by the providers on char boundaries, so the
8245    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
8246    let start = sugg.start.min(text.len());
8247    let end = sugg.end.min(text.len());
8248    let mut replaced = String::with_capacity(text.len() + top.text.len());
8249    replaced.push_str(&text[..start]);
8250    replaced.push_str(&top.text);
8251    // Keep the text AFTER the replaced span (mid-line completion: replacing
8252    // `[start, end)` must not drop the rest of the line).
8253    replaced.push_str(&text[end..]);
8254    if top.insert_space && !replaced.ends_with('/') {
8255        replaced.push(' ');
8256    }
8257    // New caret position: after the inserted text (byte offset; the editor
8258    // snaps `set_cursor` to a char boundary as a safety net).
8259    let new_cursor = replaced.len().min(
8260        start
8261            + top.text.len()
8262            + if top.insert_space && !top.text.ends_with('/') {
8263                1
8264            } else {
8265                0
8266            },
8267    );
8268    editor.set_text(&replaced);
8269    set_editor_cursor_offset(editor, &replaced, new_cursor);
8270    state.autocomplete_container.clear();
8271    true
8272}
8273
8274// ===========================================================================
8275// Transcript message helpers
8276// ===========================================================================
8277
8278/// Add the welcome header to the chat container.
8279fn add_welcome_message(container: &Arc<Container>) {
8280    add_welcome_message_with_capabilities(container, &[], &[]);
8281}
8282
8283/// Add the startup welcome header and a compact snapshot of active tools and
8284/// discovered skills. The snapshot reflects the harness configuration used by
8285/// the first turn, including tools contributed by extensions.
8286fn add_welcome_message_with_capabilities(
8287    container: &Arc<Container>,
8288    active_tools: &[String],
8289    skills: &[String],
8290) {
8291    let c = current_theme().colors;
8292    // Accent logotype + a dim tagline, separated from the rest by a thin
8293    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
8294    // to the body text, so the header didn't read as a header.
8295    let title = format!(
8296        "{} {}",
8297        c.accent.fg(&tui_bold("rpi")),
8298        c.muted.fg("interactive TUI")
8299    );
8300    container.add_child(Arc::new(Text::new(title, 1, 0)));
8301    container.add_child(Arc::new(Spacer::new(1)));
8302    container.add_child(Arc::new(Text::new(
8303        c.dim.fg("Type your message and press Enter to send."),
8304        1,
8305        0,
8306    )));
8307    let hint = c
8308        .dim
8309        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8310    container.add_child(Arc::new(Text::new(hint, 1, 0)));
8311    container.add_child(Arc::new(Spacer::new(1)));
8312    container.add_child(Arc::new(Text::new(
8313        welcome_capability_line("Tools", active_tools),
8314        1,
8315        0,
8316    )));
8317    container.add_child(Arc::new(Text::new(
8318        welcome_capability_line("Skills", skills),
8319        1,
8320        0,
8321    )));
8322    container.add_child(Arc::new(DynamicBorder::new()));
8323}
8324
8325/// Append update notices inside the live transcript. Fullscreen mode clears
8326/// pre-TUI stdout/stderr, so update state must be represented by components.
8327fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8328    let colors = current_theme().colors;
8329    let group = Arc::new(Container::new());
8330
8331    for warning in &report.warnings {
8332        let body = format!(
8333            "{}\n{} {}{}",
8334            colors.error.fg(&warning.message),
8335            colors.muted.fg("Run"),
8336            colors.accent.fg(&warning.command),
8337            colors.muted.fg(" to retry.")
8338        );
8339        add_update_panel(&group, "Update Failed", &body, colors.error);
8340    }
8341
8342    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8343        let body = format!(
8344            "{} {}{}",
8345            colors
8346                .muted
8347                .fg(&format!("New version {} is available. Run", notice.latest)),
8348            colors.accent.fg(&notice.command),
8349            colors.muted.fg(".")
8350        );
8351        add_update_panel(&group, "Update Available", &body, colors.warning);
8352    }
8353
8354    let package_notices = report
8355        .notices
8356        .iter()
8357        .filter(|notice| notice.name != "rpi")
8358        .collect::<Vec<_>>();
8359    if !package_notices.is_empty() {
8360        let command = package_notices[0].command.as_str();
8361        let mut lines = vec![format!(
8362            "{} {}{}",
8363            colors.muted.fg("Package updates are available. Run"),
8364            colors.accent.fg(command),
8365            colors.muted.fg(".")
8366        )];
8367        lines.push(colors.muted.fg("Packages:"));
8368        lines.extend(
8369            package_notices
8370                .into_iter()
8371                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8372        );
8373        add_update_panel(
8374            &group,
8375            "Package Updates Available",
8376            &lines.join("\n"),
8377            colors.warning,
8378        );
8379    }
8380
8381    // Other transcript producers append concurrently. Add the fully built
8382    // group in one operation so card borders and content cannot interleave
8383    // with user, assistant, tool, or extension messages.
8384    if group.child_count() > 0 {
8385        container.add_child(group);
8386    }
8387}
8388
8389fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8390    container.add_child(Arc::new(Spacer::new(1)));
8391    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8392    container.add_child(Arc::new(Text::new(
8393        format!("{}\n{body}", color.fg(&tui_bold(title))),
8394        1,
8395        0,
8396    )));
8397    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8398}
8399
8400fn welcome_capability_line(label: &str, names: &[String]) -> String {
8401    let c = current_theme().colors;
8402    let value = if names.is_empty() {
8403        "none".to_string()
8404    } else {
8405        names.join(" · ")
8406    };
8407    format!(
8408        "{} {}",
8409        c.accent.fg(&format!("{label} ({})", names.len())),
8410        c.muted.fg(&value)
8411    )
8412}
8413
8414/// Add the `/help` command listing to the chat container.
8415fn add_help_message(container: &Arc<Container>) {
8416    let c = current_theme().colors;
8417    // Section header + a thin themed rule, then a two-column command table:
8418    // `cmd` in accent, `— desc` in muted. The old single-space layout made
8419    // the description column wander depending on command length.
8420    container.add_child(Arc::new(Text::new(
8421        c.md_heading.fg(&tui_bold("📚 Available Commands")),
8422        1,
8423        0,
8424    )));
8425    container.add_child(Arc::new(Spacer::new(1)));
8426
8427    let cmds: &[(&str, &str)] = &[
8428        ("/help, /?", "Show this help message"),
8429        ("/clear, /new", "Clear the conversation"),
8430        ("/exit, /quit, /q", "Exit the application"),
8431        ("/version, /v", "Show version information"),
8432        ("/changelog", "Show recent release changes"),
8433        ("/model, /m", "Choose a model (live switch)"),
8434        ("/thinking, /think", "Set reasoning depth (selector)"),
8435        ("/tools", "Toggle built-in tools on/off"),
8436        ("/images", "Toggle inline image rendering"),
8437        ("/session", "List saved sessions"),
8438        ("/theme", "Choose a theme (selector)"),
8439        ("/compact", "Compact the conversation"),
8440        ("/copy", "Copy last reply to clipboard"),
8441        ("/hotkeys", "Show keyboard shortcuts"),
8442        ("/armin", "🐾 Easter egg"),
8443        ("/earendil", "Earendil announcement"),
8444    ];
8445    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8446    for (cmd, desc) in cmds {
8447        let row = format!(
8448            "  {:<cmd_w$}  {}  {}",
8449            c.accent.fg(cmd),
8450            c.dim.fg("—"),
8451            c.muted.fg(desc)
8452        );
8453        container.add_child(Arc::new(Text::new(row, 1, 0)));
8454    }
8455    container.add_child(Arc::new(Spacer::new(1)));
8456}
8457
8458/// Add the `/version` block to the chat container.
8459fn add_version_message(container: &Arc<Container>) {
8460    let c = current_theme().colors;
8461    container.add_child(Arc::new(Text::new(
8462        c.md_heading.fg(&tui_bold("📦 Version Information")),
8463        1,
8464        0,
8465    )));
8466    container.add_child(Arc::new(Spacer::new(1)));
8467    // Use the crate version (kept in sync via `version.workspace = true`)
8468    // instead of the stale hardcoded "v0.1.2".
8469    container.add_child(Arc::new(Text::new(
8470        format!(
8471            "  {} {}",
8472            c.muted.fg("rpi-cli"),
8473            c.text.fg(&format!("v{}", crate::VERSION))
8474        ),
8475        1,
8476        0,
8477    )));
8478    container.add_child(Arc::new(Text::new(
8479        format!(
8480            "  {}",
8481            c.dim.fg("Rust implementation of pi coding agent TUI")
8482        ),
8483        1,
8484        0,
8485    )));
8486    container.add_child(Arc::new(Spacer::new(1)));
8487}
8488
8489/// Add a compact `/changelog` block to the chat container. Keep this local to
8490/// the binary so the command remains useful in installed builds without a
8491/// source checkout or a network request.
8492fn add_changelog_message(container: &Arc<Container>) {
8493    let c = current_theme().colors;
8494    container.add_child(Arc::new(Text::new(
8495        c.md_heading.fg(&tui_bold("Recent Changes")),
8496        1,
8497        0,
8498    )));
8499    container.add_child(Arc::new(Spacer::new(1)));
8500    let entries = [
8501        (
8502            "Native parity phase 1",
8503            "models, images, trust, export, and JSON events",
8504        ),
8505        (
8506            "TUI controls",
8507            "external editor, thinking levels, and tool output toggles",
8508        ),
8509        (
8510            "Provider auth",
8511            "OpenAI-compatible API key aliases and gateway headers",
8512        ),
8513    ];
8514    for (release, summary) in entries {
8515        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
8516        container.add_child(Arc::new(Text::new(row, 1, 0)));
8517    }
8518    container.add_child(Arc::new(Text::new(
8519        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8520        1,
8521        0,
8522    )));
8523    container.add_child(Arc::new(Spacer::new(1)));
8524}
8525
8526/// Add the `/hotkeys` block to the chat container.
8527fn add_hotkeys_message(container: &Arc<Container>) {
8528    let c = current_theme().colors;
8529    container.add_child(Arc::new(Text::new(
8530        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
8531        1,
8532        0,
8533    )));
8534    container.add_child(Arc::new(Spacer::new(1)));
8535    let keys: &[(&str, &str)] = &[
8536        ("Enter", "Send message"),
8537        ("Shift+Enter", "New line"),
8538        ("Tab", "Accept autocomplete suggestion"),
8539        ("Ctrl+A / Ctrl+E", "Line start / end"),
8540        (
8541            "Ctrl+K / Ctrl+U",
8542            "Kill to end / start of line (Ctrl+Y yanks)",
8543        ),
8544        ("Ctrl+- / Ctrl+R", "Undo / redo"),
8545        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8546        ("Alt+Backspace", "Kill previous word"),
8547        ("Ctrl+C", "Abort a run, or exit when idle"),
8548        ("Esc", "Abort a running prompt"),
8549        ("Ctrl+L", "Open model selector"),
8550        ("Ctrl+M", "Cycle to the next model (live)"),
8551        ("Ctrl+O", "Expand/collapse all tool output"),
8552        ("Ctrl+T", "Show/hide reasoning blocks"),
8553        ("PageUp/Down", "Scroll transcript by one page"),
8554        ("Home / End", "Jump to transcript start / latest output"),
8555    ];
8556    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8557    for (key, desc) in keys {
8558        let row = format!(
8559            "  {:<key_w$}  {}  {}",
8560            c.accent.fg(key),
8561            c.dim.fg("—"),
8562            c.muted.fg(desc)
8563        );
8564        container.add_child(Arc::new(Text::new(row, 1, 0)));
8565    }
8566    container.add_child(Arc::new(Spacer::new(1)));
8567}
8568
8569/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
8570/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
8571/// plain `> text` echo. A trailing Spacer(1) separates it from the next
8572// transcript entry (every entry contributes one trailing spacer so
8573// consecutive turns are separated by exactly one blank line).
8574fn add_user_message(container: &Arc<Container>, text: &str) {
8575    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
8576    container.add_child(Arc::new(Spacer::new(1)));
8577}
8578
8579/// Add an error message to the chat container.
8580fn add_error_message(container: &Arc<Container>, text: &str) {
8581    let c = current_theme().colors;
8582    let text = sanitize_error_message(text);
8583    container.add_child(Arc::new(Text::new(
8584        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
8585        1,
8586        0,
8587    )));
8588    container.add_child(Arc::new(Spacer::new(1)));
8589}
8590
8591/// Keep provider diagnostics printable in the transcript. HTTP error bodies
8592/// may contain carriage returns, terminal escapes, or an unexpectedly large
8593/// JSON payload; letting those bytes reach the renderer can corrupt the input
8594/// row or make the whole frame exceed terminal limits.
8595fn sanitize_error_message(text: &str) -> String {
8596    const MAX_ERROR_CHARS: usize = 16 * 1024;
8597    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
8598    let mut count = 0;
8599    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
8600    let mut escape_mode = 0u8;
8601    for ch in text.chars() {
8602        if escape_mode != 0 {
8603            match escape_mode {
8604                1 if ch == '[' => escape_mode = 2,
8605                1 if ch == ']' => escape_mode = 3,
8606                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
8607                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
8608                3 if ch == '\x07' => escape_mode = 0,
8609                _ => {}
8610            }
8611            continue;
8612        }
8613        if ch == '\x1b' {
8614            escape_mode = 1;
8615            continue;
8616        }
8617        if count >= MAX_ERROR_CHARS {
8618            result.push_str("…");
8619            break;
8620        }
8621        match ch {
8622            '\n' | '\t' => {
8623                result.push(ch);
8624                count += 1;
8625            }
8626            '\r' => {}
8627            c if c.is_control() => {}
8628            c => {
8629                result.push(c);
8630                count += 1;
8631            }
8632        }
8633    }
8634    if result.trim().is_empty() {
8635        "Provider request failed.".to_string()
8636    } else {
8637        result
8638    }
8639}
8640
8641/// Add a neutral note (e.g. unsupported-command message) to the chat container.
8642fn add_note_message(container: &Arc<Container>, text: &str) {
8643    let c = current_theme().colors;
8644    container.add_child(Arc::new(Text::new(
8645        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
8646        1,
8647        0,
8648    )));
8649    container.add_child(Arc::new(Spacer::new(1)));
8650}
8651
8652/// Render the `/context` panel: a transcript message listing the discovered
8653/// context files, skills, and prompt templates loaded for this session
8654/// (Part A resource discovery). Reads the harness resources snapshot captured
8655/// at TUI startup (the blocking submit handler can't `await get_resources()`.
8656///
8657/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
8658/// via `/reload`); here it's a transcript note rather than an overlay since the
8659/// resource set is session-static between `/reload`s (deferred).
8660fn show_context_panel(
8661    chat: &Arc<Container>,
8662    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
8663) {
8664    let skills = resources.skills.as_deref().unwrap_or(&[]);
8665    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
8666    let mut lines: Vec<String> = Vec::new();
8667    lines.push("📂 Discovered resources for this session:".into());
8668
8669    if skills.is_empty() {
8670        lines.push(
8671            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
8672        );
8673    } else {
8674        lines.push(format!("  Skills ({}):", skills.len()));
8675        for s in skills {
8676            let marker = if s.disable_model_invocation == Some(true) {
8677                " [hidden]"
8678            } else {
8679                ""
8680            };
8681            let desc: String = s.description.chars().take(72).collect();
8682            lines.push(format!("    • {}{marker} — {desc}", s.name));
8683        }
8684    }
8685
8686    if templates.is_empty() {
8687        lines.push(
8688            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8689        );
8690    } else {
8691        lines.push(format!("  Prompt templates ({}):", templates.len()));
8692        for t in templates {
8693            let desc = t
8694                .description
8695                .as_deref()
8696                .unwrap_or("(no description)")
8697                .chars()
8698                .take(72)
8699                .collect::<String>();
8700            lines.push(format!("    • /{} — {desc}", t.name));
8701        }
8702    }
8703    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8704    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8705    lines.push(
8706        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8707            .into(),
8708    );
8709    let body = lines.join("\n");
8710    container_note_block(chat, &body);
8711}
8712
8713/// Append a multi-line neutral note (header line + body) to the chat container.
8714fn container_note_block(container: &Arc<Container>, body: &str) {
8715    for line in body.lines() {
8716        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8717    }
8718    container.add_child(Arc::new(Spacer::new(1)));
8719}
8720
8721// ===========================================================================
8722// TUI support + entry detection
8723// ===========================================================================
8724
8725/// Check if the terminal supports TUI mode.
8726pub fn is_tui_supported() -> bool {
8727    std::io::stdout().is_terminal()
8728}
8729
8730// Keep the `Color` import used (theme accent rendering in autocomplete).
8731#[allow(unused_imports)]
8732use rpi_tui::Color as _Color;
8733
8734#[cfg(test)]
8735mod tests {
8736    use super::*;
8737    use rpi_tui::Component;
8738
8739    #[test]
8740    fn verbose_overrides_quiet_startup_listing() {
8741        assert!(should_show_startup_listing(false, false));
8742        assert!(should_show_startup_listing(true, false));
8743        assert!(should_show_startup_listing(true, true));
8744        assert!(!should_show_startup_listing(false, true));
8745    }
8746
8747    #[test]
8748    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8749        let tmp = tempfile::tempdir().unwrap();
8750        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8751        let local_root = tmp.path().join("local-package");
8752        std::fs::create_dir_all(git_root.join(".git")).unwrap();
8753        std::fs::create_dir_all(&local_root).unwrap();
8754        std::fs::write(
8755            git_root.join("package.json"),
8756            r#"{"name":"git-demo","version":"1.0.0"}"#,
8757        )
8758        .unwrap();
8759        std::fs::write(
8760            local_root.join("package.json"),
8761            r#"{"name":"local-demo","version":"1.0.0"}"#,
8762        )
8763        .unwrap();
8764        let resources = crate::packages::discover(
8765            tmp.path(),
8766            &[
8767                "git:github.com/example/repo".to_string(),
8768                local_root.to_string_lossy().into_owned(),
8769            ],
8770        );
8771        assert_eq!(resources.packages.len(), 2);
8772
8773        let filtered = git_only_update_resources(resources);
8774
8775        assert_eq!(filtered.packages.len(), 1);
8776        assert!(matches!(
8777            filtered.packages[0].source,
8778            crate::packages::PackageSource::Git
8779        ));
8780    }
8781
8782    #[test]
8783    fn trust_command_uses_the_session_cwd_after_process_chdir() {
8784        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8785        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8786        const TEST_NAME: &str =
8787            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8788
8789        if std::env::var_os(CHILD_ENV).is_some() {
8790            let session_cwd = std::path::PathBuf::from(
8791                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8792            );
8793            let changed_cwd = std::env::current_dir().unwrap();
8794
8795            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8796
8797            assert_eq!(
8798                crate::config::project_trust_decision(&session_cwd).unwrap(),
8799                Some(true)
8800            );
8801            assert_eq!(
8802                crate::config::project_trust_decision(&changed_cwd).unwrap(),
8803                None
8804            );
8805            return;
8806        }
8807
8808        let tmp = tempfile::tempdir().unwrap();
8809        let session_cwd = tmp.path().join("session-project");
8810        let changed_cwd = tmp.path().join("extension-cwd");
8811        let agent_dir = tmp.path().join("agent");
8812        std::fs::create_dir_all(&session_cwd).unwrap();
8813        std::fs::create_dir_all(&changed_cwd).unwrap();
8814        std::fs::create_dir_all(&agent_dir).unwrap();
8815
8816        let status = std::process::Command::new(std::env::current_exe().unwrap())
8817            .arg("--exact")
8818            .arg(TEST_NAME)
8819            .arg("--nocapture")
8820            .env(CHILD_ENV, "1")
8821            .env(SESSION_CWD_ENV, &session_cwd)
8822            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8823            .current_dir(&changed_cwd)
8824            .status()
8825            .unwrap();
8826
8827        assert!(status.success(), "child test process failed: {status}");
8828    }
8829
8830    #[test]
8831    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8832        let global = crate::settings::Settings {
8833            editor_padding_x: Some(3),
8834            autocomplete_max_visible: Some(6),
8835            hide_thinking_block: Some(false),
8836            quiet_startup: Some(true),
8837            show_terminal_progress: Some(true),
8838            ..Default::default()
8839        };
8840        let rpi_project = crate::settings::Settings {
8841            editor_padding_x: Some(0),
8842            hide_thinking_block: Some(true),
8843            quiet_startup: Some(false),
8844            show_terminal_progress: Some(false),
8845            ..Default::default()
8846        };
8847        let pi_project = crate::settings::Settings {
8848            editor_padding_x: Some(9),
8849            autocomplete_max_visible: Some(12),
8850            quiet_startup: Some(true),
8851            ..Default::default()
8852        };
8853
8854        assert_eq!(
8855            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8856            TuiStartupSettings {
8857                editor_padding_x: 0,
8858                autocomplete_max_visible: 12,
8859                hide_thinking: true,
8860                quiet_startup: false,
8861                show_terminal_progress: false,
8862            }
8863        );
8864    }
8865
8866    #[test]
8867    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8868        let global = crate::settings::Settings {
8869            quiet_startup: Some(false),
8870            ..Default::default()
8871        };
8872        let rpi_project = crate::settings::Settings::default();
8873        let pi_project = crate::settings::Settings {
8874            quiet_startup: Some(true),
8875            ..Default::default()
8876        };
8877
8878        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8879
8880        assert!(resolved.quiet_startup);
8881    }
8882
8883    #[test]
8884    fn tui_startup_settings_use_global_values_without_project_fields() {
8885        let global = crate::settings::Settings {
8886            editor_padding_x: Some(7),
8887            autocomplete_max_visible: Some(8),
8888            hide_thinking_block: Some(true),
8889            quiet_startup: Some(true),
8890            show_terminal_progress: Some(false),
8891            ..Default::default()
8892        };
8893
8894        assert_eq!(
8895            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8896            TuiStartupSettings {
8897                editor_padding_x: 7,
8898                autocomplete_max_visible: 8,
8899                hide_thinking: true,
8900                quiet_startup: true,
8901                show_terminal_progress: false,
8902            }
8903        );
8904    }
8905
8906    #[test]
8907    fn tui_startup_settings_ignore_untrusted_project_values() {
8908        let global = crate::settings::Settings {
8909            quiet_startup: Some(false),
8910            show_terminal_progress: Some(true),
8911            ..Default::default()
8912        };
8913        let project = crate::settings::Settings {
8914            quiet_startup: Some(true),
8915            show_terminal_progress: Some(false),
8916            ..Default::default()
8917        };
8918
8919        let resolved = resolve_tui_startup_settings(&global, &[project], false);
8920
8921        assert!(!resolved.quiet_startup);
8922        assert!(resolved.show_terminal_progress);
8923    }
8924
8925    #[test]
8926    fn transcript_page_uses_viewport_with_overlap() {
8927        assert_eq!(transcript_page_size(24), 20);
8928        assert_eq!(transcript_page_size(4), 1);
8929        assert_eq!(transcript_page_size(0), 1);
8930    }
8931
8932    #[test]
8933    fn key_repeat_is_dispatched_but_release_is_not() {
8934        assert!(should_dispatch_key(KeyEventKind::Press));
8935        assert!(should_dispatch_key(KeyEventKind::Repeat));
8936        assert!(!should_dispatch_key(KeyEventKind::Release));
8937    }
8938
8939    #[test]
8940    fn key_event_encoding_matches_pi_keybinding_protocol() {
8941        let key = |code, modifiers| KeyEvent::new(code, modifiers);
8942        assert_eq!(
8943            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
8944            "\r"
8945        );
8946        assert_eq!(
8947            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
8948            "\x1b[13;2u"
8949        );
8950        assert_eq!(
8951            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
8952            "\x1b[9;2u"
8953        );
8954        assert_eq!(
8955            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
8956            "\x1b[Z"
8957        );
8958        assert_eq!(
8959            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
8960            "\x1b[Z"
8961        );
8962        assert_eq!(
8963            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
8964            "\x03"
8965        );
8966        assert_eq!(
8967            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
8968            "\x0f"
8969        );
8970        assert_eq!(
8971            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
8972            "!"
8973        );
8974        assert_eq!(
8975            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
8976            "1"
8977        );
8978    }
8979
8980    #[test]
8981    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
8982        let bridge = JsDialogBridge::default();
8983        let (sender, receiver) = std_mpsc::channel();
8984        bridge.pending.lock().unwrap().push_back(JsDialogPending {
8985            request: JsDialogRequest {
8986                id: "dialog-1".into(),
8987                method: "input".into(),
8988                title: String::new(),
8989                message: String::new(),
8990                options: Vec::new(),
8991                placeholder: None,
8992                prefill: None,
8993            },
8994            result: sender,
8995        });
8996
8997        // Model the cancellation arriving after the queue entry has been
8998        // removed but before the TUI has installed the native widget.
8999        bridge.cancel("dialog-1");
9000        assert!(bridge.take_pending().is_none());
9001        assert_eq!(
9002            receiver.recv().unwrap(),
9003            serde_json::json!({ "cancelled": true })
9004        );
9005        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9006    }
9007
9008    #[test]
9009    fn test_layout_renders_welcome_message() {
9010        let chat = Arc::new(Container::new());
9011        add_welcome_message(&chat);
9012
9013        let scroll = Arc::new(ScrollView::new(
9014            chat.clone(),
9015            ScrollViewOptions {
9016                follow: FollowMode::End,
9017                primary: true,
9018                ..Default::default()
9019            },
9020        ));
9021
9022        let editor = Arc::new(Editor::new(
9023            EditorOptions {
9024                padding_x: 1,
9025                ..Default::default()
9026            },
9027            EditorStyle::default(),
9028            Arc::new(rpi_tui::Keybindings::new()),
9029        ));
9030        let dock = Arc::new(Container::new());
9031        dock.add_child(editor);
9032
9033        let footer = Arc::new(FooterComponent::new());
9034
9035        let root = VStack::from_children(vec![
9036            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9037            StackChild::Entry(StackEntry::new(dock)),
9038            StackChild::Entry(StackEntry::new(footer)),
9039        ]);
9040
9041        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9042
9043        let all: String = frame.lines.join("\n");
9044        assert!(
9045            all.contains("rpi"),
9046            "Welcome message not found. Rendered: {}",
9047            all
9048        );
9049        assert!(
9050            all.contains("Type your message"),
9051            "Help text not found. Rendered: {}",
9052            all
9053        );
9054    }
9055
9056    #[test]
9057    fn test_chat_container_has_welcome_content() {
9058        let chat = Arc::new(Container::new());
9059        add_welcome_message_with_capabilities(
9060            &chat,
9061            &["read".into(), "bash".into(), "web_fetch".into()],
9062            &["rust-review".into(), "release".into()],
9063        );
9064
9065        let lines = chat.render(80);
9066        let all: String = lines.join("\n");
9067        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
9068        // joined by an ANSI reset — strip ANSI before checking the substring.
9069        let plain = strip_ansi(&all);
9070        assert!(
9071            plain.contains("rpi"),
9072            "Welcome message not in chat container: {:?}",
9073            lines
9074        );
9075        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9076        assert!(
9077            plain.contains("read · bash · web_fetch"),
9078            "Tool names missing: {plain}"
9079        );
9080        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9081        assert!(
9082            plain.contains("rust-review · release"),
9083            "Skill names missing: {plain}"
9084        );
9085    }
9086
9087    #[test]
9088    fn update_notices_render_inside_the_transcript() {
9089        let chat = Arc::new(Container::new());
9090        let report = crate::updates::UpdateReport {
9091            notices: vec![
9092                crate::updates::UpdateNotice {
9093                    name: "rpi".into(),
9094                    current: "0.1.10".into(),
9095                    latest: "0.1.11".into(),
9096                    command: "rpi update".into(),
9097                },
9098                crate::updates::UpdateNotice {
9099                    name: "rpi-search".into(),
9100                    current: "0.1.0".into(),
9101                    latest: "0.1.1".into(),
9102                    command: "rpi pi-package update".into(),
9103                },
9104            ],
9105            warnings: vec![crate::updates::UpdateWarning {
9106                message: "The previously scheduled rpi update failed: access denied".into(),
9107                command: "rpi update".into(),
9108            }],
9109        };
9110
9111        add_update_notices(&chat, &report);
9112
9113        assert_eq!(chat.child_count(), 1);
9114        let plain = strip_ansi(&chat.render(80).join("\n"));
9115        assert!(plain.contains("Update Failed"), "{plain}");
9116        assert!(
9117            plain.contains("rpi update failed: access denied"),
9118            "{plain}"
9119        );
9120        assert!(plain.contains("Update Available"), "{plain}");
9121        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9122        assert!(plain.contains("rpi update"), "{plain}");
9123        assert!(plain.contains("Package Updates Available"), "{plain}");
9124        assert!(plain.contains("rpi pi-package update"), "{plain}");
9125        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9126    }
9127
9128    #[test]
9129    fn empty_update_report_does_not_add_transcript_content() {
9130        let chat = Arc::new(Container::new());
9131
9132        add_update_notices(&chat, &crate::updates::UpdateReport::default());
9133
9134        assert_eq!(chat.child_count(), 0);
9135    }
9136
9137    #[test]
9138    fn skill_reads_are_detected_by_path() {
9139        let name = skill_tool_name(
9140            "read",
9141            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9142        );
9143        assert_eq!(name.as_deref(), Some("release"));
9144
9145        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9146        assert!(name.is_none());
9147
9148        // Only `read` (not other tools) triggers the skill box.
9149        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9150    }
9151
9152    #[test]
9153    fn welcome_capabilities_show_empty_state() {
9154        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9155        assert_eq!(plain, "Skills (0) none");
9156    }
9157
9158    #[test]
9159    fn empty_tool_progress_is_deferred_until_start() {
9160        let empty = rpi_agent::AgentToolResult::default();
9161        assert!(!tool_update_has_payload("", &empty));
9162
9163        let text = rpi_agent::AgentToolResult::text("partial output");
9164        assert!(tool_update_has_payload("partial output", &text));
9165
9166        let details = rpi_agent::AgentToolResult {
9167            details: serde_json::json!({"path": "src/lib.rs"}),
9168            ..Default::default()
9169        };
9170        assert!(tool_update_has_payload("", &details));
9171    }
9172
9173    #[test]
9174    fn completed_stream_reconciles_the_final_tail_before_detaching() {
9175        let component = Arc::new(AssistantMessageComponent::new(
9176            AssistantMessageOptions::default(),
9177        ));
9178        component.set_streaming(true);
9179        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9180
9181        let current = Mutex::new(Some(component.clone()));
9182        let cached = Mutex::new("partial response".to_string());
9183        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9184        final_message.content = vec![Content::text(
9185            "partial response with the previously missing final tail",
9186        )];
9187        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9188
9189        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
9190
9191        assert!(current.lock().unwrap().is_none());
9192        assert_eq!(
9193            cached.lock().unwrap().as_str(),
9194            "partial response with the previously missing final tail"
9195        );
9196        let rendered = strip_ansi(&component.render(100).join("\n"));
9197        assert!(
9198            rendered.contains("previously missing final tail"),
9199            "{rendered}"
9200        );
9201    }
9202
9203    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
9204    /// replaces the editor text, the NEXT rendered frame must show the
9205    /// completed text (" /model " with the caret after it), not the old
9206    /// prefix. Mirrors the real dock layout (autocomplete_container above the
9207    /// bordered editor) and drives the same accept path the Tab handler uses.
9208    #[test]
9209    fn tab_accept_suggestion_reflects_in_next_render() {
9210        use rpi_tui::render_layout_frame;
9211
9212        let editor = Arc::new(Editor::new(
9213            EditorOptions {
9214                padding_x: 1,
9215                ..Default::default()
9216            },
9217            EditorStyle::default(),
9218            Arc::new(rpi_tui::Keybindings::new()),
9219        ));
9220        editor.set_focused(true);
9221        let editor_container = Arc::new(Container::new());
9222        editor_container.add_child(editor.clone());
9223        let autocomplete_container = Arc::new(Container::new());
9224        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9225        let dock = Arc::new(VStack::from_children(vec![
9226            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9227            StackChild::Entry(
9228                StackEntry::new(editor_container.clone())
9229                    .shrink(0)
9230                    .min_size(3),
9231            ),
9232            StackChild::Entry(StackEntry::new(footer)),
9233        ]));
9234
9235        // Simulate the user typing "/mo" (the popup shows suggestions).
9236        let mut manager = AutocompleteManager::new();
9237        let mut combined = CombinedAutocompleteProvider::new();
9238        combined.add_provider(Arc::new(
9239            SlashCommandAutocompleteProvider::with_default_commands(),
9240        ));
9241        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9242        manager.set_provider(Arc::new(combined));
9243        // Simulate typing "/mo" via the real insert path (advances the caret
9244        // by char length, like `handle_key` does).
9245        editor.insert("/mo");
9246        assert_eq!(editor.cursor_position(), (0, 3));
9247
9248        let frame_before = render_layout_frame(dock.clone(), 80, 10);
9249        assert!(
9250            frame_before.lines.iter().any(|l| l.contains("/mo")),
9251            "precondition: editor shows the typed prefix. Frame rows:\n{}",
9252            frame_before
9253                .lines
9254                .iter()
9255                .map(|l| format!("  [{l}]"))
9256                .collect::<Vec<_>>()
9257                .join("\n")
9258        );
9259
9260        // Tab: accept the top suggestion (the same code path as the key loop).
9261        let text = editor.get_text();
9262        let cursor = editor_cursor_offset(&editor, &text);
9263        let sugg = manager
9264            .get_suggestions(&text, cursor)
9265            .expect("slash suggestions for /mo");
9266        let top = sugg.items.first().expect("at least one suggestion");
9267        let start = sugg.start.min(text.len());
9268        let end = sugg.end.min(text.len());
9269        let mut replaced = String::new();
9270        replaced.push_str(&text[..start]);
9271        replaced.push_str(&top.text);
9272        replaced.push_str(&text[end..]);
9273        if top.insert_space && !replaced.ends_with('/') {
9274            replaced.push(' ');
9275        }
9276        let new_cursor = start + top.text.len();
9277        editor.set_text(&replaced);
9278        set_editor_cursor_offset(&editor, &replaced, new_cursor);
9279        autocomplete_container.clear();
9280        assert_eq!(editor.get_text(), "/model");
9281
9282        // The next render MUST display the completed text.
9283        let frame_after = render_layout_frame(dock, 80, 10);
9284        let all: String = frame_after.lines.join("\n");
9285        assert!(
9286            all.contains("/model"),
9287            "completed text missing from next render. Got:\n{all}"
9288        );
9289        // The caret must sit AFTER the completed command (the snap_boundary
9290        // regression put it one char early: "/mode|l" with the final char
9291        // dangling past the caret).
9292        let editor_line = frame_after
9293            .lines
9294            .iter()
9295            .find(|l| l.contains("/model"))
9296            .expect("editor row with completed text");
9297        assert!(
9298            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9299            "caret must follow the full completed text. Got: {editor_line:?}"
9300        );
9301    }
9302
9303    #[test]
9304    fn multiline_autocomplete_preserves_row_and_column() {
9305        let editor = Arc::new(Editor::simple());
9306        editor.set_text("first\n/mo");
9307        editor.set_cursor(1, 3);
9308
9309        let text = editor.get_text();
9310        assert_eq!(editor_cursor_offset(&editor, &text), 9);
9311
9312        let replaced = "first\n/model";
9313        editor.set_text(replaced);
9314        set_editor_cursor_offset(&editor, replaced, 12);
9315        assert_eq!(editor.cursor_position(), (1, 6));
9316    }
9317
9318    #[test]
9319    fn test_slash_command_dispatch() {
9320        // The registry is the single source of truth for dispatch: `find(token)`
9321        // returns the command (by name or alias) whose `name()` is the canonical
9322        // form, or `None` for an unknown token. This replaces the old enum-based
9323        // `handle_slash_command` assertions with equivalent registry lookups.
9324        let registry = build_builtin_registry();
9325
9326        // Helper: a token resolves to the command with this canonical name.
9327        let resolves_to = |token: &str, canonical: &str| {
9328            let found = registry.find(token).expect("{token} should resolve");
9329            assert_eq!(
9330                found.name(),
9331                canonical,
9332                "{token} resolved to {} (expected {canonical})",
9333                found.name()
9334            );
9335        };
9336
9337        resolves_to("/help", "/help");
9338        resolves_to("/?", "/help"); // alias → canonical
9339        resolves_to("/clear", "/clear");
9340        resolves_to("/new", "/clear"); // alias
9341        resolves_to("/q", "/exit"); // alias
9342        resolves_to("/quit", "/exit"); // alias
9343        resolves_to("/version", "/version");
9344        resolves_to("/v", "/version"); // alias
9345        resolves_to("/changelog", "/changelog");
9346        resolves_to("/hotkeys", "/hotkeys");
9347        resolves_to("/model", "/model");
9348        resolves_to("/m", "/model"); // alias
9349        resolves_to("/theme", "/theme");
9350        resolves_to("/session", "/session");
9351        resolves_to("/resume", "/session"); // alias
9352        resolves_to("/compact", "/compact");
9353        resolves_to("/copy", "/copy");
9354        resolves_to("/thinking", "/thinking");
9355        resolves_to("/think", "/thinking"); // alias
9356        resolves_to("/tools", "/tools");
9357        resolves_to("/images", "/images");
9358        resolves_to("/armin", "/armin");
9359        resolves_to("/earendil", "/earendil");
9360        resolves_to("/context", "/context");
9361        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
9362        resolves_to("/settings", "/settings");
9363        resolves_to("/name", "/name");
9364        resolves_to("/export", "/export");
9365
9366        // Unknown token → not found.
9367        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9368    }
9369
9370    #[test]
9371
9372    fn test_registry_visible_entries_cover_dispatch() {
9373        // The autocomplete list is derived from the registry, so every visible
9374        // command the dispatcher recognizes must appear in it — by construction,
9375        // but this guards against a future command being registered with
9376        // `visible()` / a non-empty description that the builder drops.
9377        let registry = build_builtin_registry();
9378        let names: Vec<String> = registry
9379            .visible_entries()
9380            .iter()
9381            .map(|c| c.name.clone())
9382            .collect();
9383        for recognized in [
9384            "/help",
9385            "/clear",
9386            "/new",
9387            "/exit",
9388            "/quit",
9389            "/version",
9390            "/changelog",
9391            "/model",
9392            "/session",
9393            "/theme",
9394            "/compact",
9395            "/copy",
9396            "/hotkeys",
9397            "/tools",
9398            "/images",
9399            "/thinking",
9400            "/armin",
9401            "/earendil",
9402        ] {
9403            assert!(
9404                names.contains(&recognized.to_string()),
9405                "{recognized} missing from autocomplete list"
9406            );
9407        }
9408        // Hidden commands stay off the list.
9409        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9410            assert!(
9411                !names.contains(&hidden.to_string()),
9412                "{hidden} should be hidden from autocomplete"
9413            );
9414        }
9415    }
9416
9417    #[test]
9418    fn test_agent_event_mapping_creates_assistant_and_tool() {
9419        // Synthetic AgentEvent sequence → UI mutations, exercised against the
9420        // real drain handler with a no-op TUI stand-in.
9421        use rpi_ai::types::{
9422            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9423            ToolCall, ToolCallType, Usage,
9424        };
9425
9426        let state = Arc::new(TuiState {
9427            current_assistant: std::sync::Mutex::new(None),
9428            tool_components: std::sync::Mutex::new(HashMap::new()),
9429            bash_components: std::sync::Mutex::new(HashMap::new()),
9430            themes_enabled: true,
9431            hide_thinking: std::sync::Mutex::new(false),
9432            tool_outputs_expanded: std::sync::Mutex::new(false),
9433            show_terminal_progress: true,
9434            status: std::sync::Mutex::new(RunStatus::Idle),
9435            js_preparation_cancel: std::sync::Mutex::new(None),
9436            footer: Arc::new(FooterComponent::new()),
9437            status_container: Arc::new(Container::new()),
9438            chat_container: Arc::new(Container::new()),
9439            loader: Arc::new(Loader::new()),
9440            last_assistant_text: std::sync::Mutex::new(String::new()),
9441            active_selector: std::sync::Mutex::new(None),
9442            active_extension_editor: std::sync::Mutex::new(None),
9443            active_extension_input: std::sync::Mutex::new(None),
9444            active_extension_cancel: std::sync::Mutex::new(None),
9445            autocomplete: AutocompleteManager::new(),
9446            autocomplete_container: Arc::new(Container::new()),
9447            autocomplete_max_visible: 5,
9448            pending_images: std::sync::Mutex::new(Vec::new()),
9449            theme_manager: Arc::new(ThemeManager::new()),
9450            tui: None,
9451            current_model_id: std::sync::Mutex::new(String::new()),
9452            show_images: std::sync::Mutex::new(true),
9453            history: std::sync::Mutex::new(Vec::new()),
9454            history_index: std::sync::Mutex::new(-1),
9455            history_draft: std::sync::Mutex::new(None),
9456            last_input_tokens: std::sync::Mutex::new(0),
9457            scoped_edit: std::sync::Mutex::new(None),
9458            markdown_transformer: std::sync::Mutex::new(None),
9459            extension_session: Arc::new(std::sync::Mutex::new(
9460                rpi_extensions::ExtensionSession::none(),
9461            )),
9462        });
9463
9464        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
9465        // terminal; instead, exercise the *mutation* half directly against a
9466        // captured chat container via a synthetic message-start event's data.
9467        let assistant = AssistantMessage {
9468            role: rpi_ai::types::AssistantRole,
9469            content: vec![
9470                Content::Thinking(ThinkingContent {
9471                    kind: ThinkingContentType,
9472                    thinking: "Reasoning about the reply.".into(),
9473                    thinking_signature: None,
9474                    redacted: false,
9475                }),
9476                Content::Text(TextContent {
9477                    kind: TextContentType,
9478                    text: "Hello.".into(),
9479                    text_signature: None,
9480                }),
9481                Content::ToolCall(ToolCall {
9482                    kind: ToolCallType,
9483                    id: "tc1".into(),
9484                    name: "bash".into(),
9485                    arguments: serde_json::json!({"command": "echo hi"}),
9486                    thought_signature: None,
9487                    namespace: None,
9488                }),
9489            ],
9490            api: rpi_ai::Api::AnthropicMessages,
9491            provider: "anthropic".into(),
9492            model: "claude-sonnet-5".into(),
9493            response_model: None,
9494            response_id: None,
9495            usage: Usage::zero(),
9496            stop_reason: StopReason::Stop,
9497            deferred: None,
9498            error_message: None,
9499            raw_stop_reason: None,
9500            end_turn: None,
9501            timestamp: 0,
9502        };
9503
9504        // Manually apply the MessageStart assistant branch logic (mirrors the
9505        // drain handler, without needing a TuiAltScreen).
9506        let comp = Arc::new(AssistantMessageComponent::new(
9507            AssistantMessageOptions::default(),
9508        ));
9509        comp.set_streaming(true);
9510        comp.update_blocks(&assistant_blocks(&assistant));
9511        let chat = Arc::new(Container::new());
9512        chat.add_child(comp.clone());
9513        *state.current_assistant.lock().unwrap() = Some(comp);
9514
9515        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
9516        for c in &assistant.content {
9517            if let Content::ToolCall(tc) = c {
9518                let mut tools = state.tool_components.lock().unwrap();
9519                if !tools.contains_key(&tc.id) {
9520                    let tc_comp = Arc::new(ToolExecutionComponent::new(
9521                        &tc.name,
9522                        &tc.arguments.to_string(),
9523                    ));
9524                    tc_comp.set_running();
9525                    chat.add_child(tc_comp.clone());
9526                    tools.insert(tc.id.clone(), tc_comp);
9527                }
9528            }
9529        }
9530
9531        // Assert: the assistant component rendered the text + the thinking
9532        // block (the update_blocks path keeps thinking visible), and a tool
9533        // component was registered.
9534        let rendered = chat.render(80);
9535        let joined: String = rendered.join("\n");
9536        assert!(
9537            joined.contains("Hello."),
9538            "assistant text not rendered: {joined}"
9539        );
9540        assert!(
9541            joined.contains("Reasoning about the reply."),
9542            "thinking block not rendered: {joined}"
9543        );
9544        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9545        assert!(state.current_assistant.lock().unwrap().is_some());
9546
9547        // Manually apply ToolExecutionEnd (mirrors drain).
9548        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
9549        ended.set_result("hi", false);
9550        assert!(state.tool_components.lock().unwrap().is_empty());
9551
9552        // A running bash panel owns the visible spinner. The global loader is
9553        // hidden until the last concurrent bash tool completes, then restored
9554        // while the agent remains in the Working state.
9555        assert!(state.try_start_working());
9556        assert!(
9557            !state.try_start_working(),
9558            "a second submit must be rejected"
9559        );
9560        state.set_status(RunStatus::Idle);
9561        state.set_status(RunStatus::Working);
9562        assert_eq!(state.status_container.child_count(), 1);
9563        assert!(state.footer.get_status().is_empty());
9564        state.show_retry(3, 10, 8_000);
9565        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
9566        assert!(retry_status.contains("Retrying (3/10)"));
9567        state.set_status(RunStatus::Working);
9568        {
9569            let mut bash = state.bash_components.lock().unwrap();
9570            bash.insert(
9571                "bash-1".into(),
9572                Arc::new(BashExecutionComponent::new("one")),
9573            );
9574            bash.insert(
9575                "bash-2".into(),
9576                Arc::new(BashExecutionComponent::new("two")),
9577            );
9578        }
9579        state.sync_working_loader_with_bash();
9580        assert_eq!(state.status_container.child_count(), 0);
9581        state.bash_components.lock().unwrap().remove("bash-1");
9582        state.sync_working_loader_with_bash();
9583        assert_eq!(state.status_container.child_count(), 0);
9584        state.bash_components.lock().unwrap().remove("bash-2");
9585        state.sync_working_loader_with_bash();
9586        assert_eq!(state.status_container.child_count(), 1);
9587
9588        state.set_status(RunStatus::Aborting);
9589        assert_eq!(state.status_container.child_count(), 0);
9590        assert!(!state.loader.is_running());
9591    }
9592
9593    #[test]
9594    fn fresh_launch_does_not_restore_old_history() {
9595        let fresh = Args::default();
9596        assert!(!launch_restores_history(&fresh));
9597
9598        let continued = Args {
9599            continue_session: true,
9600            ..Args::default()
9601        };
9602        assert!(launch_restores_history(&continued));
9603
9604        let selected = Args {
9605            session: Some("session-id".into()),
9606            ..Args::default()
9607        };
9608        assert!(launch_restores_history(&selected));
9609    }
9610
9611    #[test]
9612    fn test_short_model_name() {
9613        assert_eq!(
9614            short_model_name("anthropic:claude-sonnet-5"),
9615            "claude-sonnet-5"
9616        );
9617        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
9618    }
9619
9620    #[test]
9621    fn model_selector_items_are_deduplicated_and_provider_qualified() {
9622        use rpi_ai::{Api, Model};
9623
9624        let mut gateway = Model::new(
9625            "gpt-5.6-sol",
9626            "GPT 5.6 Sol",
9627            Api::OpenaiCompletions,
9628            "routeryo-copy",
9629            "https://gateway.example.com",
9630        );
9631        let duplicate = gateway.clone();
9632        let anthropic = Model::new(
9633            "claude-sonnet-5",
9634            "Claude Sonnet 5",
9635            Api::AnthropicMessages,
9636            "anthropic",
9637            "https://api.anthropic.com",
9638        );
9639        gateway.headers = Some(std::collections::BTreeMap::from([(
9640            "authorization".into(),
9641            "Bearer test".into(),
9642        )]));
9643
9644        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
9645        assert_eq!(items.len(), 2);
9646        assert_eq!(items[0].value, "gpt-5.6-sol");
9647        assert_eq!(items[0].label, "GPT 5.6 Sol");
9648        assert_eq!(
9649            items[0].description.as_deref(),
9650            Some("routeryo-copy/gpt-5.6-sol (current)")
9651        );
9652        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
9653    }
9654
9655    #[test]
9656    fn model_selector_match_accepts_bare_and_qualified_ids() {
9657        use rpi_ai::{Api, Model};
9658
9659        let gateway = Model::new(
9660            "gpt-5.6-sol",
9661            "GPT 5.6 Sol",
9662            Api::OpenaiCompletions,
9663            "routeryo-copy",
9664            "https://gateway.example.com",
9665        );
9666        let anthropic = Model::new(
9667            "claude-sonnet-5",
9668            "Claude Sonnet 5",
9669            Api::AnthropicMessages,
9670            "anthropic",
9671            "https://api.anthropic.com",
9672        );
9673        let catalog = [gateway, anthropic];
9674        assert_eq!(
9675            find_model_selector_match(&catalog, "gpt-5.6-sol")
9676                .unwrap()
9677                .provider,
9678            "routeryo-copy"
9679        );
9680        assert_eq!(
9681            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9682                .unwrap()
9683                .id,
9684            "gpt-5.6-sol"
9685        );
9686        assert_eq!(
9687            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9688                .unwrap()
9689                .id,
9690            "claude-sonnet-5"
9691        );
9692        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9693    }
9694
9695    #[test]
9696    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9697        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9698
9699        let failed = AssistantMessage {
9700            role: AssistantRole,
9701            content: Vec::new(),
9702            api: rpi_ai::Api::AnthropicMessages,
9703            provider: "anthropic".into(),
9704            model: "claude-sonnet-5".into(),
9705            response_model: None,
9706            response_id: None,
9707            usage: Usage::zero(),
9708            stop_reason: StopReason::Error,
9709            deferred: None,
9710            error_message: Some("upstream returned 401".into()),
9711            raw_stop_reason: None,
9712            end_turn: None,
9713            timestamp: 0,
9714        };
9715        assert_eq!(
9716            assistant_error_text(&failed).as_deref(),
9717            Some("upstream returned 401")
9718        );
9719
9720        let mut no_detail = failed;
9721        no_detail.error_message = Some("  ".into());
9722        assert_eq!(
9723            assistant_error_text(&no_detail).as_deref(),
9724            Some("Provider request failed.")
9725        );
9726
9727        let mut aborted = no_detail;
9728        aborted.stop_reason = StopReason::Aborted;
9729        aborted.error_message = Some("abort error: Request aborted".into());
9730        assert_eq!(
9731            assistant_error_text(&aborted).as_deref(),
9732            Some("abort error: Request aborted")
9733        );
9734
9735        aborted.error_message = None;
9736        assert_eq!(
9737            assistant_error_text(&aborted).as_deref(),
9738            Some("Request aborted.")
9739        );
9740    }
9741
9742    #[test]
9743    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9744        assert_eq!(
9745            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9746            "405\nMethod Not Allowed"
9747        );
9748        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9749        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
9750        let long = "x".repeat(20_000);
9751        let cleaned = sanitize_error_message(&long);
9752        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9753        assert!(cleaned.ends_with('…'));
9754    }
9755
9756    #[test]
9757    fn test_cycle_next_model_wraps_around() {
9758        use rpi_ai::{Api, Model};
9759        let mk = |id: &str| {
9760            Model::new(
9761                id,
9762                id,
9763                Api::AnthropicMessages,
9764                "anthropic",
9765                "https://api.anthropic.com",
9766            )
9767        };
9768        let catalog = [mk("a"), mk("b"), mk("c")];
9769        // Next after "a" is "b"; after "c" wraps to "a".
9770        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9771        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9772        // An unknown current id falls back to the first model.
9773        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9774        // Empty catalog yields None.
9775        let empty: Vec<Model> = vec![];
9776        assert!(cycle_next_model(&empty, "a").is_none());
9777    }
9778
9779    #[test]
9780    fn test_autocomplete_slash_suggestions_render() {
9781        // The autocomplete container should render at least one suggestion
9782        // line when the editor holds a `/` prefix, and clear when it doesn't.
9783        let state = Arc::new(TuiState {
9784            current_assistant: std::sync::Mutex::new(None),
9785            tool_components: std::sync::Mutex::new(HashMap::new()),
9786            bash_components: std::sync::Mutex::new(HashMap::new()),
9787            themes_enabled: true,
9788            hide_thinking: std::sync::Mutex::new(false),
9789            tool_outputs_expanded: std::sync::Mutex::new(false),
9790            show_terminal_progress: true,
9791            status: std::sync::Mutex::new(RunStatus::Idle),
9792            js_preparation_cancel: std::sync::Mutex::new(None),
9793            footer: Arc::new(FooterComponent::new()),
9794            status_container: Arc::new(Container::new()),
9795            chat_container: Arc::new(Container::new()),
9796            loader: Arc::new(Loader::new()),
9797            last_assistant_text: std::sync::Mutex::new(String::new()),
9798            active_selector: std::sync::Mutex::new(None),
9799            active_extension_editor: std::sync::Mutex::new(None),
9800            active_extension_input: std::sync::Mutex::new(None),
9801            active_extension_cancel: std::sync::Mutex::new(None),
9802            autocomplete: AutocompleteManager::new(),
9803            autocomplete_container: Arc::new(Container::new()),
9804            autocomplete_max_visible: 5,
9805            pending_images: std::sync::Mutex::new(Vec::new()),
9806            theme_manager: Arc::new(ThemeManager::new()),
9807            tui: None,
9808            current_model_id: std::sync::Mutex::new(String::new()),
9809            show_images: std::sync::Mutex::new(true),
9810            history: std::sync::Mutex::new(Vec::new()),
9811            history_index: std::sync::Mutex::new(-1),
9812            history_draft: std::sync::Mutex::new(None),
9813            last_input_tokens: std::sync::Mutex::new(0),
9814            scoped_edit: std::sync::Mutex::new(None),
9815            markdown_transformer: std::sync::Mutex::new(None),
9816            extension_session: Arc::new(std::sync::Mutex::new(
9817                rpi_extensions::ExtensionSession::none(),
9818            )),
9819        });
9820        {
9821            let mut combined = CombinedAutocompleteProvider::new();
9822            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9823                build_builtin_registry().visible_entries(),
9824            )));
9825            state.autocomplete.set_provider(Arc::new(combined));
9826        }
9827
9828        let editor = Arc::new(Editor::simple());
9829        editor.set_text("/he");
9830        editor.set_cursor(0, 3);
9831        refresh_autocomplete(&state, &editor);
9832        let lines = state.autocomplete_container.render(80);
9833        let joined: String = lines.join("\n");
9834        assert!(
9835            joined.contains("/help"),
9836            "slash suggestions not rendered: {joined}"
9837        );
9838
9839        // Clear: no suggestions for plain text.
9840        editor.set_text("hello");
9841        editor.set_cursor(0, 5);
9842        refresh_autocomplete(&state, &editor);
9843        assert!(state.autocomplete_container.render(80).is_empty());
9844    }
9845
9846    #[test]
9847    fn test_select_list_swap_restores_editor() {
9848        // The editor-container swap: opening a selector replaces the editor
9849        // child; closing restores it. Verify the container child count + the
9850        // active_selector flag round-trip.
9851        let state = Arc::new(TuiState {
9852            current_assistant: std::sync::Mutex::new(None),
9853            tool_components: std::sync::Mutex::new(HashMap::new()),
9854            bash_components: std::sync::Mutex::new(HashMap::new()),
9855            themes_enabled: true,
9856            hide_thinking: std::sync::Mutex::new(false),
9857            tool_outputs_expanded: std::sync::Mutex::new(false),
9858            show_terminal_progress: true,
9859            status: std::sync::Mutex::new(RunStatus::Idle),
9860            js_preparation_cancel: std::sync::Mutex::new(None),
9861            footer: Arc::new(FooterComponent::new()),
9862            status_container: Arc::new(Container::new()),
9863            chat_container: Arc::new(Container::new()),
9864            loader: Arc::new(Loader::new()),
9865            last_assistant_text: std::sync::Mutex::new(String::new()),
9866            active_selector: std::sync::Mutex::new(None),
9867            active_extension_editor: std::sync::Mutex::new(None),
9868            active_extension_input: std::sync::Mutex::new(None),
9869            active_extension_cancel: std::sync::Mutex::new(None),
9870            autocomplete: AutocompleteManager::new(),
9871            autocomplete_container: Arc::new(Container::new()),
9872            autocomplete_max_visible: 5,
9873            pending_images: std::sync::Mutex::new(Vec::new()),
9874            theme_manager: Arc::new(ThemeManager::new()),
9875            tui: None,
9876            current_model_id: std::sync::Mutex::new(String::new()),
9877            show_images: std::sync::Mutex::new(true),
9878            history: std::sync::Mutex::new(Vec::new()),
9879            history_index: std::sync::Mutex::new(-1),
9880            history_draft: std::sync::Mutex::new(None),
9881            last_input_tokens: std::sync::Mutex::new(0),
9882            scoped_edit: std::sync::Mutex::new(None),
9883            markdown_transformer: std::sync::Mutex::new(None),
9884            extension_session: Arc::new(std::sync::Mutex::new(
9885                rpi_extensions::ExtensionSession::none(),
9886            )),
9887        });
9888        let editor_container = Arc::new(Container::new());
9889        let editor = Arc::new(Editor::simple());
9890        editor_container.add_child(editor.clone());
9891        assert!(!state.selector_open());
9892
9893        let tui_terminal = Box::new(ProcessTerminal::new());
9894        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9895        let list = Arc::new(SelectList::new(
9896            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9897            5,
9898        ));
9899        open_selector(
9900            &state,
9901            &editor_container,
9902            &editor,
9903            &tui,
9904            list,
9905            SelectorKind::Theme,
9906        );
9907        assert!(state.selector_open());
9908        // list only (editor swapped out).
9909        assert_eq!(editor_container.child_count(), 1);
9910
9911        close_selector(&state, &editor_container, &editor, &tui);
9912        assert!(!state.selector_open());
9913        // editor restored.
9914        assert_eq!(editor_container.child_count(), 1);
9915    }
9916
9917    #[test]
9918    fn test_message_history_browse_restores_draft() {
9919        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
9920        // messages, browse older → newer → back past the newest restores the
9921        // draft the user was typing.
9922        let state = Arc::new(TuiState {
9923            current_assistant: std::sync::Mutex::new(None),
9924            tool_components: std::sync::Mutex::new(HashMap::new()),
9925            bash_components: std::sync::Mutex::new(HashMap::new()),
9926            themes_enabled: true,
9927            hide_thinking: std::sync::Mutex::new(false),
9928            tool_outputs_expanded: std::sync::Mutex::new(false),
9929            show_terminal_progress: true,
9930            status: std::sync::Mutex::new(RunStatus::Idle),
9931            js_preparation_cancel: std::sync::Mutex::new(None),
9932            footer: Arc::new(FooterComponent::new()),
9933            status_container: Arc::new(Container::new()),
9934            chat_container: Arc::new(Container::new()),
9935            loader: Arc::new(Loader::new()),
9936            last_assistant_text: std::sync::Mutex::new(String::new()),
9937            active_selector: std::sync::Mutex::new(None),
9938            active_extension_editor: std::sync::Mutex::new(None),
9939            active_extension_input: std::sync::Mutex::new(None),
9940            active_extension_cancel: std::sync::Mutex::new(None),
9941            autocomplete: AutocompleteManager::new(),
9942            autocomplete_container: Arc::new(Container::new()),
9943            autocomplete_max_visible: 5,
9944            pending_images: std::sync::Mutex::new(Vec::new()),
9945            theme_manager: Arc::new(ThemeManager::new()),
9946            tui: None,
9947            current_model_id: std::sync::Mutex::new(String::new()),
9948            show_images: std::sync::Mutex::new(true),
9949            history: std::sync::Mutex::new(Vec::new()),
9950            history_index: std::sync::Mutex::new(-1),
9951            history_draft: std::sync::Mutex::new(None),
9952            last_input_tokens: std::sync::Mutex::new(0),
9953            scoped_edit: std::sync::Mutex::new(None),
9954            markdown_transformer: std::sync::Mutex::new(None),
9955            extension_session: Arc::new(std::sync::Mutex::new(
9956                rpi_extensions::ExtensionSession::none(),
9957            )),
9958        });
9959        let editor = Arc::new(Editor::simple());
9960
9961        push_history(&state, "first message");
9962        push_history(&state, "second message");
9963        // Consecutive duplicate is skipped.
9964        push_history(&state, "second message");
9965        push_history(&state, "   "); // empty → skipped
9966        assert_eq!(state.history.lock().unwrap().len(), 2);
9967        assert_eq!(state.history.lock().unwrap()[0], "second message");
9968
9969        // User starts typing a fresh prompt.
9970        editor.set_text("half-typed");
9971        editor.set_cursor(0, 11);
9972
9973        // ↑ → most recent.
9974        navigate_history(&state, &editor, -1);
9975        assert_eq!(editor.get_text(), "second message");
9976        assert_eq!(*state.history_index.lock().unwrap(), 0);
9977        // ↑ → older.
9978        navigate_history(&state, &editor, -1);
9979        assert_eq!(editor.get_text(), "first message");
9980        assert_eq!(*state.history_index.lock().unwrap(), 1);
9981        // ↑ past the oldest → stays (no wrap).
9982        navigate_history(&state, &editor, -1);
9983        assert_eq!(editor.get_text(), "first message");
9984        // ↓ → newer.
9985        navigate_history(&state, &editor, 1);
9986        assert_eq!(editor.get_text(), "second message");
9987        // ↓ past the newest → restores the draft.
9988        navigate_history(&state, &editor, 1);
9989        assert_eq!(editor.get_text(), "half-typed");
9990        assert_eq!(*state.history_index.lock().unwrap(), -1);
9991    }
9992
9993    #[test]
9994    fn test_accept_top_suggestion_replaces_prefix() {
9995        // `/he` + Tab → `/help ` (slash command provider inserts a space).
9996        let state = Arc::new(TuiState {
9997            current_assistant: std::sync::Mutex::new(None),
9998            tool_components: std::sync::Mutex::new(HashMap::new()),
9999            bash_components: std::sync::Mutex::new(HashMap::new()),
10000            themes_enabled: true,
10001            hide_thinking: std::sync::Mutex::new(false),
10002            tool_outputs_expanded: std::sync::Mutex::new(false),
10003            show_terminal_progress: true,
10004            status: std::sync::Mutex::new(RunStatus::Idle),
10005            js_preparation_cancel: std::sync::Mutex::new(None),
10006            footer: Arc::new(FooterComponent::new()),
10007            status_container: Arc::new(Container::new()),
10008            chat_container: Arc::new(Container::new()),
10009            loader: Arc::new(Loader::new()),
10010            last_assistant_text: std::sync::Mutex::new(String::new()),
10011            active_selector: std::sync::Mutex::new(None),
10012            active_extension_editor: std::sync::Mutex::new(None),
10013            active_extension_input: std::sync::Mutex::new(None),
10014            active_extension_cancel: std::sync::Mutex::new(None),
10015            autocomplete: AutocompleteManager::new(),
10016            autocomplete_container: Arc::new(Container::new()),
10017            autocomplete_max_visible: 5,
10018            pending_images: std::sync::Mutex::new(Vec::new()),
10019            theme_manager: Arc::new(ThemeManager::new()),
10020            tui: None,
10021            current_model_id: std::sync::Mutex::new(String::new()),
10022            show_images: std::sync::Mutex::new(true),
10023            history: std::sync::Mutex::new(Vec::new()),
10024            history_index: std::sync::Mutex::new(-1),
10025            history_draft: std::sync::Mutex::new(None),
10026            last_input_tokens: std::sync::Mutex::new(0),
10027            scoped_edit: std::sync::Mutex::new(None),
10028            markdown_transformer: std::sync::Mutex::new(None),
10029            extension_session: Arc::new(std::sync::Mutex::new(
10030                rpi_extensions::ExtensionSession::none(),
10031            )),
10032        });
10033        {
10034            let mut combined = CombinedAutocompleteProvider::new();
10035            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10036                build_builtin_registry().visible_entries(),
10037            )));
10038            state.autocomplete.set_provider(Arc::new(combined));
10039        }
10040        let editor = Arc::new(Editor::simple());
10041        editor.set_text("/he");
10042        editor.set_cursor(0, 3);
10043        refresh_autocomplete(&state, &editor);
10044        let accepted = accept_top_suggestion(&state, &editor);
10045        assert!(accepted, "should accept the top suggestion");
10046        let text = editor.get_text();
10047        assert!(
10048            text.starts_with("/help"),
10049            "editor text should start with /help, got {text}"
10050        );
10051    }
10052
10053    #[test]
10054    fn configured_key_parser_supports_native_notation() {
10055        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10056        assert_eq!(combo.code, KeyCode::Char('g'));
10057        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10058        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10059        assert_eq!(combo.code, KeyCode::BackTab);
10060    }
10061
10062    #[test]
10063    fn double_escape_trigger_has_half_second_window() {
10064        let now = std::time::Instant::now();
10065        assert!(!double_escape_trigger(None, now));
10066        assert!(double_escape_trigger(
10067            Some(now - std::time::Duration::from_millis(500)),
10068            now
10069        ));
10070        assert!(!double_escape_trigger(
10071            Some(now - std::time::Duration::from_millis(501)),
10072            now
10073        ));
10074    }
10075
10076    // ---- ask_user TUI bridge ----
10077
10078    #[test]
10079    fn ask_user_tool_names_are_recognized() {
10080        assert!(is_ask_user_tool("ask_user"));
10081        assert!(is_ask_user_tool("ask_user_question"));
10082        assert!(!is_ask_user_tool("bash"));
10083    }
10084
10085    #[test]
10086    fn ask_user_args_display_never_leaks_transport_json() {
10087        let text = ask_user_args_display(&serde_json::json!({
10088            "question": "你的代理端口是多少?",
10089            "context": "本机",
10090            "options": ["7890", {"title": "7897", "description": "clash"}],
10091            "suggest": "1080"
10092        }));
10093        assert!(text.contains("你的代理端口是多少?"));
10094        assert!(text.contains("本机"));
10095        assert!(text.contains("Choices: 7890, 7897"));
10096        assert!(text.contains("Suggestions: 1080"));
10097        assert!(!text.trim_start().starts_with('{'));
10098
10099        let multi = ask_user_args_display(&serde_json::json!({
10100            "questions": [
10101                {"id": "a", "question": "Q1"},
10102                {"id": "b", "header": "H", "question": "Q2"}
10103            ]
10104        }));
10105        assert!(multi.contains("Q1"));
10106        assert!(multi.contains("H: Q2"));
10107
10108        assert_eq!(
10109            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10110            "Confirmation requested: Deploy?"
10111        );
10112        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10113    }
10114
10115    #[test]
10116    fn ask_user_result_and_progress_prefer_readable_content() {
10117        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10118        assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10119            .contains("端口?"));
10120        let empty = rpi_agent::AgentToolResult::default();
10121        assert!(
10122            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10123                .contains("端口?")
10124        );
10125        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10126        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10127        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10128    }
10129
10130    #[test]
10131    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10132        let flat = rpi_extensions::UiDialogRequest {
10133            request_id: "r1".into(),
10134            tool_call_id: Some("c1".into()),
10135            ui: serde_json::json!({
10136                "kind": "selector",
10137                "id": "proxy_port",
10138                "header": "Proxy",
10139                "question": "端口?",
10140                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10141                "allowMultiple": true,
10142                "allowFreeform": false,
10143                "suggest": "1080"
10144            }),
10145            raw: serde_json::json!({}),
10146        };
10147        let prompt = parse_ask_user_prompt(&flat);
10148        assert_eq!(prompt.question_id, "proxy_port");
10149        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10150        assert_eq!(prompt.options.len(), 2);
10151        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10152        assert!(prompt.allow_multiple);
10153        assert!(!prompt.allow_freeform);
10154        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10155
10156        let alias = rpi_extensions::UiDialogRequest {
10157            ui: serde_json::json!({
10158                "questions": [{"id": "q", "question": "Q?", "options": []}]
10159            }),
10160            ..flat.clone()
10161        };
10162        let prompt = parse_ask_user_prompt(&alias);
10163        assert_eq!(prompt.question_id, "q");
10164        assert!(prompt.allow_freeform, "no options defaults to freeform");
10165
10166        let confirm = rpi_extensions::UiDialogRequest {
10167            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10168            ..flat.clone()
10169        };
10170        let prompt = parse_ask_user_prompt(&confirm);
10171        assert_eq!(prompt.kind, "confirm");
10172        assert_eq!(prompt.options.len(), 2);
10173        assert_eq!(prompt.options[0].0, "Yes");
10174    }
10175
10176    #[test]
10177    fn ask_user_bridge_answers_and_cancels_by_request_id() {
10178        let mailbox = rpi_extensions::UiDialogMailbox::new();
10179        mailbox.attach();
10180        let bridge = AskUserBridge::new(mailbox.clone());
10181
10182        // Two concurrent requests must not cross answers.
10183        mailbox
10184            .handle(serde_json::json!({
10185                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10186                "ui": {"kind": "input", "question": "Port?"}
10187            }))
10188            .unwrap();
10189        mailbox
10190            .handle(serde_json::json!({
10191                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10192                "ui": {"kind": "input", "question": "Level?"}
10193            }))
10194            .unwrap();
10195
10196        let first = bridge.take_pending().expect("r1 visible");
10197        assert_eq!(first.request_id, "r1");
10198        assert!(
10199            bridge.take_pending().is_none(),
10200            "only one request occupies the input slot"
10201        );
10202        bridge.respond("r1", serde_json::json!({"text": "7897"}));
10203        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10204
10205        let second = bridge.take_pending().expect("r2 visible");
10206        assert_eq!(second.request_id, "r2");
10207        bridge.cancel("r2");
10208        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10209
10210        // cancel_all drops anything still queued.
10211        mailbox
10212            .handle(serde_json::json!({
10213                "op": "open", "requestId": "r3",
10214                "ui": {"kind": "input", "question": "X?"}
10215            }))
10216            .unwrap();
10217        bridge.cancel_all();
10218        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10219
10220        // A detached mailbox rejects new prompts (headless contract).
10221        bridge.shutdown();
10222        assert!(mailbox
10223            .handle(serde_json::json!({
10224                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10225            }))
10226            .is_err());
10227    }
10228}