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, Markdown, 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/// Markdown-aware version of `extension_dialog_title` for extension dialogs
1030/// that may contain markdown content (tables, lists, bold, etc).
1031fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1032    let text = if bold {
1033        format!("**{}**", title)
1034    } else {
1035        title.to_string()
1036    };
1037    Arc::new(Markdown::new(text, 1, 0))
1038}
1039
1040/// Markdown-aware version of `extension_dialog_hint` for extension dialogs
1041/// that may contain markdown content.
1042fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1043    Arc::new(Markdown::new(label, 1, 0))
1044}
1045
1046/// Take and run the cancellation callback for the active extension dialog.
1047/// Taking it before invoking the callback breaks the temporary Arc cycle: the
1048/// callback owns the command context so it can process a follow-up result.
1049fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1050    let callback = state.active_extension_cancel.lock().unwrap().take();
1051    if let Some(callback) = callback {
1052        callback();
1053        true
1054    } else {
1055        false
1056    }
1057}
1058
1059fn open_extension_input(
1060    ctx: &CommandContext,
1061    session: crate::session::ExtensionSessionCell,
1062    command_name: String,
1063    value: serde_json::Value,
1064) {
1065    let title = value
1066        .get("title")
1067        .and_then(|v| v.as_str())
1068        .filter(|title| !title.is_empty())
1069        .unwrap_or("Input");
1070    let input = value
1071        .get("placeholder")
1072        .and_then(|v| v.as_str())
1073        .map(Input::with_placeholder)
1074        .unwrap_or_default();
1075    let input = Arc::new(input);
1076    if let Some(initial) = value
1077        .get("initialText")
1078        .or_else(|| value.get("text"))
1079        .and_then(|v| v.as_str())
1080    {
1081        input.set_value(initial);
1082    }
1083    input.set_focused(true);
1084
1085    let frame = Arc::new(Container::new());
1086    frame.add_child(Arc::new(DynamicBorder::new()));
1087    frame.add_child(Arc::new(Spacer::new(1)));
1088    frame.add_child(extension_dialog_title(title, false));
1089    frame.add_child(Arc::new(Spacer::new(1)));
1090    frame.add_child(input.clone());
1091    frame.add_child(Arc::new(Spacer::new(1)));
1092    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1093    frame.add_child(Arc::new(Spacer::new(1)));
1094    frame.add_child(Arc::new(DynamicBorder::new()));
1095
1096    *ctx.state.active_extension_editor.lock().unwrap() = None;
1097    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1098    ctx.editor_container.clear();
1099    ctx.editor_container.add_child(frame);
1100
1101    let state = ctx.state.clone();
1102    let ec = ctx.editor_container.clone();
1103    let original = ctx.editor.clone();
1104    let tui = ctx.tui.clone();
1105    let session_submit = session.clone();
1106    let command_submit = command_name.clone();
1107    let ctx_submit = ctx.clone();
1108    input.on_submit(Arc::new(move |text| {
1109        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1110        let result = invoke_extension_command(
1111            &session_submit,
1112            &command_submit,
1113            &serde_json::to_string(&args).unwrap_or_default(),
1114        );
1115        close_extension_editor(&state, &ec, &original, &tui);
1116        handle_extension_ui_result(
1117            result,
1118            &ctx_submit,
1119            session_submit.clone(),
1120            command_submit.clone(),
1121        );
1122    }));
1123
1124    let state_cancel = ctx.state.clone();
1125    let ec_cancel = ctx.editor_container.clone();
1126    let original_cancel = ctx.editor.clone();
1127    let tui_cancel = ctx.tui.clone();
1128    let session_cancel = session.clone();
1129    let command_cancel = command_name.clone();
1130    let ctx_cancel = ctx.clone();
1131    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1132        let args = serde_json::json!({ "action": "cancel" });
1133        let result = invoke_extension_command(
1134            &session_cancel,
1135            &command_cancel,
1136            &serde_json::to_string(&args).unwrap_or_default(),
1137        );
1138        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1139        handle_extension_ui_result(
1140            result,
1141            &ctx_cancel,
1142            session_cancel.clone(),
1143            command_cancel.clone(),
1144        );
1145    }));
1146
1147    ctx.tui.set_focus(Some(input));
1148    ctx.tui.request_render(false);
1149}
1150
1151fn open_extension_selector(
1152    ctx: &CommandContext,
1153    session: crate::session::ExtensionSessionCell,
1154    command_name: String,
1155    value: serde_json::Value,
1156) {
1157    let items = value
1158        .get("items")
1159        .and_then(|v| v.as_array())
1160        .map(|items| {
1161            items
1162                .iter()
1163                .filter_map(|item| {
1164                    // Native pi's selector accepts `string[]`; the Rust ABI
1165                    // also permits `{value,label,description}` objects.
1166                    let value = if let Some(value) = item.as_str() {
1167                        value
1168                    } else {
1169                        item.get("value")?.as_str()?
1170                    };
1171                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1172                    let mut out = SelectItem::new(value, label);
1173                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1174                        out = out.with_description(desc);
1175                    }
1176                    Some(out)
1177                })
1178                .collect::<Vec<_>>()
1179        })
1180        .unwrap_or_default();
1181    if items.is_empty() {
1182        add_error_message(&ctx.chat, "Extension selector has no items.");
1183        ctx.tui.request_render(false);
1184        return;
1185    }
1186    let list = Arc::new(SelectList::new(items, 10));
1187    let title = value
1188        .get("title")
1189        .and_then(|v| v.as_str())
1190        .filter(|title| !title.is_empty())
1191        .unwrap_or("Select");
1192    let frame = Arc::new(Container::new());
1193    frame.add_child(Arc::new(DynamicBorder::new()));
1194    frame.add_child(Arc::new(Spacer::new(1)));
1195    frame.add_child(extension_dialog_title(title, true));
1196    frame.add_child(Arc::new(Spacer::new(1)));
1197    frame.add_child(list.clone());
1198    frame.add_child(Arc::new(Spacer::new(1)));
1199    frame.add_child(extension_dialog_hint(
1200        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1201    ));
1202    frame.add_child(Arc::new(Spacer::new(1)));
1203    frame.add_child(Arc::new(DynamicBorder::new()));
1204
1205    let state = ctx.state.clone();
1206    let ec = ctx.editor_container.clone();
1207    let editor = ctx.editor.clone();
1208    let tui = ctx.tui.clone();
1209    let session_select = session.clone();
1210    let command_select = command_name.clone();
1211    let ctx_select = ctx.clone();
1212    list.on_select(Arc::new(move |item| {
1213        let args = serde_json::json!({ "action": "select", "value": item.value });
1214        let result = invoke_extension_command(
1215            &session_select,
1216            &command_select,
1217            &serde_json::to_string(&args).unwrap_or_default(),
1218        );
1219        close_selector(&state, &ec, &editor, &tui);
1220        handle_extension_ui_result(
1221            result,
1222            &ctx_select,
1223            session_select.clone(),
1224            command_select.clone(),
1225        );
1226    }));
1227    let state_cancel = ctx.state.clone();
1228    let ec_cancel = ctx.editor_container.clone();
1229    let editor_cancel = ctx.editor.clone();
1230    let tui_cancel = ctx.tui.clone();
1231    list.on_cancel(Arc::new(move || {
1232        if !run_extension_cancel(&state_cancel) {
1233            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1234        }
1235    }));
1236    let state_cancel = ctx.state.clone();
1237    let ec_cancel = ctx.editor_container.clone();
1238    let editor_cancel = ctx.editor.clone();
1239    let tui_cancel = ctx.tui.clone();
1240    let session_cancel = session.clone();
1241    let command_cancel = command_name.clone();
1242    let ctx_cancel = ctx.clone();
1243    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1244        let args = serde_json::json!({ "action": "cancel" });
1245        let result = invoke_extension_command(
1246            &session_cancel,
1247            &command_cancel,
1248            &serde_json::to_string(&args).unwrap_or_default(),
1249        );
1250        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1251        handle_extension_ui_result(
1252            result,
1253            &ctx_cancel,
1254            session_cancel.clone(),
1255            command_cancel.clone(),
1256        );
1257    }));
1258    open_selector_with_view(
1259        &ctx.state,
1260        &ctx.editor_container,
1261        &ctx.editor,
1262        &ctx.tui,
1263        list,
1264        frame,
1265        SelectorKind::Extension,
1266    );
1267}
1268
1269fn open_extension_editor(
1270    ctx: &CommandContext,
1271    session: crate::session::ExtensionSessionCell,
1272    command_name: String,
1273    value: serde_json::Value,
1274) {
1275    let initial = value
1276        .get("initialText")
1277        .or_else(|| value.get("text"))
1278        .and_then(|v| v.as_str())
1279        .unwrap_or_default()
1280        .to_string();
1281    let title = value
1282        .get("title")
1283        .and_then(|v| v.as_str())
1284        .filter(|title| !title.is_empty())
1285        .unwrap_or("Editor");
1286    let editor = Arc::new(Editor::new(
1287        EditorOptions {
1288            padding_x: 1,
1289            autocomplete_max_visible: 0,
1290            placeholder: value
1291                .get("placeholder")
1292                .and_then(|v| v.as_str())
1293                .map(str::to_string),
1294            initial_text: Some(initial),
1295        },
1296        EditorStyle {
1297            prompt: "> ".to_string(),
1298            placeholder: String::new(),
1299        },
1300        Arc::new(rpi_tui::Keybindings::new()),
1301    ));
1302    editor.set_focused(true);
1303    let frame = Arc::new(Container::new());
1304    frame.add_child(Arc::new(DynamicBorder::new()));
1305    frame.add_child(Arc::new(Spacer::new(1)));
1306    frame.add_child(extension_dialog_title(title, false));
1307    frame.add_child(Arc::new(Spacer::new(1)));
1308    frame.add_child(editor.clone());
1309    frame.add_child(Arc::new(Spacer::new(1)));
1310    frame.add_child(extension_dialog_hint(
1311        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1312    ));
1313    frame.add_child(Arc::new(Spacer::new(1)));
1314    frame.add_child(Arc::new(DynamicBorder::new()));
1315
1316    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1317    *ctx.state.active_extension_input.lock().unwrap() = None;
1318    ctx.editor_container.clear();
1319    ctx.editor_container.add_child(frame);
1320
1321    let state = ctx.state.clone();
1322    let ec = ctx.editor_container.clone();
1323    let original = ctx.editor.clone();
1324    let tui = ctx.tui.clone();
1325    let session_submit = session.clone();
1326    let command_submit = command_name.clone();
1327    let ctx_submit = ctx.clone();
1328    editor.on_submit(Arc::new(move |text| {
1329        let args = serde_json::json!({ "action": "edit", "text": text });
1330        let result = invoke_extension_command(
1331            &session_submit,
1332            &command_submit,
1333            &serde_json::to_string(&args).unwrap_or_default(),
1334        );
1335        close_extension_editor(&state, &ec, &original, &tui);
1336        handle_extension_ui_result(
1337            result,
1338            &ctx_submit,
1339            session_submit.clone(),
1340            command_submit.clone(),
1341        );
1342    }));
1343
1344    let state_cancel = ctx.state.clone();
1345    let ec_cancel = ctx.editor_container.clone();
1346    let original_cancel = ctx.editor.clone();
1347    let tui_cancel = ctx.tui.clone();
1348    let session_cancel = session.clone();
1349    let command_cancel = command_name.clone();
1350    let ctx_cancel = ctx.clone();
1351    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1352        let args = serde_json::json!({ "action": "cancel" });
1353        let result = invoke_extension_command(
1354            &session_cancel,
1355            &command_cancel,
1356            &serde_json::to_string(&args).unwrap_or_default(),
1357        );
1358        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1359        handle_extension_ui_result(
1360            result,
1361            &ctx_cancel,
1362            session_cancel.clone(),
1363            command_cancel.clone(),
1364        );
1365    }));
1366
1367    ctx.tui.set_focus(Some(editor));
1368    ctx.tui.request_render(false);
1369}
1370
1371fn close_extension_editor(
1372    state: &Arc<TuiState>,
1373    editor_container: &Arc<Container>,
1374    editor: &Arc<Editor>,
1375    tui: &Arc<TuiAltScreen>,
1376) {
1377    editor_container.clear();
1378    editor_container.add_child(editor.clone());
1379    state.autocomplete_container.clear();
1380    *state.active_extension_editor.lock().unwrap() = None;
1381    *state.active_extension_input.lock().unwrap() = None;
1382    *state.active_extension_cancel.lock().unwrap() = None;
1383    editor.set_focused(true);
1384    tui.set_focus(Some(editor.clone()));
1385    tui.request_render(false);
1386}
1387
1388/// Open one Node `ctx.ui.*` request in the native editor slot. The Node host
1389/// waits on the runtime response while these callbacks resolve the bridge on
1390/// Enter, selection, or cancellation.
1391fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1392    match request.method.as_str() {
1393        "select" => open_js_selector(ctx, bridge, request, false),
1394        "confirm" => open_js_selector(ctx, bridge, request, true),
1395        "input" => open_js_input(ctx, bridge, request),
1396        "editor" => open_js_editor(ctx, bridge, request),
1397        _ => {
1398            bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1399        }
1400    }
1401}
1402
1403fn open_js_selector(
1404    ctx: &CommandContext,
1405    bridge: Arc<JsDialogBridge>,
1406    request: JsDialogRequest,
1407    confirm: bool,
1408) {
1409    let values = if confirm {
1410        vec!["Yes".to_string(), "No".to_string()]
1411    } else {
1412        request.options.clone()
1413    };
1414    if values.is_empty() {
1415        bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1416        return;
1417    }
1418    let items = values
1419        .iter()
1420        .map(|value| SelectItem::new(value, value))
1421        .collect::<Vec<_>>();
1422    let list = Arc::new(SelectList::new(items, 10));
1423    let frame = Arc::new(Container::new());
1424    frame.add_child(Arc::new(DynamicBorder::new()));
1425    frame.add_child(Arc::new(Spacer::new(1)));
1426    frame.add_child(extension_dialog_title(
1427        if request.title.is_empty() {
1428            if confirm {
1429                "Confirm"
1430            } else {
1431                "Select"
1432            }
1433        } else {
1434            request.title.as_str()
1435        },
1436        true,
1437    ));
1438    if !request.message.is_empty() {
1439        frame.add_child(Arc::new(Spacer::new(1)));
1440        frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1441    }
1442    frame.add_child(Arc::new(Spacer::new(1)));
1443    frame.add_child(list.clone());
1444    frame.add_child(Arc::new(Spacer::new(1)));
1445    frame.add_child(extension_dialog_hint(
1446        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1447    ));
1448    frame.add_child(Arc::new(Spacer::new(1)));
1449    frame.add_child(Arc::new(DynamicBorder::new()));
1450
1451    let id = request.id.clone();
1452    let bridge_select = bridge.clone();
1453    let state_select = ctx.state.clone();
1454    let ec_select = ctx.editor_container.clone();
1455    let editor_select = ctx.editor.clone();
1456    let tui_select = ctx.tui.clone();
1457    list.on_select(Arc::new(move |item| {
1458        let result = if confirm {
1459            serde_json::json!({ "confirmed": item.value == "Yes" })
1460        } else {
1461            serde_json::json!({ "value": item.value })
1462        };
1463        bridge_select.respond(&id, result);
1464        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1465    }));
1466
1467    let id_cancel = request.id.clone();
1468    let bridge_cancel = bridge.clone();
1469    let state_cancel = ctx.state.clone();
1470    let ec_cancel = ctx.editor_container.clone();
1471    let editor_cancel = ctx.editor.clone();
1472    let tui_cancel = ctx.tui.clone();
1473    list.on_cancel(Arc::new(move || {
1474        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1475        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1476    }));
1477
1478    let id_abort = request.id.clone();
1479    let bridge_abort = bridge.clone();
1480    let state_abort = ctx.state.clone();
1481    let ec_abort = ctx.editor_container.clone();
1482    let editor_abort = ctx.editor.clone();
1483    let tui_abort = ctx.tui.clone();
1484    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1485        bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1486        close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1487    }));
1488
1489    open_selector_with_view(
1490        &ctx.state,
1491        &ctx.editor_container,
1492        &ctx.editor,
1493        &ctx.tui,
1494        list,
1495        frame,
1496        SelectorKind::Extension,
1497    );
1498}
1499
1500fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1501    let input = request
1502        .placeholder
1503        .as_deref()
1504        .map(Input::with_placeholder)
1505        .unwrap_or_default();
1506    let input = Arc::new(input);
1507    input.set_focused(true);
1508
1509    let frame = Arc::new(Container::new());
1510    frame.add_child(Arc::new(DynamicBorder::new()));
1511    frame.add_child(Arc::new(Spacer::new(1)));
1512    frame.add_child(extension_dialog_title(
1513        if request.title.is_empty() {
1514            "Input"
1515        } else {
1516            &request.title
1517        },
1518        false,
1519    ));
1520    frame.add_child(Arc::new(Spacer::new(1)));
1521    frame.add_child(input.clone());
1522    frame.add_child(Arc::new(Spacer::new(1)));
1523    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1524    frame.add_child(Arc::new(Spacer::new(1)));
1525    frame.add_child(Arc::new(DynamicBorder::new()));
1526
1527    *ctx.state.active_extension_editor.lock().unwrap() = None;
1528    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1529    ctx.editor_container.clear();
1530    ctx.editor_container.add_child(frame);
1531
1532    let id = request.id.clone();
1533    let bridge_submit = bridge.clone();
1534    let state_submit = ctx.state.clone();
1535    let ec_submit = ctx.editor_container.clone();
1536    let editor_submit = ctx.editor.clone();
1537    let tui_submit = ctx.tui.clone();
1538    input.on_submit(Arc::new(move |value| {
1539        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1540        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1541    }));
1542
1543    let id_cancel = request.id.clone();
1544    let bridge_cancel = bridge.clone();
1545    let state_cancel = ctx.state.clone();
1546    let ec_cancel = ctx.editor_container.clone();
1547    let editor_cancel = ctx.editor.clone();
1548    let tui_cancel = ctx.tui.clone();
1549    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1550        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1551        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1552    }));
1553    ctx.tui.set_focus(Some(input));
1554    ctx.tui.request_render(false);
1555}
1556
1557fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1558    let editor = Arc::new(Editor::new(
1559        EditorOptions {
1560            padding_x: 1,
1561            autocomplete_max_visible: 0,
1562            initial_text: request.prefill.clone(),
1563            ..Default::default()
1564        },
1565        EditorStyle {
1566            prompt: "> ".to_string(),
1567            placeholder: String::new(),
1568        },
1569        Arc::new(rpi_tui::Keybindings::new()),
1570    ));
1571    editor.set_focused(true);
1572
1573    let frame = Arc::new(Container::new());
1574    frame.add_child(Arc::new(DynamicBorder::new()));
1575    frame.add_child(Arc::new(Spacer::new(1)));
1576    frame.add_child(extension_dialog_title(
1577        if request.title.is_empty() {
1578            "Editor"
1579        } else {
1580            &request.title
1581        },
1582        false,
1583    ));
1584    frame.add_child(Arc::new(Spacer::new(1)));
1585    frame.add_child(editor.clone());
1586    frame.add_child(Arc::new(Spacer::new(1)));
1587    frame.add_child(extension_dialog_hint(
1588        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1589    ));
1590    frame.add_child(Arc::new(Spacer::new(1)));
1591    frame.add_child(Arc::new(DynamicBorder::new()));
1592
1593    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1594    *ctx.state.active_extension_input.lock().unwrap() = None;
1595    ctx.editor_container.clear();
1596    ctx.editor_container.add_child(frame);
1597
1598    let id = request.id.clone();
1599    let bridge_submit = bridge.clone();
1600    let state_submit = ctx.state.clone();
1601    let ec_submit = ctx.editor_container.clone();
1602    let editor_submit = ctx.editor.clone();
1603    let tui_submit = ctx.tui.clone();
1604    editor.on_submit(Arc::new(move |value| {
1605        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1606        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1607    }));
1608
1609    let id_cancel = request.id.clone();
1610    let bridge_cancel = bridge.clone();
1611    let state_cancel = ctx.state.clone();
1612    let ec_cancel = ctx.editor_container.clone();
1613    let editor_cancel = ctx.editor.clone();
1614    let tui_cancel = ctx.tui.clone();
1615    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1616        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1617        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1618    }));
1619    ctx.tui.set_focus(Some(editor));
1620    ctx.tui.request_render(false);
1621}
1622
1623fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1624    for id in bridge.cancelled_active_ids() {
1625        // Several commands can ask for a dialog concurrently. Only the id
1626        // currently occupying the TUI slot may close the visible component;
1627        // an older cancellation must leave a newer ask dialog untouched.
1628        if !bridge.is_visible(&id) {
1629            bridge.finish(&id);
1630            continue;
1631        }
1632        if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1633            selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1634        } else if ctx.state.extension_dialog_open() {
1635            if !run_extension_cancel(&ctx.state) {
1636                close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1637            }
1638        }
1639        // `respond` normally removes the active entry from the callback. The
1640        // fallback path above can run before a callback was installed, so
1641        // always discard the id after routing the cancellation.
1642        bridge.finish(&id);
1643    }
1644}
1645
1646// ===========================================================================
1647// ask_user — plugin UI-dialog bridge (runtime action 17)
1648// ===========================================================================
1649
1650/// Sentinel option value that switches a freeform-capable selector into the
1651/// single-line input slot (native `allowFreeform` affordance).
1652const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1653
1654/// One in-flight `ask_user` request being shown in the TUI. Unlike
1655/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1656/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1657/// bridge only tracks which request id currently occupies the input slot.
1658#[derive(Clone)]
1659struct AskUserBridge {
1660    mailbox: rpi_extensions::UiDialogMailbox,
1661    visible: Arc<Mutex<Option<String>>>,
1662}
1663
1664impl AskUserBridge {
1665    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1666        Self {
1667            mailbox,
1668            visible: Arc::new(Mutex::new(None)),
1669        }
1670    }
1671
1672    /// Mark the interactive consumer as available. Idempotent.
1673    fn attach(&self) {
1674        self.mailbox.attach();
1675    }
1676
1677    /// Take the next pending request and mark it visible. Only the single key
1678    /// loop calls this while no other dialog is open, so the visible-id
1679    /// bookkeeping is race-free without a separate lock.
1680    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1681        let mut visible = self.visible.lock().ok()?;
1682        if visible.is_some() {
1683            return None;
1684        }
1685        let request = self.mailbox.take_pending()?;
1686        *visible = Some(request.request_id.clone());
1687        Some(request)
1688    }
1689
1690    fn clear_visible(&self, id: &str) {
1691        if let Ok(mut visible) = self.visible.lock() {
1692            if visible.as_deref() == Some(id) {
1693                *visible = None;
1694            }
1695        }
1696    }
1697
1698    /// Record an answer for `id` and release the input slot.
1699    fn respond(&self, id: &str, answer: serde_json::Value) {
1700        let _ = self.mailbox.respond(id, answer);
1701        self.clear_visible(id);
1702    }
1703
1704    /// Cancel `id` and release the input slot.
1705    fn cancel(&self, id: &str) {
1706        let _ = self.mailbox.cancel(id);
1707        self.clear_visible(id);
1708    }
1709
1710    /// Cancel every outstanding request (run abort). Pending prompts from the
1711    /// aborted turn must not surface in a later turn.
1712    fn cancel_all(&self) {
1713        self.mailbox.cancel_all();
1714        if let Ok(mut visible) = self.visible.lock() {
1715            *visible = None;
1716        }
1717    }
1718
1719    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1720    /// observes a terminal state instead of blocking on a dead session.
1721    fn shutdown(&self) {
1722        self.mailbox.detach();
1723        if let Ok(mut visible) = self.visible.lock() {
1724            *visible = None;
1725        }
1726    }
1727}
1728
1729/// Parsed presentation data for one `ask_user` question. Supports both the
1730/// flat native question contract and the earlier `questions[0]` alias, plus a
1731/// `confirm` summary.
1732#[derive(Clone)]
1733struct AskUserPrompt {
1734    question_id: String,
1735    question: String,
1736    header: Option<String>,
1737    context: Option<String>,
1738    options: Vec<(String, Option<String>)>,
1739    allow_multiple: bool,
1740    allow_freeform: bool,
1741    suggest: Option<String>,
1742    kind: String,
1743}
1744
1745fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1746    keys.iter().find_map(|key| {
1747        value
1748            .get(*key)
1749            .and_then(serde_json::Value::as_str)
1750            .map(str::trim)
1751            .filter(|text| !text.is_empty())
1752            .map(str::to_string)
1753    })
1754}
1755
1756fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1757    keys.iter()
1758        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1759}
1760
1761fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1762    let mut options = Vec::new();
1763    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1764        return options;
1765    };
1766    for item in items {
1767        if let Some(title) = item.as_str() {
1768            let title = title.trim();
1769            if !title.is_empty() {
1770                options.push((title.to_string(), None));
1771            }
1772            continue;
1773        }
1774        let Some(object) = item.as_object() else { continue };
1775        let title = ["title", "label", "text", "value", "name"]
1776            .iter()
1777            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1778            .map(str::trim)
1779            .filter(|title| !title.is_empty());
1780        let Some(title) = title else { continue };
1781        let description = object
1782            .get("description")
1783            .and_then(serde_json::Value::as_str)
1784            .map(str::trim)
1785            .filter(|text| !text.is_empty())
1786            .map(str::to_string);
1787        options.push((title.to_string(), description));
1788    }
1789    options
1790}
1791
1792fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1793    let ui = &request.ui;
1794    let kind = ui
1795        .get("kind")
1796        .and_then(serde_json::Value::as_str)
1797        .unwrap_or("selector")
1798        .to_string();
1799    // A request may carry a single flat question or a `questions[]` array. The
1800    // plugin emits one question per request, but accepting `questions[0]` keeps
1801    // the host compatible with hosts/plugins that batch.
1802    let nested = ui
1803        .get("questions")
1804        .and_then(serde_json::Value::as_array)
1805        .and_then(|items| items.first());
1806    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1807
1808    let question = ask_user_str(source, &["question", "summary", "title"])
1809        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1810        .unwrap_or_default();
1811    let question_id = ask_user_str(source, &["id", "questionId"])
1812        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1813        .unwrap_or_else(|| request.request_id.clone());
1814    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1815    let context = ask_user_str(source, &["context", "message"])
1816        .or_else(|| ask_user_str(ui, &["context", "message"]));
1817    let mut options = ask_user_options(source);
1818    if options.is_empty() {
1819        options = ask_user_options(ui);
1820    }
1821    if kind == "confirm" && options.is_empty() {
1822        options = vec![
1823            ("Yes".to_string(), None),
1824            ("No".to_string(), None),
1825        ];
1826    }
1827    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1828        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1829        .unwrap_or(false);
1830    // Freeform defaults on when there is nothing to pick from, matching the
1831    // "no options ⇒ free input" contract.
1832    let allow_freeform = ask_user_bool(
1833        source,
1834        &["allowFreeform", "allow_freeform", "allow_custom"],
1835    )
1836    .or_else(|| {
1837        ask_user_bool(
1838            ui,
1839            &["allowFreeform", "allow_freeform", "allow_custom"],
1840        )
1841    })
1842    .unwrap_or(options.is_empty());
1843    let suggest = ask_user_str(source, &["suggest", "placeholder"])
1844        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1845
1846    AskUserPrompt {
1847        question_id,
1848        question,
1849        header,
1850        context,
1851        options,
1852        allow_multiple,
1853        allow_freeform,
1854        suggest,
1855        kind,
1856    }
1857}
1858
1859/// Build the shared header frame for an ask-user prompt.
1860/// Uses markdown-aware rendering for question and context content.
1861fn ask_user_frame(
1862    prompt: &AskUserPrompt,
1863    body: Arc<dyn Component>,
1864    hint: &str,
1865) -> Arc<Container> {
1866    let frame = Arc::new(Container::new());
1867    frame.add_child(Arc::new(DynamicBorder::new()));
1868    frame.add_child(Arc::new(Spacer::new(1)));
1869    if let Some(header) = prompt.header.as_deref() {
1870        frame.add_child(extension_dialog_title_md(header, true));
1871    }
1872    if !prompt.question.trim().is_empty() {
1873        frame.add_child(extension_dialog_title_md(
1874            &prompt.question,
1875            prompt.header.is_none(),
1876        ));
1877    }
1878    if let Some(context) = prompt.context.as_deref() {
1879        frame.add_child(extension_dialog_hint_md(context));
1880    }
1881    frame.add_child(Arc::new(Spacer::new(1)));
1882    frame.add_child(body);
1883    frame.add_child(Arc::new(Spacer::new(1)));
1884    frame.add_child(extension_dialog_hint(hint));
1885    frame.add_child(Arc::new(Spacer::new(1)));
1886    frame.add_child(Arc::new(DynamicBorder::new()));
1887    frame
1888}
1889
1890/// Open one `ask_user` request in the TUI input slot.
1891fn open_ask_user_dialog(
1892    ctx: &CommandContext,
1893    bridge: AskUserBridge,
1894    request: rpi_extensions::UiDialogRequest,
1895) {
1896    let prompt = parse_ask_user_prompt(&request);
1897    if prompt.kind == "input" || prompt.options.is_empty() {
1898        open_ask_user_input(ctx, bridge, request, prompt);
1899    } else {
1900        open_ask_user_selector(ctx, bridge, request, prompt);
1901    }
1902}
1903
1904fn open_ask_user_selector(
1905    ctx: &CommandContext,
1906    bridge: AskUserBridge,
1907    request: rpi_extensions::UiDialogRequest,
1908    prompt: AskUserPrompt,
1909) {
1910    let multi = prompt.allow_multiple && prompt.kind != "confirm";
1911    let mut items: Vec<SelectItem> = Vec::new();
1912    for (title, description) in &prompt.options {
1913        let item = SelectItem::new(title, title);
1914        let item = match description.as_deref() {
1915            Some(description) => item.with_description(description),
1916            None => item,
1917        };
1918        items.push(item);
1919    }
1920    // A freeform-capable single selector gets an extra sentinel row that opens
1921    // the input slot for a typed answer.
1922    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1923        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1924    }
1925    let list = if multi {
1926        Arc::new(SelectList::new_multi(items, 10))
1927    } else {
1928        Arc::new(SelectList::new(items, 10))
1929    };
1930
1931    let request_id = request.request_id.clone();
1932    let state = ctx.state.clone();
1933    let editor_container = ctx.editor_container.clone();
1934    let editor = ctx.editor.clone();
1935    let tui = ctx.tui.clone();
1936
1937    // Single-select: respond with the chosen title, or fall through to the
1938    // input slot for the freeform sentinel.
1939    let bridge_select = bridge.clone();
1940    let request_for_freeform = request.clone();
1941    let prompt_for_freeform = AskUserPrompt {
1942        question_id: prompt.question_id.clone(),
1943        question: prompt.question.clone(),
1944        header: prompt.header.clone(),
1945        context: prompt.context.clone(),
1946        options: Vec::new(),
1947        allow_multiple: false,
1948        allow_freeform: true,
1949        suggest: prompt.suggest.clone(),
1950        kind: "input".to_string(),
1951    };
1952    let state_select = state.clone();
1953    let ec_select = editor_container.clone();
1954    let editor_select = editor.clone();
1955    let tui_select = tui.clone();
1956    let ctx_select = ctx.clone();
1957    list.on_select(Arc::new(move |item: &SelectItem| {
1958        if item.value == ASK_USER_FREEFORM {
1959            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1960            open_ask_user_input(
1961                &ctx_select,
1962                bridge_select.clone(),
1963                request_for_freeform.clone(),
1964                prompt_for_freeform.clone(),
1965            );
1966            return;
1967        }
1968        bridge_select.respond(
1969            &request_id,
1970            serde_json::json!({
1971                "values": [item.display_value()],
1972                "kind": "selector",
1973            }),
1974        );
1975        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1976    }));
1977
1978    if multi {
1979        let bridge_multi = bridge.clone();
1980        let request_id_multi = request.request_id.clone();
1981        let state_multi = state.clone();
1982        let ec_multi = editor_container.clone();
1983        let editor_multi = editor.clone();
1984        let tui_multi = tui.clone();
1985        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
1986            let values: Vec<String> = selected
1987                .iter()
1988                .map(|item| item.display_value().to_string())
1989                .collect();
1990            bridge_multi.respond(
1991                &request_id_multi,
1992                serde_json::json!({"values": values, "kind": "selector"}),
1993            );
1994            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
1995        }));
1996    }
1997
1998    let bridge_cancel = bridge.clone();
1999    let request_id_cancel = request.request_id.clone();
2000    let state_cancel = state.clone();
2001    let ec_cancel = editor_container.clone();
2002    let editor_cancel = editor.clone();
2003    let tui_cancel = tui.clone();
2004    list.on_cancel(Arc::new(move || {
2005        bridge_cancel.cancel(&request_id_cancel);
2006        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2007    }));
2008
2009    let hint = if multi {
2010        "Space toggle · Enter submit · Esc cancel"
2011    } else {
2012        "Enter select · Esc cancel"
2013    };
2014    let frame = ask_user_frame(&prompt, list.clone(), hint);
2015    open_selector_with_view(
2016        &state,
2017        &editor_container,
2018        &editor,
2019        &tui,
2020        list,
2021        frame,
2022        SelectorKind::Extension,
2023    );
2024}
2025
2026fn open_ask_user_input(
2027    ctx: &CommandContext,
2028    bridge: AskUserBridge,
2029    request: rpi_extensions::UiDialogRequest,
2030    prompt: AskUserPrompt,
2031) {
2032    let placeholder = prompt
2033        .suggest
2034        .clone()
2035        .unwrap_or_else(|| "Type your answer…".to_string());
2036    let input = Arc::new(Input::with_placeholder(&placeholder));
2037    input.set_focused(true);
2038
2039    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2040    *ctx.state.active_extension_editor.lock().unwrap() = None;
2041    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2042    ctx.editor_container.clear();
2043    ctx.editor_container.add_child(frame);
2044    ctx.tui.set_focus(Some(input.clone()));
2045    ctx.tui.request_render(false);
2046
2047    let state = ctx.state.clone();
2048    let ec = ctx.editor_container.clone();
2049    let editor = ctx.editor.clone();
2050    let tui = ctx.tui.clone();
2051    let bridge_submit = bridge.clone();
2052    let request_id = request.request_id.clone();
2053    input.on_submit(Arc::new(move |text: &str| {
2054        bridge_submit.respond(
2055            &request_id,
2056            serde_json::json!({
2057                "values": [text],
2058                "value": text,
2059                "text": text,
2060                "kind": "input",
2061            }),
2062        );
2063        close_extension_editor(&state, &ec, &editor, &tui);
2064    }));
2065
2066    let state_cancel = ctx.state.clone();
2067    let ec_cancel = ctx.editor_container.clone();
2068    let editor_cancel = ctx.editor.clone();
2069    let tui_cancel = ctx.tui.clone();
2070    let bridge_cancel = bridge.clone();
2071    let request_id_cancel = request.request_id.clone();
2072    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2073        bridge_cancel.cancel(&request_id_cancel);
2074        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2075    }));
2076}
2077
2078// ---- Built-in command implementations ----
2079
2080struct HelpCommand;
2081impl SlashCommand for HelpCommand {
2082    fn name(&self) -> &'static str {
2083        "/help"
2084    }
2085    fn aliases(&self) -> &'static [&'static str] {
2086        &["/?"]
2087    }
2088    fn description(&self) -> &'static str {
2089        "Show available commands"
2090    }
2091    fn execute(&self, ctx: &CommandContext, _args: &str) {
2092        add_help_message(&ctx.chat);
2093        ctx.tui.request_render(false);
2094    }
2095}
2096
2097struct ClearChatCommand;
2098impl SlashCommand for ClearChatCommand {
2099    fn name(&self) -> &'static str {
2100        "/clear"
2101    }
2102    fn aliases(&self) -> &'static [&'static str] {
2103        &["/new"]
2104    }
2105    // `/new` carries its own weight as a discoverable entry, so surface it.
2106    fn alias_visible(&self) -> &'static [&'static str] {
2107        &["/new"]
2108    }
2109    fn description(&self) -> &'static str {
2110        "Clear the conversation"
2111    }
2112    fn execute(&self, ctx: &CommandContext, _args: &str) {
2113        let _ = ctx.tx.send(TuiMessage::ClearChat);
2114    }
2115}
2116
2117struct ExitCommand;
2118impl SlashCommand for ExitCommand {
2119    fn name(&self) -> &'static str {
2120        "/exit"
2121    }
2122    fn aliases(&self) -> &'static [&'static str] {
2123        &["/quit", "/q"]
2124    }
2125    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
2126    fn alias_visible(&self) -> &'static [&'static str] {
2127        &["/quit"]
2128    }
2129    fn description(&self) -> &'static str {
2130        "Exit the application"
2131    }
2132    fn execute(&self, ctx: &CommandContext, _args: &str) {
2133        ctx.state.cancel_js_preparation();
2134        let _ = ctx.tx.send(TuiMessage::Exit);
2135    }
2136}
2137
2138struct VersionCommand;
2139impl SlashCommand for VersionCommand {
2140    fn name(&self) -> &'static str {
2141        "/version"
2142    }
2143    fn aliases(&self) -> &'static [&'static str] {
2144        &["/v"]
2145    }
2146    fn description(&self) -> &'static str {
2147        "Show version information"
2148    }
2149    fn execute(&self, ctx: &CommandContext, _args: &str) {
2150        add_version_message(&ctx.chat);
2151        ctx.tui.request_render(false);
2152    }
2153}
2154
2155struct ChangelogCommand;
2156impl SlashCommand for ChangelogCommand {
2157    fn name(&self) -> &'static str {
2158        "/changelog"
2159    }
2160    fn description(&self) -> &'static str {
2161        "Show recent release changes"
2162    }
2163    fn execute(&self, ctx: &CommandContext, _args: &str) {
2164        add_changelog_message(&ctx.chat);
2165        ctx.tui.request_render(false);
2166    }
2167}
2168
2169struct HotkeysCommand;
2170impl SlashCommand for HotkeysCommand {
2171    fn name(&self) -> &'static str {
2172        "/hotkeys"
2173    }
2174    fn description(&self) -> &'static str {
2175        "Show keyboard shortcuts"
2176    }
2177    fn execute(&self, ctx: &CommandContext, _args: &str) {
2178        add_hotkeys_message(&ctx.chat);
2179        ctx.tui.request_render(false);
2180    }
2181}
2182
2183struct ModelCommand;
2184impl SlashCommand for ModelCommand {
2185    fn name(&self) -> &'static str {
2186        "/model"
2187    }
2188    fn aliases(&self) -> &'static [&'static str] {
2189        &["/m"]
2190    }
2191    fn description(&self) -> &'static str {
2192        "Choose a model (selector)"
2193    }
2194    fn execute(&self, ctx: &CommandContext, args: &str) {
2195        let term = args.trim();
2196        if !term.is_empty() {
2197            // /model <name> — direct switch by id (pi handleModelCommand).
2198            let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
2199                add_error_message(
2200                    &ctx.chat,
2201                    &format!("No model matches \"{term}\". Try /model for the list."),
2202                );
2203                ctx.tui.request_render(false);
2204                return;
2205            };
2206            let model_id = model.id.clone();
2207            ctx.state.set_current_model(&model);
2208            let lane = ctx.lane.clone();
2209            tokio::spawn(async move {
2210                let _ = lane.set_model(model).await;
2211            });
2212            add_note_message(
2213                &ctx.chat,
2214                &format!(
2215                    "Model set to {} — applies to the next message.",
2216                    short_model_name(&model_id)
2217                ),
2218            );
2219            ctx.tui.request_render(false);
2220            return;
2221        }
2222        open_model_selector(
2223            &ctx.state,
2224            &ctx.editor_container,
2225            &ctx.editor,
2226            &ctx.tui,
2227            &ctx.model_catalog,
2228            &ctx.lane,
2229            &ctx.lane_model_id,
2230            &ctx.chat,
2231        );
2232    }
2233}
2234
2235struct ThinkingCommand;
2236impl SlashCommand for ThinkingCommand {
2237    fn name(&self) -> &'static str {
2238        "/thinking"
2239    }
2240    fn aliases(&self) -> &'static [&'static str] {
2241        &["/think"]
2242    }
2243    fn description(&self) -> &'static str {
2244        "Set thinking level (selector)"
2245    }
2246    fn execute(&self, ctx: &CommandContext, args: &str) {
2247        let level_name = args.trim();
2248        if !level_name.is_empty() {
2249            // /thinking <level> — direct set (pi supports the param form).
2250            let Some(level) = thinking_level_from_name(level_name) else {
2251                add_error_message(
2252                    &ctx.chat,
2253                    &format!(
2254                        "Unknown thinking level \"{level_name}\". Valid: {}",
2255                        crate::args::VALID_THINKING_LEVELS.join(", ")
2256                    ),
2257                );
2258                ctx.tui.request_render(false);
2259                return;
2260            };
2261            let lane = ctx.lane.clone();
2262            let footer = ctx.state.footer.clone();
2263            tokio::spawn(async move {
2264                let _ = lane.set_thinking_level(level).await;
2265            });
2266            footer.set_thinking_level(Some(thinking_level_name(level)));
2267            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2268            ctx.tui.request_render(false);
2269            return;
2270        }
2271        open_thinking_selector(
2272            &ctx.state,
2273            &ctx.editor_container,
2274            &ctx.editor,
2275            &ctx.tui,
2276            &ctx.lane,
2277            &ctx.model_catalog,
2278            &ctx.lane_model_id,
2279            &ctx.chat,
2280        );
2281    }
2282}
2283
2284struct ToolsCommand;
2285impl SlashCommand for ToolsCommand {
2286    fn name(&self) -> &'static str {
2287        "/tools"
2288    }
2289    fn description(&self) -> &'static str {
2290        "Toggle tools on/off"
2291    }
2292    fn execute(&self, ctx: &CommandContext, _args: &str) {
2293        open_tools_selector(
2294            &ctx.state,
2295            &ctx.editor_container,
2296            &ctx.editor,
2297            &ctx.tui,
2298            &ctx.lane,
2299            &ctx.chat,
2300        );
2301    }
2302}
2303
2304struct ImagesCommand;
2305impl SlashCommand for ImagesCommand {
2306    fn name(&self) -> &'static str {
2307        "/images"
2308    }
2309    fn description(&self) -> &'static str {
2310        "Toggle inline images"
2311    }
2312    fn execute(&self, ctx: &CommandContext, _args: &str) {
2313        open_images_selector(
2314            &ctx.state,
2315            &ctx.editor_container,
2316            &ctx.editor,
2317            &ctx.tui,
2318            &ctx.chat,
2319        );
2320    }
2321}
2322
2323struct SessionCommand;
2324impl SlashCommand for SessionCommand {
2325    fn name(&self) -> &'static str {
2326        "/session"
2327    }
2328    fn aliases(&self) -> &'static [&'static str] {
2329        &["/resume"]
2330    }
2331    fn description(&self) -> &'static str {
2332        "List saved sessions"
2333    }
2334    fn execute(&self, ctx: &CommandContext, _args: &str) {
2335        open_session_selector(
2336            &ctx.state,
2337            &ctx.editor_container,
2338            &ctx.editor,
2339            &ctx.tui,
2340            &ctx.cwd,
2341            &ctx.tx,
2342        );
2343    }
2344}
2345
2346struct ThemeCommand;
2347impl SlashCommand for ThemeCommand {
2348    fn name(&self) -> &'static str {
2349        "/theme"
2350    }
2351    fn description(&self) -> &'static str {
2352        "Choose a theme (selector)"
2353    }
2354    fn execute(&self, ctx: &CommandContext, args: &str) {
2355        let name = args.trim().to_ascii_lowercase();
2356        if !name.is_empty() {
2357            // /theme <name> — direct apply + persist (matches /settings Theme).
2358            let preset = match name.as_str() {
2359                "light" => ThemePreset::Light,
2360                "monochrome" => ThemePreset::Monochrome,
2361                "dark" => ThemePreset::Dark,
2362                _ => {
2363                    add_error_message(
2364                        &ctx.chat,
2365                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2366                    );
2367                    ctx.tui.request_render(false);
2368                    return;
2369                }
2370            };
2371            apply_theme_preset(preset);
2372            let mut settings = crate::settings::load_settings().unwrap_or_default();
2373            settings.theme = Some(name.clone());
2374            let _ = crate::settings::save_settings(&settings);
2375            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2376            ctx.tui.request_render(false);
2377            ctx.tui.render_now(true);
2378            return;
2379        }
2380        open_theme_selector(
2381            &ctx.state,
2382            &ctx.editor_container,
2383            &ctx.editor,
2384            &ctx.tui,
2385            &ctx.cwd,
2386            &ctx.package_resources,
2387        );
2388    }
2389}
2390
2391struct CompactCommand;
2392impl SlashCommand for CompactCommand {
2393    fn name(&self) -> &'static str {
2394        "/compact"
2395    }
2396    fn description(&self) -> &'static str {
2397        "Compact the conversation"
2398    }
2399    fn execute(&self, ctx: &CommandContext, _args: &str) {
2400        let _ = ctx.tx.send(TuiMessage::Compact);
2401    }
2402}
2403
2404struct CopyCommand;
2405impl SlashCommand for CopyCommand {
2406    fn name(&self) -> &'static str {
2407        "/copy"
2408    }
2409    fn description(&self) -> &'static str {
2410        "Copy last reply to clipboard"
2411    }
2412    fn execute(&self, ctx: &CommandContext, _args: &str) {
2413        let _ = ctx.tx.send(TuiMessage::Copy);
2414    }
2415}
2416
2417struct ExportCommand;
2418impl SlashCommand for ExportCommand {
2419    fn name(&self) -> &'static str {
2420        "/export"
2421    }
2422    fn description(&self) -> &'static str {
2423        "Export session to a markdown file"
2424    }
2425    fn execute(&self, ctx: &CommandContext, _args: &str) {
2426        let _ = ctx.tx.send(TuiMessage::ExportSession);
2427    }
2428}
2429
2430struct ForkCommand;
2431impl SlashCommand for ForkCommand {
2432    fn name(&self) -> &'static str {
2433        "/fork"
2434    }
2435    fn description(&self) -> &'static str {
2436        "Fork the session into a new one"
2437    }
2438    fn execute(&self, ctx: &CommandContext, _args: &str) {
2439        let _ = ctx.tx.send(TuiMessage::ForkSession);
2440    }
2441}
2442
2443/// `/clone` is the native Pi spelling for duplicating the current session.
2444/// Reuse the same durable fork path as `/fork`; both create a child session
2445/// and rebind the live harness to it.
2446struct CloneCommand;
2447impl SlashCommand for CloneCommand {
2448    fn name(&self) -> &'static str {
2449        "/clone"
2450    }
2451    fn description(&self) -> &'static str {
2452        "Duplicate the current session"
2453    }
2454    fn execute(&self, ctx: &CommandContext, _args: &str) {
2455        let _ = ctx.tx.send(TuiMessage::ForkSession);
2456    }
2457}
2458
2459struct TreeCommand;
2460impl SlashCommand for TreeCommand {
2461    fn name(&self) -> &'static str {
2462        "/tree"
2463    }
2464    fn description(&self) -> &'static str {
2465        "Navigate the current session tree"
2466    }
2467    fn execute(&self, ctx: &CommandContext, _args: &str) {
2468        let _ = ctx.tx.send(TuiMessage::OpenTree);
2469    }
2470}
2471
2472struct LoginCommand;
2473impl SlashCommand for LoginCommand {
2474    fn name(&self) -> &'static str {
2475        "/login"
2476    }
2477    fn description(&self) -> &'static str {
2478        "Save an Anthropic API key"
2479    }
2480    fn execute(&self, ctx: &CommandContext, args: &str) {
2481        let key = args.trim();
2482        if key.is_empty() {
2483            add_note_message(&ctx.chat, "Usage: /login <api-key>");
2484        } else {
2485            let result = crate::config::upsert_credential(
2486                "anthropic",
2487                crate::config::Credential::ApiKey {
2488                    key: Some(key.to_string()),
2489                    env: None,
2490                },
2491            );
2492            match result {
2493                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2494                Err(error) => {
2495                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2496                }
2497            }
2498        }
2499        ctx.tui.request_render(false);
2500    }
2501}
2502
2503struct LogoutCommand;
2504impl SlashCommand for LogoutCommand {
2505    fn name(&self) -> &'static str {
2506        "/logout"
2507    }
2508    fn description(&self) -> &'static str {
2509        "Remove saved Anthropic credentials"
2510    }
2511    fn execute(&self, ctx: &CommandContext, _args: &str) {
2512        match crate::config::delete_credential("anthropic") {
2513            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2514            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2515            Err(error) => {
2516                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2517            }
2518        }
2519        ctx.tui.request_render(false);
2520    }
2521}
2522
2523fn set_project_trust_for_command(
2524    cwd: &std::path::Path,
2525    value: Option<bool>,
2526) -> Result<(), crate::config::ConfigError> {
2527    crate::config::set_project_trust(cwd, value)
2528}
2529
2530struct TrustCommand;
2531impl SlashCommand for TrustCommand {
2532    fn name(&self) -> &'static str {
2533        "/trust"
2534    }
2535    fn description(&self) -> &'static str {
2536        "Trust the current project"
2537    }
2538    fn execute(&self, ctx: &CommandContext, args: &str) {
2539        let value = match args.trim().to_ascii_lowercase().as_str() {
2540            "" | "yes" | "y" | "true" => Some(true),
2541            "no" | "n" | "false" => Some(false),
2542            "clear" | "reset" | "none" => None,
2543            _ => {
2544                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2545                ctx.tui.request_render(false);
2546                return;
2547            }
2548        };
2549        match set_project_trust_for_command(&ctx.cwd, value) {
2550            Ok(()) => {
2551                let label = match value {
2552                    Some(true) => "trusted",
2553                    Some(false) => "untrusted",
2554                    None => "trust decision cleared",
2555                };
2556                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2557            }
2558            Err(error) => add_error_message(
2559                &ctx.chat,
2560                &format!("Could not save trust decision: {error}"),
2561            ),
2562        }
2563        ctx.tui.request_render(false);
2564    }
2565}
2566
2567struct NameCommand;
2568impl SlashCommand for NameCommand {
2569    fn name(&self) -> &'static str {
2570        "/name"
2571    }
2572    fn description(&self) -> &'static str {
2573        "Set session display name"
2574    }
2575    fn execute(&self, ctx: &CommandContext, args: &str) {
2576        let name = args.trim();
2577        if name.is_empty() {
2578            add_note_message(
2579                &ctx.chat,
2580                "Usage: /name <display name> — sets the current session's name.",
2581            );
2582            ctx.tui.request_render(false);
2583            return;
2584        }
2585        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2586    }
2587}
2588
2589struct ImportCommand;
2590impl SlashCommand for ImportCommand {
2591    fn name(&self) -> &'static str {
2592        "/import"
2593    }
2594    fn description(&self) -> &'static str {
2595        "Import a session file (path)"
2596    }
2597    fn execute(&self, ctx: &CommandContext, args: &str) {
2598        let path = args.trim();
2599        if path.is_empty() {
2600            add_note_message(
2601                &ctx.chat,
2602                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2603            );
2604            ctx.tui.request_render(false);
2605            return;
2606        }
2607        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2608    }
2609}
2610
2611struct SettingsCommand;
2612impl SlashCommand for SettingsCommand {
2613    fn name(&self) -> &'static str {
2614        "/settings"
2615    }
2616    fn description(&self) -> &'static str {
2617        "Open settings menu"
2618    }
2619    fn execute(&self, ctx: &CommandContext, _args: &str) {
2620        open_settings_selector(
2621            &ctx.state,
2622            &ctx.editor_container,
2623            &ctx.editor,
2624            &ctx.tui,
2625            &ctx.lane,
2626            &ctx.model_catalog,
2627            &ctx.lane_model_id,
2628            &ctx.chat,
2629            &ctx.cwd,
2630            &ctx.package_resources,
2631        );
2632    }
2633}
2634
2635struct ScopedModelsCommand;
2636impl SlashCommand for ScopedModelsCommand {
2637    fn name(&self) -> &'static str {
2638        "/scoped-models"
2639    }
2640    fn description(&self) -> &'static str {
2641        "Choose models for Ctrl+M cycling"
2642    }
2643    fn execute(&self, ctx: &CommandContext, _args: &str) {
2644        open_scoped_models_selector(
2645            &ctx.state,
2646            &ctx.editor_container,
2647            &ctx.editor,
2648            &ctx.tui,
2649            &ctx.model_catalog,
2650            &ctx.chat,
2651        );
2652    }
2653}
2654
2655struct ShareCommand;
2656impl SlashCommand for ShareCommand {
2657    fn name(&self) -> &'static str {
2658        "/share"
2659    }
2660    fn description(&self) -> &'static str {
2661        "Share session (gist via gh, or clipboard)"
2662    }
2663    fn execute(&self, ctx: &CommandContext, _args: &str) {
2664        let _ = ctx.tx.send(TuiMessage::ShareSession);
2665    }
2666}
2667
2668struct ArminCommand;
2669impl SlashCommand for ArminCommand {
2670    fn name(&self) -> &'static str {
2671        "/armin"
2672    }
2673    fn description(&self) -> &'static str {
2674        "??? (easter egg)"
2675    }
2676    fn execute(&self, ctx: &CommandContext, _args: &str) {
2677        crate::extras::add_armin(&ctx.chat);
2678        ctx.tui.request_render(false);
2679    }
2680}
2681
2682struct EarendilCommand;
2683impl SlashCommand for EarendilCommand {
2684    fn name(&self) -> &'static str {
2685        "/earendil"
2686    }
2687    fn description(&self) -> &'static str {
2688        "Announcement"
2689    }
2690    fn execute(&self, ctx: &CommandContext, _args: &str) {
2691        crate::extras::add_earendil(&ctx.chat);
2692        ctx.tui.request_render(false);
2693    }
2694}
2695
2696/// `/context` — lists discovered context files, skills, and prompt templates.
2697/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2698/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2699struct ContextCommand;
2700impl SlashCommand for ContextCommand {
2701    fn name(&self) -> &'static str {
2702        "/context"
2703    }
2704    fn visible(&self) -> bool {
2705        false
2706    }
2707    fn execute(&self, ctx: &CommandContext, _args: &str) {
2708        show_context_panel(&ctx.chat, &ctx.resources);
2709        ctx.tui.request_render(false);
2710    }
2711}
2712
2713/// `/reload` — re-run extension + resource discovery into the LIVE harness
2714/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2715/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2716/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2717/// The command itself runs on the blocking submit thread, so it can't drive
2718/// the async `reload_extension_resources` routine directly — it signals the main
2719/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2720/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2721/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2722/// self-unmapping race a synchronous plugin-initiated reload would have.)
2723struct ReloadCommand;
2724impl SlashCommand for ReloadCommand {
2725    fn name(&self) -> &'static str {
2726        "/reload"
2727    }
2728    fn description(&self) -> &'static str {
2729        "Reload extensions, skills, prompts"
2730    }
2731    fn execute(&self, ctx: &CommandContext, _args: &str) {
2732        // Signal the main loop. It owns the `&AgentHarness` borrow the
2733        // `reload_extension_resources` routine needs (the blocking submit thread
2734        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
2735        add_note_message(&ctx.chat, "Reloading extensions + resources…");
2736        ctx.tui.request_render(false);
2737        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2738    }
2739}
2740
2741/// Build the full command registry: active built-ins first (so they win on a
2742/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
2743/// commands are merged in separately by the autocomplete builder (they dispatch
2744/// via template expansion, not this registry).
2745fn build_builtin_registry() -> CommandRegistry {
2746    let mut r = CommandRegistry::new();
2747    r.register(Arc::new(HelpCommand));
2748    r.register(Arc::new(ClearChatCommand));
2749    r.register(Arc::new(ExitCommand));
2750    r.register(Arc::new(VersionCommand));
2751    r.register(Arc::new(ChangelogCommand));
2752    r.register(Arc::new(ModelCommand));
2753    r.register(Arc::new(ThinkingCommand));
2754    r.register(Arc::new(ToolsCommand));
2755    r.register(Arc::new(ImagesCommand));
2756    r.register(Arc::new(SessionCommand));
2757    r.register(Arc::new(ThemeCommand));
2758    r.register(Arc::new(CompactCommand));
2759    r.register(Arc::new(CopyCommand));
2760    r.register(Arc::new(HotkeysCommand));
2761    r.register(Arc::new(ArminCommand));
2762    r.register(Arc::new(EarendilCommand));
2763    r.register(Arc::new(ContextCommand));
2764    // Recognized but inert in v1 (one struct backs them all). The TS builtins
2765    // out of v1 scope; each carries a description so autocomplete surfaces its
2766    // existence even though running it reports "not supported".
2767    r.register(Arc::new(NameCommand));
2768    r.register(Arc::new(SettingsCommand));
2769    r.register(Arc::new(ScopedModelsCommand));
2770    r.register(Arc::new(ExportCommand));
2771    r.register(Arc::new(ImportCommand));
2772    r.register(Arc::new(ShareCommand));
2773    r.register(Arc::new(ForkCommand));
2774    r.register(Arc::new(CloneCommand));
2775    r.register(Arc::new(TreeCommand));
2776    r.register(Arc::new(TrustCommand));
2777    r.register(Arc::new(LoginCommand));
2778    r.register(Arc::new(LogoutCommand));
2779    r.register(Arc::new(ReloadCommand));
2780    r
2781}
2782
2783fn register_extension_commands(
2784    registry: &mut CommandRegistry,
2785    session: crate::session::ExtensionSessionCell,
2786) {
2787    let commands = session
2788        .lock()
2789        .ok()
2790        .and_then(|s| s.snapshot_arc())
2791        .map(|snap| snap.commands().to_vec())
2792        .unwrap_or_default();
2793    for command in commands {
2794        let name = if command.name.starts_with('/') {
2795            command.name.clone()
2796        } else {
2797            format!("/{}", command.name)
2798        };
2799        if registry.find(&name).is_some() {
2800            continue;
2801        }
2802        registry.register(Arc::new(ExtensionCommand {
2803            name,
2804            description: command.description,
2805            session: session.clone(),
2806        }));
2807    }
2808}
2809
2810fn register_js_extension_commands(
2811    registry: &mut CommandRegistry,
2812    session: Option<crate::js_extensions::JsExtensionSession>,
2813) {
2814    let Some(session) = session else {
2815        return;
2816    };
2817    for command in &session.commands {
2818        let name = if command.starts_with('/') {
2819            command.clone()
2820        } else {
2821            format!("/{command}")
2822        };
2823        if registry.find(&name).is_none() {
2824            registry.register(Arc::new(JsExtensionCommand {
2825                name,
2826                session: session.clone(),
2827            }));
2828        }
2829    }
2830}
2831
2832// ===========================================================================
2833// Channel + helpers
2834// ===========================================================================
2835
2836/// Message type for communication between the key/callback threads and the
2837/// main async loop.
2838enum TuiMessage {
2839    UserInput(String),
2840    OpenTree,
2841    NavigateTree(String),
2842    Exit,
2843    /// Clear the transcript (from `/clear`).
2844    ClearChat,
2845    /// Compact the conversation (from `/compact`).
2846    Compact,
2847    /// Copy the last assistant reply to the clipboard (from `/copy`).
2848    Copy,
2849    /// Hot-switch to another saved session (from the `/session` selector):
2850    /// the payload is the session id the selector's item value carried.
2851    SwitchSession(String),
2852    /// Export the current session to a markdown file (from `/export`).
2853    ExportSession,
2854    /// Fork the current session into a new one and switch to it (from `/fork`).
2855    ForkSession,
2856    /// Rename the current session (from `/name <name>`).
2857    SetSessionName(String),
2858    /// Import a JSONL session file into the session dir and switch to it
2859    /// (from `/import <path>`).
2860    ImportSession(String),
2861    /// Share the current session (`/share`): `gh gist create` when the gh CLI
2862    /// is available, otherwise copy the transcript to the clipboard.
2863    ShareSession,
2864    /// `/reload` — re-run extension + resource discovery into the live harness
2865    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
2866    /// mailbox) signal the main loop, which awaits
2867    /// `reload_extension_resources` on the async runtime.
2868    ReloadExtensions,
2869    /// Result returned after Ctrl+G edits a temporary file in an external
2870    /// editor. Handling it on the async loop keeps editor mutation single-
2871    /// threaded with the rest of the TUI state.
2872    ExternalEditorResult(Result<String, String>),
2873}
2874
2875/// Extract the concatenated text content from an assistant message (mirrors
2876/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
2877fn assistant_text(msg: &AssistantMessage) -> String {
2878    msg.content
2879        .iter()
2880        .filter_map(|c| match c {
2881            Content::Text(t) => Some(t.text.clone()),
2882            _ => None,
2883        })
2884        .collect()
2885}
2886
2887/// The user message's text (Text content or the text blocks of a Blocks
2888/// payload — images are skipped, consistent with the v1 text-only prompt path).
2889fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2890    msg.content
2891        .iter()
2892        .filter_map(|c| match c {
2893            Content::Text(t) => Some(t.text.clone()),
2894            _ => None,
2895        })
2896        .collect::<Vec<_>>()
2897        .join("\n")
2898}
2899
2900fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2901    match &msg.content {
2902        rpi_ai::types::UserContent::Text(s) => s.clone(),
2903        rpi_ai::types::UserContent::Blocks(blocks) => blocks
2904            .iter()
2905            .filter_map(|c| match c {
2906                Content::Text(t) => Some(t.text.clone()),
2907                _ => None,
2908            })
2909            .collect(),
2910    }
2911}
2912
2913/// Render the `/settings` panel: the saved settings.json values the session
2914/// honors, plus pointers to the commands that edit them (theme via `/theme`,
2915/// defaults via flags, cycle scope via `/scoped-models`). Kept for the
2916/// read-only summary; the interactive menu is [`open_settings_selector`].
2917fn show_settings_panel(chat: &Arc<Container>) {
2918    let s = crate::settings::load_settings().unwrap_or_default();
2919    let mut lines: Vec<String> = Vec::new();
2920    lines.push("⚙️  Saved settings:".into());
2921    lines.push(format!(
2922        "  Theme: {} (edit with /theme)",
2923        s.theme.as_deref().unwrap_or("(default)")
2924    ));
2925    lines.push(format!(
2926        "  Default model: {} (set at launch with --model)",
2927        s.default_model.as_deref().unwrap_or("(none)")
2928    ));
2929    lines.push(format!(
2930        "  Default thinking: {} (set at launch with --thinking)",
2931        s.default_thinking_level.as_deref().unwrap_or("(default)")
2932    ));
2933    match &s.scoped_models {
2934        Some(list) if !list.is_empty() => lines.push(format!(
2935            "  Ctrl+M cycle scope: {} (edit with /scoped-models)",
2936            list.join(", ")
2937        )),
2938        _ => lines.push("  Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
2939    }
2940    let body = lines.join("\n");
2941    container_note_block(chat, &body);
2942}
2943
2944/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
2945/// settings.json when present, otherwise every model. The current model is
2946/// always included (fallback) so cycling can never strand the user off-scope.
2947fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
2948    let scoped = crate::settings::load_settings()
2949        .ok()
2950        .and_then(|s| s.scoped_models)
2951        .unwrap_or_default();
2952    if scoped.is_empty() {
2953        return catalog.to_vec();
2954    }
2955    let mut out: Vec<rpi_ai::Model> = catalog
2956        .iter()
2957        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
2958        .cloned()
2959        .collect();
2960    // Never strand the user: if the current model isn't in scope, keep it.
2961    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
2962        if let Some(cur) = catalog
2963            .iter()
2964            .find(|m| m.id.eq_ignore_ascii_case(current_id))
2965        {
2966            out.push(cur.clone());
2967        }
2968    }
2969    out
2970}
2971
2972/// Interactive `/settings` menu: a top-level selector over the editable
2973/// settings, each opening a sub-selector that applies the choice AND persists
2974/// it to settings.json (theme / default model / default thinking / cycle
2975/// scope). Selecting a menu item swaps the current selector for the
2976/// sub-selector (the `active_selector` slot is single, so each open replaces
2977/// the previous list); the sub-selector's cancel restores the editor.
2978fn open_settings_selector(
2979    state: &Arc<TuiState>,
2980    editor_container: &Arc<Container>,
2981    editor: &Arc<Editor>,
2982    tui: &Arc<TuiAltScreen>,
2983    lane: &Arc<dyn AgentLane>,
2984    catalog: &[rpi_ai::Model],
2985    lane_model_id: &str,
2986    chat: &Arc<Container>,
2987    cwd: &std::path::Path,
2988    package_resources: &Arc<crate::packages::PackageResources>,
2989) {
2990    let settings = crate::settings::load_settings().unwrap_or_default();
2991    let mut items: Vec<SelectItem> = Vec::new();
2992    items.push(
2993        SelectItem::new("theme", "Theme")
2994            .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
2995    );
2996    items.push(
2997        SelectItem::new("model", "Default model").with_description(
2998            &settings
2999                .default_model
3000                .clone()
3001                .unwrap_or_else(|| "(none)".into()),
3002        ),
3003    );
3004    items.push(
3005        SelectItem::new("thinking", "Default thinking").with_description(
3006            &settings
3007                .default_thinking_level
3008                .clone()
3009                .unwrap_or_else(|| "(default)".into()),
3010        ),
3011    );
3012    let scope_desc = match &settings.scoped_models {
3013        Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
3014        _ => "all models".to_string(),
3015    };
3016    items
3017        .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
3018    let list = Arc::new(SelectList::new(items, 10));
3019
3020    let state_sel = state.clone();
3021    let ec_sel = editor_container.clone();
3022    let editor_sel = editor.clone();
3023    let tui_sel = tui.clone();
3024    let lane_sel = lane.clone();
3025    let chat_sel = chat.clone();
3026    let catalog_sel = catalog.to_vec();
3027    let lane_model_sel = lane_model_id.to_string();
3028    let cwd_sel = cwd.to_path_buf();
3029    let package_resources_sel = package_resources.clone();
3030    list.on_select(Arc::new(move |item| {
3031        // Swap this menu for the sub-selector; each sub-selector saves its
3032        // choice to settings.json on select.
3033        match item.value.as_str() {
3034            "theme" => open_settings_theme_selector(
3035                &state_sel,
3036                &ec_sel,
3037                &editor_sel,
3038                &tui_sel,
3039                &chat_sel,
3040                &cwd_sel,
3041                &package_resources_sel,
3042            ),
3043            "model" => open_settings_model_selector(
3044                &state_sel,
3045                &ec_sel,
3046                &editor_sel,
3047                &tui_sel,
3048                &lane_sel,
3049                &catalog_sel,
3050                &lane_model_sel,
3051                &chat_sel,
3052            ),
3053            "thinking" => open_settings_thinking_selector(
3054                &state_sel,
3055                &ec_sel,
3056                &editor_sel,
3057                &tui_sel,
3058                &lane_sel,
3059                &catalog_sel,
3060                &lane_model_sel,
3061                &chat_sel,
3062            ),
3063            "scoped-models" => open_scoped_models_selector(
3064                &state_sel,
3065                &ec_sel,
3066                &editor_sel,
3067                &tui_sel,
3068                &catalog_sel,
3069                &chat_sel,
3070            ),
3071            _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3072        }
3073    }));
3074    let state_cancel = state.clone();
3075    let ec_cancel = editor_container.clone();
3076    let editor_cancel = editor.clone();
3077    let tui_cancel = tui.clone();
3078    list.on_cancel(Arc::new(move || {
3079        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3080    }));
3081
3082    open_selector(
3083        state,
3084        editor_container,
3085        editor,
3086        tui,
3087        list,
3088        SelectorKind::Settings,
3089    );
3090}
3091
3092/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3093fn open_settings_theme_selector(
3094    state: &Arc<TuiState>,
3095    editor_container: &Arc<Container>,
3096    editor: &Arc<Editor>,
3097    tui: &Arc<TuiAltScreen>,
3098    chat: &Arc<Container>,
3099    cwd: &std::path::Path,
3100    package_resources: &Arc<crate::packages::PackageResources>,
3101) {
3102    let mut items = vec![
3103        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3104        SelectItem::new("light", "Light").with_description("Light background"),
3105        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3106    ];
3107    if state.themes_enabled {
3108        for path in package_resources.theme_files() {
3109            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3110                items.push(SelectItem::new(name, name).with_description("Package theme"));
3111            }
3112        }
3113    }
3114    let list = Arc::new(SelectList::new(items, 10));
3115
3116    let state_sel = state.clone();
3117    let ec_sel = editor_container.clone();
3118    let editor_sel = editor.clone();
3119    let tui_sel = tui.clone();
3120    let chat_sel = chat.clone();
3121    let cwd_sel = cwd.to_path_buf();
3122    let package_resources_sel = package_resources.clone();
3123    list.on_select(Arc::new(move |item| {
3124        let preset = match item.value.as_str() {
3125            "light" => Some(ThemePreset::Light),
3126            "monochrome" => Some(ThemePreset::Monochrome),
3127            "dark" => Some(ThemePreset::Dark),
3128            name => {
3129                if state_sel.themes_enabled {
3130                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3131                        &cwd_sel,
3132                        name,
3133                        &package_resources_sel,
3134                    ) {
3135                        rpi_tui::global_theme_manager().set(custom.clone());
3136                        state_sel.theme_manager.set(custom);
3137                    }
3138                }
3139                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3140                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3141                tui_sel.render_now(true);
3142                return;
3143            }
3144        };
3145        let Some(preset) = preset else { return };
3146        apply_theme_preset(preset);
3147        state_sel.theme_manager.apply_preset(preset);
3148        let mut settings = crate::settings::load_settings().unwrap_or_default();
3149        settings.theme = Some(item.value.clone());
3150        let saved = crate::settings::save_settings(&settings);
3151        add_note_message(
3152            &chat_sel,
3153            &format!(
3154                "Theme set to {} (saved{})",
3155                item.label,
3156                if saved.is_ok() { "" } else { ", not saved" },
3157            ),
3158        );
3159        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3160        tui_sel.render_now(true);
3161    }));
3162    let state_cancel = state.clone();
3163    let ec_cancel = editor_container.clone();
3164    let editor_cancel = editor.clone();
3165    let tui_cancel = tui.clone();
3166    list.on_cancel(Arc::new(move || {
3167        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3168    }));
3169
3170    open_selector(
3171        state,
3172        editor_container,
3173        editor,
3174        tui,
3175        list,
3176        SelectorKind::Settings,
3177    );
3178}
3179
3180/// Choose the default model AND persist it (`/settings` → Default model):
3181/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3182/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3183fn open_settings_model_selector(
3184    state: &Arc<TuiState>,
3185    editor_container: &Arc<Container>,
3186    editor: &Arc<Editor>,
3187    tui: &Arc<TuiAltScreen>,
3188    lane: &Arc<dyn AgentLane>,
3189    catalog: &[rpi_ai::Model],
3190    lane_model_id: &str,
3191    chat: &Arc<Container>,
3192) {
3193    let items = model_selector_items(catalog, lane_model_id);
3194    if items.is_empty() {
3195        add_note_message(chat, "No models in the catalog.");
3196        tui.request_render(false);
3197        return;
3198    }
3199    let list = Arc::new(SelectList::new(items, 10));
3200
3201    let catalog_arc = catalog.to_vec();
3202    let state_sel = state.clone();
3203    let ec_sel = editor_container.clone();
3204    let editor_sel = editor.clone();
3205    let tui_sel = tui.clone();
3206    let chat_sel = chat.clone();
3207    let lane_sel = lane.clone();
3208    list.on_select(Arc::new(move |item| {
3209        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3210            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3211            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3212            return;
3213        };
3214        state_sel.set_current_model(&model);
3215        let lane = lane_sel.clone();
3216        tokio::spawn(async move {
3217            let _ = lane.set_model(model).await;
3218        });
3219        let mut settings = crate::settings::load_settings().unwrap_or_default();
3220        settings.default_model = Some(item.value.clone());
3221        let saved = crate::settings::save_settings(&settings);
3222        add_note_message(
3223            &chat_sel,
3224            &format!(
3225                "Default model set to {} (saved{}",
3226                short_model_name(&item.value),
3227                if saved.is_ok() { ")" } else { ", not saved)" },
3228            ),
3229        );
3230        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3231    }));
3232    let state_cancel = state.clone();
3233    let ec_cancel = editor_container.clone();
3234    let editor_cancel = editor.clone();
3235    let tui_cancel = tui.clone();
3236    list.on_cancel(Arc::new(move || {
3237        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3238    }));
3239
3240    open_selector(
3241        state,
3242        editor_container,
3243        editor,
3244        tui,
3245        list,
3246        SelectorKind::Settings,
3247    );
3248}
3249
3250/// Convert the authenticated runtime catalog into selector rows. Keep the
3251/// model id as the value so `/model <id>` and the selection callback share one
3252/// lookup path, while making the provider visible for OpenAI-compatible
3253/// gateways where the same model id may exist at multiple endpoints.
3254fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3255    let mut seen = std::collections::HashSet::new();
3256    catalog
3257        .iter()
3258        .filter(|m| {
3259            seen.insert((
3260                m.api.clone(),
3261                m.provider.to_ascii_lowercase(),
3262                m.id.to_ascii_lowercase(),
3263            ))
3264        })
3265        .map(|m| {
3266            let label = if m.name.is_empty() {
3267                short_model_name(&m.id)
3268            } else {
3269                m.name.clone()
3270            };
3271            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3272                && m.provider.eq_ignore_ascii_case("anthropic")
3273            {
3274                m.id.clone()
3275            } else {
3276                format!("{}/{}", m.provider, m.id)
3277            };
3278            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3279                " (current)"
3280            } else {
3281                ""
3282            };
3283            SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
3284        })
3285        .collect()
3286}
3287
3288/// Resolve a selector input by either bare model id or the qualified
3289/// `provider/model` identity shown for gateway models. This keeps manual
3290/// `/model ...` input consistent with the rows rendered by the selector.
3291fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3292    let (provider, id) = input
3293        .split_once('/')
3294        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3295        .map_or((None, input), |(provider, id)| (Some(provider), id));
3296    catalog
3297        .iter()
3298        .find(|model| {
3299            model.id.eq_ignore_ascii_case(id)
3300                && provider.map_or(true, |provider| {
3301                    model.provider.eq_ignore_ascii_case(provider)
3302                        || (provider.eq_ignore_ascii_case("anthropic")
3303                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3304                })
3305        })
3306        .cloned()
3307}
3308
3309/// Choose the default thinking level AND persist it (`/settings` → Default
3310/// thinking): applies live via `lane.set_thinking_level` and saves
3311/// `defaultThinkingLevel` to settings.json.
3312fn open_settings_thinking_selector(
3313    state: &Arc<TuiState>,
3314    editor_container: &Arc<Container>,
3315    editor: &Arc<Editor>,
3316    tui: &Arc<TuiAltScreen>,
3317    lane: &Arc<dyn AgentLane>,
3318    catalog: &[rpi_ai::Model],
3319    lane_model_id: &str,
3320    chat: &Arc<Container>,
3321) {
3322    let model = catalog
3323        .iter()
3324        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3325    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3326        .map(|m| m.supported_thinking_levels())
3327        .unwrap_or_else(|| {
3328            use rpi_ai::types::ThinkingLevel::*;
3329            vec![Off, Minimal, Low, Medium, High]
3330        });
3331    let mut items: Vec<SelectItem> = Vec::new();
3332    for lvl in &levels {
3333        let name = thinking_level_name(*lvl);
3334        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3335    }
3336    if items.is_empty() {
3337        add_note_message(chat, "This model has no supported thinking levels.");
3338        tui.request_render(false);
3339        return;
3340    }
3341    let list = Arc::new(SelectList::new(items, 10));
3342
3343    let state_sel = state.clone();
3344    let ec_sel = editor_container.clone();
3345    let editor_sel = editor.clone();
3346    let tui_sel = tui.clone();
3347    let chat_sel = chat.clone();
3348    let lane_sel = lane.clone();
3349    list.on_select(Arc::new(move |item| {
3350        let Some(level) = thinking_level_from_name(&item.value) else {
3351            add_note_message(
3352                &chat_sel,
3353                &format!("Unknown thinking level: {}.", item.label),
3354            );
3355            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3356            return;
3357        };
3358        let lane = lane_sel.clone();
3359        let footer_sel = state_sel.footer.clone();
3360        tokio::spawn(async move {
3361            let _ = lane.set_thinking_level(level).await;
3362        });
3363        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3364        let mut settings = crate::settings::load_settings().unwrap_or_default();
3365        settings.default_thinking_level = Some(item.value.clone());
3366        let saved = crate::settings::save_settings(&settings);
3367        add_note_message(
3368            &chat_sel,
3369            &format!(
3370                "Default thinking set to {} (saved{}",
3371                item.label,
3372                if saved.is_ok() { ")" } else { ", not saved)" },
3373            ),
3374        );
3375        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3376    }));
3377    let state_cancel = state.clone();
3378    let ec_cancel = editor_container.clone();
3379    let editor_cancel = editor.clone();
3380    let tui_cancel = tui.clone();
3381    list.on_cancel(Arc::new(move || {
3382        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3383    }));
3384
3385    open_selector(
3386        state,
3387        editor_container,
3388        editor,
3389        tui,
3390        list,
3391        SelectorKind::Settings,
3392    );
3393}
3394
3395/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3396/// item toggles it in the in-progress set (the selector stays open); Esc saves
3397/// the set to settings.json and closes. The active scoped set is echoed after
3398/// each toggle so the user sees the current selection.
3399fn open_scoped_models_selector(
3400    state: &Arc<TuiState>,
3401    editor_container: &Arc<Container>,
3402    editor: &Arc<Editor>,
3403    tui: &Arc<TuiAltScreen>,
3404    catalog: &[rpi_ai::Model],
3405    chat: &Arc<Container>,
3406) {
3407    if catalog.is_empty() {
3408        add_note_message(chat, "No models in the catalog.");
3409        tui.request_render(false);
3410        return;
3411    }
3412    // Seed the edit set from the saved scoped models.
3413    let seed: Vec<String> = crate::settings::load_settings()
3414        .ok()
3415        .and_then(|s| s.scoped_models)
3416        .unwrap_or_default();
3417    *state.scoped_edit.lock().unwrap() = Some(seed);
3418
3419    let mut items: Vec<SelectItem> = Vec::new();
3420    for m in catalog {
3421        items.push(SelectItem::new(&m.id, &m.id));
3422    }
3423    let list = Arc::new(SelectList::new(items, 10));
3424
3425    let state_sel = state.clone();
3426    let chat_sel = chat.clone();
3427    let tui_sel = tui.clone();
3428    list.on_select(Arc::new(move |item| {
3429        // Toggle the model in the in-progress set; the selector stays open.
3430        let mut set = state_sel.scoped_edit.lock().unwrap();
3431        let set = set.get_or_insert_with(Vec::new);
3432        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3433            set.remove(pos);
3434            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3435        } else {
3436            set.push(item.value.clone());
3437            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3438        }
3439        tui_sel.request_render(false);
3440    }));
3441    let state_cancel = state.clone();
3442    let ec_cancel = editor_container.clone();
3443    let editor_cancel = editor.clone();
3444    let tui_cancel = tui.clone();
3445    let chat_cancel = chat.clone();
3446    list.on_cancel(Arc::new(move || {
3447        // Save the edited set to settings.json and close.
3448        let set = state_cancel
3449            .scoped_edit
3450            .lock()
3451            .unwrap()
3452            .take()
3453            .unwrap_or_default();
3454        let mut settings = crate::settings::load_settings().unwrap_or_default();
3455        settings.scoped_models = if set.is_empty() {
3456            None
3457        } else {
3458            Some(set.clone())
3459        };
3460        match crate::settings::save_settings(&settings) {
3461            Ok(()) => {
3462                if set.is_empty() {
3463                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3464                } else {
3465                    add_note_message(
3466                        &chat_cancel,
3467                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3468                    );
3469                }
3470            }
3471            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3472        }
3473        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3474    }));
3475
3476    open_selector(
3477        state,
3478        editor_container,
3479        editor,
3480        tui,
3481        list,
3482        SelectorKind::ScopedModels,
3483    );
3484}
3485
3486/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3487/// PATH, create a gist of the exported markdown; otherwise fall back to the
3488/// clipboard (best-effort) and note the local path.
3489async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3490    use std::process::Stdio;
3491
3492    // Reuse the export builder for the transcript text.
3493    let tree = harness.session().view("main");
3494    let entries = match tree
3495        .find_entries(&EntryQuery {
3496            entry_type: None,
3497            custom_type: None,
3498            // Exports append entries top-to-bottom, so use chronological order
3499            // instead of the session query default (newest-first).
3500            order: Some(EntryOrder::OldestFirst),
3501            limit: None,
3502            cursor: None,
3503        })
3504        .await
3505    {
3506        Ok(e) => e,
3507        Err(e) => {
3508            add_error_message(chat, &format!("Could not read session: {e}"));
3509            return;
3510        }
3511    };
3512    let mut md = String::from("# Session\n\n");
3513    for e in entries {
3514        let Entry::Message(me) = e else { continue };
3515        match &me.message {
3516            AgentMessage::User(u) => {
3517                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3518            }
3519            AgentMessage::Assistant(a) => {
3520                let text = assistant_text(a);
3521                if !text.is_empty() {
3522                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3523                }
3524            }
3525            _ => {}
3526        }
3527    }
3528
3529    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3530    let gh = std::process::Command::new("gh")
3531        .arg("gist")
3532        .arg("create")
3533        .arg("--filename")
3534        .arg("session.md")
3535        .arg("-")
3536        .stdin(Stdio::piped())
3537        .stdout(Stdio::piped())
3538        .stderr(Stdio::null())
3539        .spawn();
3540    if let Ok(mut child) = gh {
3541        use std::io::Write;
3542        if let Some(mut stdin) = child.stdin.take() {
3543            let _ = stdin.write_all(md.as_bytes());
3544            let _ = stdin.flush();
3545        }
3546        let out = child.wait_with_output().ok();
3547        if let Some(out) = out {
3548            if out.status.success() {
3549                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3550                add_note_message(chat, &format!("Shared session: {url}"));
3551                return;
3552            }
3553        }
3554        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3555    } else {
3556        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3557    }
3558    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3559    if copy_to_clipboard(&md) {
3560        add_note_message(chat, "Session transcript copied to the clipboard.");
3561    } else {
3562        add_note_message(
3563            chat,
3564            "Clipboard unavailable — use /export to write the transcript to a file.",
3565        );
3566    }
3567}
3568
3569/// Export the current session to a markdown transcript file. Writes
3570/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
3571/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
3572/// Best-effort: failures surface as a chat note.
3573/// Export the current session to a markdown transcript file. Writes
3574/// `<cwd>/<session-name-or-id>.md` with the user/assistant/tool-call history
3575/// (mirrors the TS `/export` intent locally — no remote sharing in v1).
3576/// Best-effort: failures surface as a chat note.
3577async fn export_session(harness: &AgentHarness, chat: &Arc<Container>, cwd: &std::path::Path) {
3578    let tree = harness.session().view("main");
3579    let entries = match tree
3580        .find_entries(&EntryQuery {
3581            entry_type: None,
3582            custom_type: None,
3583            // Keep exported entries in the same chronological order shown in
3584            // the transcript; the storage default is newest-first.
3585            order: Some(EntryOrder::OldestFirst),
3586            limit: None,
3587            cursor: None,
3588        })
3589        .await
3590    {
3591        Ok(e) => e,
3592        Err(e) => {
3593            add_error_message(chat, &format!("Could not read session: {e}"));
3594            return;
3595        }
3596    };
3597    let name = tree.get_name().await.ok().flatten().unwrap_or_default();
3598    let id = tree
3599        .get_leaf_id()
3600        .await
3601        .ok()
3602        .flatten()
3603        .unwrap_or_else(|| "session".to_string());
3604    let mut md = String::from("# Session\n\n");
3605    for e in entries {
3606        let Entry::Message(me) = e else { continue };
3607        match &me.message {
3608            AgentMessage::User(u) => {
3609                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3610            }
3611            AgentMessage::Assistant(a) => {
3612                let text = assistant_text(a);
3613                if !text.is_empty() {
3614                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3615                }
3616            }
3617            _ => {}
3618        }
3619    }
3620    let file_name = if name.is_empty() {
3621        format!("{id}.md")
3622    } else {
3623        format!("{name}.md")
3624    };
3625    let path = cwd.join(&file_name);
3626    match std::fs::write(&path, md) {
3627        Ok(_) => add_note_message(chat, &format!("Exported session to {}", path.display())),
3628        Err(e) => add_error_message(chat, &format!("Could not write export: {e}")),
3629    }
3630}
3631
3632/// Fork the current session into a new JSONL session and switch to it (TS
3633/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3634/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3635/// the harness backing and renders the (empty-ish) fork transcript.
3636/// Hot-switch the harness to another saved session: abort any in-flight run,
3637/// open the target session file, swap the durable backing, and re-render the
3638/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3639/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3640/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3641async fn switch_to_session(
3642    harness: &AgentHarness,
3643    lane: &Arc<dyn AgentLane>,
3644    id: &str,
3645    cwd: &std::path::Path,
3646    chat: &Arc<Container>,
3647    state: &Arc<TuiState>,
3648) -> bool {
3649    if *state.status.lock().unwrap() == RunStatus::Working {
3650        state.set_status(RunStatus::Aborting);
3651        let _ = lane.abort().await;
3652    }
3653    let cwd_str = cwd.to_string_lossy().to_string();
3654    match crate::session::open_session_by_id(id, &cwd_str).await {
3655        Ok(new_session) => {
3656            let _ = harness.set_session(new_session).await;
3657            chat.clear();
3658            add_welcome_message(chat);
3659            render_session_history(
3660                harness,
3661                chat,
3662                state.markdown_transformer(),
3663                Some(state.extension_session.clone()),
3664            )
3665            .await;
3666            state.set_status(RunStatus::Idle);
3667            add_note_message(chat, &format!("Switched to session {id}."));
3668            true
3669        }
3670        Err(e) => {
3671            state.set_status(RunStatus::Idle);
3672            add_error_message(chat, &format!("Could not open session {id}: {e}"));
3673            false
3674        }
3675    }
3676}
3677
3678/// `/import <path>`: copy a JSONL session file into the default session dir,
3679/// then hot-switch to it (the file name becomes its id — matching the
3680/// selector/`open_session_by_id` containment rules).
3681async fn import_session(
3682    harness: &AgentHarness,
3683    lane: &Arc<dyn AgentLane>,
3684    path: &str,
3685    cwd: &std::path::Path,
3686    chat: &Arc<Container>,
3687    state: &Arc<TuiState>,
3688) {
3689    use std::path::Path as FsPath;
3690
3691    let src = FsPath::new(path);
3692    if !src.is_file() {
3693        add_error_message(chat, &format!("Import source not found: {path}"));
3694        return;
3695    }
3696    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3697        add_error_message(chat, "Import source has no file name.");
3698        return;
3699    };
3700    if !fname.ends_with(".jsonl") {
3701        add_error_message(chat, "Import source must be a .jsonl session file.");
3702        return;
3703    }
3704    let dir = crate::session::default_session_dir(cwd);
3705    if let Err(e) = std::fs::create_dir_all(&dir) {
3706        add_error_message(chat, &format!("Could not create session dir: {e}"));
3707        return;
3708    }
3709    let dest = dir.join(fname);
3710    match std::fs::copy(src, &dest) {
3711        Ok(_) => {
3712            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3713            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3714                add_note_message(chat, &format!("Imported session from {path}"));
3715            }
3716        }
3717        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3718    }
3719}
3720
3721async fn fork_session(
3722    harness: &AgentHarness,
3723    cwd: &std::path::Path,
3724    chat: &Arc<Container>,
3725    state: &Arc<TuiState>,
3726) {
3727    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3728    use rpi_tools::FileSystem;
3729
3730    let cwd_str = cwd.to_string_lossy().to_string();
3731    let dir = crate::session::default_session_dir(cwd);
3732    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3733    let fs: Arc<dyn FileSystem> = env.clone();
3734    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3735        fs,
3736        sessions_root: dir.to_string_lossy().into_owned(),
3737        clock: Arc::new(rpi_harness::session::memory::SystemClock),
3738        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3739    });
3740    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
3741    // it from the session list by the current session's id.
3742    let id = harness.session().storage().metadata().id.clone();
3743    let metas = match crate::session::list_session_metadata(&cwd_str).await {
3744        Ok(m) => m,
3745        Err(e) => {
3746            add_error_message(chat, &format!("Could not list sessions: {e}"));
3747            return;
3748        }
3749    };
3750    let Some(source) = metas.iter().find(|m| m.id == id) else {
3751        add_error_message(chat, &format!("Current session {id} not found on disk."));
3752        return;
3753    };
3754    let fork_storage = match repo
3755        .fork_typed(
3756            source,
3757            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3758                id: None,
3759                parent_session_id: Some(source.id.clone()),
3760                cwd: cwd_str.clone(),
3761                metadata: None,
3762            },
3763            &rpi_harness::session::types::ForkOptions::default(),
3764        )
3765        .await
3766    {
3767        Ok(s) => s,
3768        Err(e) => {
3769            add_error_message(chat, &format!("Could not fork session: {e}"));
3770            return;
3771        }
3772    };
3773    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3774    let _ = harness.set_session(new_session).await;
3775    chat.clear();
3776    add_welcome_message(chat);
3777    render_session_history(
3778        harness,
3779        chat,
3780        state.markdown_transformer(),
3781        Some(state.extension_session.clone()),
3782    )
3783    .await;
3784    state.set_status(RunStatus::Idle);
3785    add_note_message(chat, "Forked into a new session.");
3786}
3787
3788/// Render the restored session's prior transcript (user + assistant messages)
3789/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
3790/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
3791/// any session read failure just starts with an empty transcript.
3792///
3793/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
3794/// the identity path. Each restored assistant component installs it so replayed
3795/// history renders through the same `register_markdown_transformer` handlers
3796/// the live stream does.
3797async fn render_session_history(
3798    harness: &AgentHarness,
3799    chat: &Arc<Container>,
3800    transformer: Option<MarkdownTransformer>,
3801    extension_session: Option<crate::session::ExtensionSessionCell>,
3802) {
3803    let tree = harness.session().view("main");
3804    let entries = match tree
3805        .find_entries(&EntryQuery {
3806            entry_type: None,
3807            custom_type: None,
3808            // Session queries default to newest-first for selectors and
3809            // pagination. The transcript appends children top-to-bottom, so
3810            // restored history must explicitly be chronological.
3811            order: Some(EntryOrder::OldestFirst),
3812            limit: None,
3813            cursor: None,
3814        })
3815        .await
3816    {
3817        Ok(e) => e,
3818        Err(_) => return,
3819    };
3820    let mut rendered_any = false;
3821    for e in entries {
3822        match e {
3823            Entry::Message(me) => match &me.message {
3824                AgentMessage::User(u) => {
3825                    add_user_message(chat, &user_message_text(u));
3826                    rendered_any = true;
3827                }
3828                AgentMessage::Assistant(a) => {
3829                    let comp = Arc::new(AssistantMessageComponent::new(
3830                        AssistantMessageOptions::default(),
3831                    ));
3832                    if let Some(t) = &transformer {
3833                        comp.set_markdown_transformer(Some(t.clone()));
3834                    }
3835                    comp.update_blocks(&assistant_blocks(a));
3836                    chat.add_child(comp);
3837                    // Single trailing spacer: the next transcript entry (user or
3838                    // assistant) follows one blank line below.
3839                    chat.add_child(Arc::new(Spacer::new(1)));
3840                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3841                        add_note_message(chat, &text);
3842                    }
3843                    if let Some(error) = assistant_error_text(a) {
3844                        add_error_message(chat, &error);
3845                    }
3846                    rendered_any = true;
3847                }
3848                AgentMessage::ToolResult(result) => {
3849                    // Tool results are persisted as separate message entries,
3850                    // not as part of the assistant text. Restore them as
3851                    // completed tool panels so resumed sessions show the
3852                    // command output as well as the user's prompts.
3853                    let comp = Arc::new(ToolExecutionComponent::new(
3854                        &result.tool_name,
3855                        "",
3856                    ));
3857                    comp.set_result(&tool_result_message_text(result), result.is_error);
3858                    chat.add_child(comp);
3859                    chat.add_child(Arc::new(Spacer::new(1)));
3860                    rendered_any = true;
3861                }
3862                AgentMessage::Custom(custom) => {
3863                    if let Some(session) = &extension_session {
3864                        if let Some(component) = extension_message_component(
3865                            session,
3866                            &custom.role,
3867                            &serde_json::json!({
3868                                "customType": custom.role,
3869                                "content": custom.content,
3870                                "details": custom.data,
3871                            }),
3872                            transformer.clone(),
3873                        ) {
3874                            chat.add_child(component);
3875                            chat.add_child(Arc::new(Spacer::new(1)));
3876                            rendered_any = true;
3877                            continue;
3878                        }
3879                    }
3880                    add_note_message(chat, &custom_message_fallback(&custom));
3881                    rendered_any = true;
3882                }
3883            },
3884            Entry::Compaction(compaction) => {
3885                add_note_message(
3886                    chat,
3887                    &format!(
3888                        "Compacted {} tokens: {}",
3889                        compaction.tokens_before, compaction.summary
3890                    ),
3891                );
3892                rendered_any = true;
3893            }
3894            Entry::BranchSummary(summary) => {
3895                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3896                rendered_any = true;
3897            }
3898            Entry::Custom(custom) => {
3899                let rendered = extension_session.as_ref().and_then(|session| {
3900                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
3901                });
3902                if let Some(component) = rendered {
3903                    chat.add_child(component);
3904                    chat.add_child(Arc::new(Spacer::new(1)));
3905                    rendered_any = true;
3906                } else if let Some(text) =
3907                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3908                {
3909                    add_note_message(chat, &text);
3910                    rendered_any = true;
3911                }
3912            }
3913            Entry::ModelChange(change) => {
3914                add_note_message(
3915                    chat,
3916                    &format!("Model changed to {}:{}", change.provider, change.model_id),
3917                );
3918                rendered_any = true;
3919            }
3920            Entry::ThinkingLevel(change) => {
3921                add_note_message(
3922                    chat,
3923                    &format!("Thinking level: {:?}", change.thinking_level),
3924                );
3925                rendered_any = true;
3926            }
3927            Entry::ActiveTools(change) => {
3928                add_note_message(
3929                    chat,
3930                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
3931                );
3932                rendered_any = true;
3933            }
3934        }
3935    }
3936    if rendered_any {
3937        // No trailing spacer here — each entry already adds its own trailing
3938        // Spacer(1), so an extra would double the bottom gap.
3939    }
3940}
3941
3942fn invoke_extension_renderer(
3943    session: &crate::session::ExtensionSessionCell,
3944    kind: rpi_extensions::RegisteredRendererKind,
3945    payload: &serde_json::Value,
3946) -> Option<serde_json::Value> {
3947    let snapshot = session.lock().ok()?.snapshot_arc()?;
3948    let input = serde_json::to_string(payload).ok()?;
3949    for renderer in snapshot.renderers_of(kind) {
3950        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3951            let mut out = rpi_plugin_sdk::StbString::empty();
3952            let rc = (renderer.render_fn)(
3953                rpi_plugin_sdk::StbStringRef::from_str(&input),
3954                &mut out as *mut rpi_plugin_sdk::StbString,
3955                renderer.user_data,
3956            );
3957            let text = if rc == 0 {
3958                Some(out.to_string_lossy())
3959            } else {
3960                None
3961            };
3962            out.free_with(Some(renderer.plugin_free_string));
3963            text
3964        }))
3965        .ok()
3966        .flatten();
3967        let Some(text) = outcome else { continue };
3968        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3969            return Some(value);
3970        }
3971    }
3972    None
3973}
3974
3975fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3976    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3977        let text = lines
3978            .iter()
3979            .filter_map(|line| line.as_str())
3980            .collect::<Vec<_>>()
3981            .join("\n");
3982        return Some(Arc::new(Text::new(text, 0, 0)));
3983    }
3984    let text = value.get("text").and_then(|v| v.as_str())?;
3985    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3986        let component = Arc::new(AssistantMessageComponent::new(
3987            AssistantMessageOptions::default(),
3988        ));
3989        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3990        Some(component)
3991    } else {
3992        Some(Arc::new(Text::new(text, 0, 0)))
3993    }
3994}
3995
3996fn extension_message_component(
3997    session: &crate::session::ExtensionSessionCell,
3998    custom_type: &str,
3999    payload: &serde_json::Value,
4000    transformer: Option<MarkdownTransformer>,
4001) -> Option<Arc<dyn rpi_tui::Component>> {
4002    let value = invoke_extension_renderer(
4003        session,
4004        rpi_extensions::RegisteredRendererKind::Message,
4005        payload,
4006    )?;
4007    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4008        let text = value.get("text").and_then(|v| v.as_str())?;
4009        let component = Arc::new(AssistantMessageComponent::new(
4010            AssistantMessageOptions::default(),
4011        ));
4012        if let Some(transformer) = transformer {
4013            component.set_markdown_transformer(Some(transformer));
4014        }
4015        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4016        return Some(component);
4017    }
4018    extension_text_component(&value)
4019        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4020}
4021
4022/// Render usage from a completed assistant message through the registered
4023/// message renderers. Hosts without a token-usage renderer return `None`.
4024fn extension_usage_text(
4025    session: Option<&crate::session::ExtensionSessionCell>,
4026    usage: &rpi_ai::types::Usage,
4027) -> Option<String> {
4028    let session = session?;
4029    let payload = serde_json::json!({
4030        "customType": "token-usage",
4031        "usage": usage,
4032    });
4033    let value = invoke_extension_renderer(
4034        session,
4035        rpi_extensions::RegisteredRendererKind::Message,
4036        &payload,
4037    )?;
4038    value
4039        .get("text")
4040        .and_then(|value| value.as_str())
4041        .filter(|text| !text.trim().is_empty())
4042        .map(ToOwned::to_owned)
4043}
4044
4045fn extension_entry_component(
4046    session: &crate::session::ExtensionSessionCell,
4047    custom_type: &str,
4048    data: Option<serde_json::Value>,
4049) -> Option<Arc<dyn rpi_tui::Component>> {
4050    let payload = serde_json::json!({
4051        "customType": custom_type,
4052        "data": data,
4053    });
4054    let value = invoke_extension_renderer(
4055        session,
4056        rpi_extensions::RegisteredRendererKind::Entry,
4057        &payload,
4058    )?;
4059    extension_text_component(&value)
4060}
4061
4062/// Project an assistant message's content into the provider-free
4063/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4064/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4065/// are rendered by their own components in the transcript.
4066/// Whether startup intentionally opened a session that already has history.
4067fn launch_restores_history(args: &Args) -> bool {
4068    args.continue_session
4069        || args.resume
4070        || args.session.is_some()
4071        || args.session_id.is_some()
4072        || args.fork.is_some()
4073}
4074
4075fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4076    msg.content
4077        .iter()
4078        .filter_map(|c| match c {
4079            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4080            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4081            Content::Image(image) => base64::engine::general_purpose::STANDARD
4082                .decode(&image.data)
4083                .ok()
4084                .filter(|data| !data.is_empty())
4085                .map(AssistantBlock::Image),
4086            _ => None,
4087        })
4088        .collect()
4089}
4090
4091fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4092    let content = custom
4093        .content
4094        .iter()
4095        .filter_map(|item| match item {
4096            Content::Text(text) => Some(text.text.as_str()),
4097            _ => None,
4098        })
4099        .collect::<Vec<_>>()
4100        .join("\n");
4101    if content.is_empty() {
4102        format!("{}: {}", custom.role, custom.data)
4103    } else {
4104        format!("{}: {}", custom.role, content)
4105    }
4106}
4107
4108/// The name displayed for a model id (last path segment / after the final
4109/// `:`), to keep the footer compact.
4110fn short_model_name(id: &str) -> String {
4111    id.rsplit([':', '/'])
4112        .next()
4113        .filter(|s| !s.is_empty())
4114        .unwrap_or(id)
4115        .to_string()
4116}
4117
4118// ===========================================================================
4119// Streaming run status
4120// ===========================================================================
4121
4122/// The live status of the agent run, fed to the footer + status slot.
4123#[derive(Clone, Copy, PartialEq, Eq)]
4124enum RunStatus {
4125    Idle,
4126    Working,
4127    Aborting,
4128}
4129
4130/// Which selector overlay (if any) is currently swapped into the editor slot.
4131#[derive(Clone, Copy, PartialEq, Eq)]
4132enum SelectorKind {
4133    /// `/model` — available models (live switch via `lane.set_model`).
4134    Model,
4135    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4136    Thinking,
4137    /// `/tools` — toggle builtin tools on/off.
4138    Tools,
4139    /// `/images` — toggle inline image rendering.
4140    Images,
4141    /// `/session` — browse and switch saved JSONL sessions.
4142    Session,
4143    /// `/theme` — dark / light / monochrome presets applied live.
4144    Theme,
4145    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
4146    ScopedModels,
4147    /// `/settings` — interactive settings menu (and its sub-selectors).
4148    Settings,
4149    /// `/tree` — navigate to an existing entry in the current session.
4150    Tree,
4151    /// Extension-provided selector; uses the same keyboard contract.
4152    Extension,
4153}
4154
4155/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4156/// and the render-tick task.
4157struct TuiState {
4158    /// The in-flight streaming assistant message (cleared on finalize).
4159    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4160    /// Tool-execution components keyed by `tool_call_id`.
4161    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4162    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4163    /// generic tool map so bash output streams into a `BashExecutionComponent`
4164    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4165    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4166    /// Whether package/custom themes may be selected in this session.
4167    themes_enabled: bool,
4168    /// Persisted display preference toggled by Ctrl+T.
4169    hide_thinking: std::sync::Mutex<bool>,
4170    /// Global tool-output expansion preference toggled by Ctrl+O.
4171    tool_outputs_expanded: std::sync::Mutex<bool>,
4172    /// Whether the native-style terminal progress indicator is enabled.
4173    show_terminal_progress: bool,
4174    /// Run status for the status indicator + interrupt routing.
4175    status: std::sync::Mutex<RunStatus>,
4176    /// Cancellation signal for the short phase that starts the persistent JS
4177    /// host and runs `before_agent_start`. The key thread can trigger this
4178    /// directly while the async message loop is awaiting the blocking worker.
4179    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4180    /// The footer, updated live by the drain task.
4181    footer: Arc<FooterComponent>,
4182    /// The status-container (status slot in the dock) — cleared/filled with a
4183    /// loader while a run is active.
4184    status_container: Arc<Container>,
4185    /// The chat transcript container.
4186    chat_container: Arc<Container>,
4187    /// The active loader shown while `Working`.
4188    loader: Arc<Loader>,
4189    /// The last finalized assistant text (for `/copy`). Updated by the drain
4190    /// task on `MessageEnd` / `AgentEnd`.
4191    last_assistant_text: std::sync::Mutex<String>,
4192    /// The active selector overlay, swapped into the editor slot. `Some` while
4193    /// a selector is open; the key loop routes to it first and restores the
4194    /// editor on done/cancel.
4195    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
4196    /// Extension-provided editor currently occupying the input slot.
4197    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4198    /// Single-line input currently occupying the input slot for an extension.
4199    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4200    /// Callback used to resolve an extension dialog with a cancellation action.
4201    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4202    /// The autocomplete manager (slash + @file providers) consulted on every
4203    /// editor keystroke.
4204    autocomplete: AutocompleteManager,
4205    /// The container rendered above the editor holding the live autocomplete
4206    /// suggestion list (cleared when there are no suggestions).
4207    autocomplete_container: Arc<Container>,
4208    /// Maximum number of autocomplete rows rendered above the editor.
4209    autocomplete_max_visible: usize,
4210    /// Images queued from clipboard paste and attached to the next prompt.
4211    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4212    /// The owned theme manager — `/theme` applies presets here. The global
4213    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4214    /// only way to apply a preset at runtime.
4215    theme_manager: Arc<ThemeManager>,
4216    /// The alt-screen handle, held so `set_status` can reflect run state in the
4217    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4218    /// that never call `set_status` with a title.
4219    tui: Option<Arc<TuiAltScreen>>,
4220    /// The model id currently shown in the footer + used as the Ctrl+M
4221    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
4222    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4223    current_model_id: std::sync::Mutex<String>,
4224    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4225    /// even though image wiring is minimal this pass — the flag is consulted
4226    /// where images would be shown and echoed back by `/images`.
4227    show_images: std::sync::Mutex<bool>,
4228    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4229    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4230    history: std::sync::Mutex<Vec<String>>,
4231    /// Browse index while recalling history: -1 = not browsing, 0 = most
4232    /// recent, 1 = older, … Reset to -1 on every submit.
4233    history_index: std::sync::Mutex<isize>,
4234    /// The editor text captured when entering browse mode, restored when the
4235    /// user navigates back past the newest entry (TS `historyDraft`).
4236    history_draft: std::sync::Mutex<Option<String>>,
4237    /// The previous turn's input token count, used by the cache-miss notice:
4238    /// a large input that reads nothing from cache after an established prefix
4239    /// means the prefix was re-billed (simplified `maybeShowCacheMissNotice`).
4240    last_input_tokens: std::sync::Mutex<i64>,
4241    /// The in-progress scoped-models selection while the `/scoped-models`
4242    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4243    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4244    /// B5e: the live assistant-markdown transformer, built from the current
4245    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4246    /// when no markdown transformers are registered (identity render path).
4247    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4248    /// closure no-ops once its snapshot's `active` flag flips false) and
4249    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4250    /// transform takes effect on the visible streaming message immediately.
4251    /// New assistant components pick up whatever closure is current at
4252    /// construction time via [`install_markdown_transformer`].
4253    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4254    /// Live extension registry used by message/entry renderer dispatch.
4255    extension_session: crate::session::ExtensionSessionCell,
4256}
4257
4258/// How many submitted messages are kept for ↑ recall (mirrors the TS
4259/// editor's 100-entry cap).
4260const HISTORY_LIMIT: usize = 100;
4261
4262/// A turn with at least this many input tokens is worth a cache-miss notice
4263/// when nothing was read from cache (matches the TS 20k threshold).
4264const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
4265
4266/// Keep a few rows of overlap so page scrolling preserves visual context,
4267/// matching the upstream fullscreen viewport behavior.
4268const PAGE_SCROLL_OVERLAP: usize = 4;
4269
4270/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4271/// transcript one physical row at a time. Three lines stays precise while
4272/// avoiding the sluggish feel of the previous implementation.
4273const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4274
4275/// Parse the compact key notation used by native Pi settings (for example
4276/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4277fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4278    let mut modifiers = KeyModifiers::NONE;
4279    let mut key = None;
4280    for part in value.trim().to_ascii_lowercase().split('+') {
4281        match part {
4282            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4283            "shift" => modifiers |= KeyModifiers::SHIFT,
4284            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4285            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4286            part if !part.is_empty() => key = Some(part.to_string()),
4287            _ => {}
4288        }
4289    }
4290    let key = key?;
4291    let code = match key.as_str() {
4292        "esc" | "escape" => KeyCode::Esc,
4293        "enter" | "return" => KeyCode::Enter,
4294        "tab" => {
4295            if modifiers.contains(KeyModifiers::SHIFT) {
4296                return Some(rpi_tui::KeyCombo::new(
4297                    KeyCode::BackTab,
4298                    modifiers & !KeyModifiers::SHIFT,
4299                ));
4300            }
4301            KeyCode::Tab
4302        }
4303        "backspace" | "back" => KeyCode::Backspace,
4304        "delete" | "del" => KeyCode::Delete,
4305        "up" | "arrowup" => KeyCode::Up,
4306        "down" | "arrowdown" => KeyCode::Down,
4307        "left" | "arrowleft" => KeyCode::Left,
4308        "right" | "arrowright" => KeyCode::Right,
4309        "home" => KeyCode::Home,
4310        "end" => KeyCode::End,
4311        "pageup" | "page-up" => KeyCode::PageUp,
4312        "pagedown" | "page-down" => KeyCode::PageDown,
4313        "space" => KeyCode::Char(' '),
4314        "f1" => KeyCode::F(1),
4315        "f2" => KeyCode::F(2),
4316        "f3" => KeyCode::F(3),
4317        "f4" => KeyCode::F(4),
4318        "f5" => KeyCode::F(5),
4319        "f6" => KeyCode::F(6),
4320        "f7" => KeyCode::F(7),
4321        "f8" => KeyCode::F(8),
4322        "f9" => KeyCode::F(9),
4323        "f10" => KeyCode::F(10),
4324        "f11" => KeyCode::F(11),
4325        "f12" => KeyCode::F(12),
4326        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4327        _ => return None,
4328    };
4329    Some(rpi_tui::KeyCombo::new(code, modifiers))
4330}
4331
4332fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4333    let mut bindings = rpi_tui::Keybindings::new();
4334    let settings = crate::settings::load_settings().unwrap_or_default();
4335    let Some(overrides) = settings.keybindings else {
4336        rpi_tui::set_keybindings(bindings.clone());
4337        return Arc::new(bindings);
4338    };
4339    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4340        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4341        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4342        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4343        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4344        (
4345            "app.model.cycleForward",
4346            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4347        ),
4348        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4349        (
4350            "app.thinking.toggle",
4351            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4352        ),
4353        (
4354            "app.editor.external",
4355            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4356        ),
4357        (
4358            "app.thinking.cycle",
4359            rpi_tui::keybindings::keys::THINKING_CYCLE,
4360        ),
4361        (
4362            "app.clipboard.pasteImage",
4363            rpi_tui::keybindings::keys::PASTE_IMAGE,
4364        ),
4365    ];
4366    for (name, id) in known {
4367        let Some(value) = overrides.get(*name) else {
4368            continue;
4369        };
4370        let values: Vec<String> = match value {
4371            serde_json::Value::String(value) => vec![value.clone()],
4372            serde_json::Value::Array(values) => values
4373                .iter()
4374                .filter_map(|v| v.as_str().map(str::to_string))
4375                .collect(),
4376            serde_json::Value::Null => Vec::new(),
4377            _ => continue,
4378        };
4379        let combos: Vec<_> = values
4380            .iter()
4381            .filter_map(|value| parse_configured_key(value))
4382            .collect();
4383        if values.is_empty() || !combos.is_empty() {
4384            bindings.set(id, combos);
4385        }
4386    }
4387    rpi_tui::set_keybindings(bindings.clone());
4388    Arc::new(bindings)
4389}
4390
4391fn keybinding_matches(
4392    bindings: &rpi_tui::Keybindings,
4393    event: &crossterm::event::KeyEvent,
4394    id: rpi_tui::KeybindingId,
4395) -> bool {
4396    if bindings.matches(event, id) {
4397        return true;
4398    }
4399    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4400    // plus Shift on others. Treat both forms as the same configured action.
4401    if event.code == KeyCode::BackTab {
4402        let normalized =
4403            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4404        bindings.matches(&normalized, id)
4405    } else {
4406        false
4407    }
4408}
4409
4410fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4411    last.is_some_and(|previous| {
4412        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4413    })
4414}
4415
4416fn transcript_page_size(viewport_height: usize) -> i32 {
4417    viewport_height
4418        .saturating_sub(PAGE_SCROLL_OVERLAP)
4419        .max(1)
4420        .min(i32::MAX as usize) as i32
4421}
4422
4423fn should_dispatch_key(kind: KeyEventKind) -> bool {
4424    kind != KeyEventKind::Release
4425}
4426
4427/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4428fn format_tokens(n: i64) -> String {
4429    if n >= 1_000_000 {
4430        format!("{:.1}M", n as f64 / 1_000_000.0)
4431    } else if n >= 1_000 {
4432        format!("{:.1}K", n as f64 / 1_000.0)
4433    } else {
4434        n.to_string()
4435    }
4436}
4437
4438/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4439/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4440/// resets the browse state so a fresh prompt never resumes mid-history.
4441fn push_history(state: &Arc<TuiState>, text: &str) {
4442    let trimmed = text.trim().to_string();
4443    if trimmed.is_empty() {
4444        return;
4445    }
4446    let mut history = state.history.lock().unwrap();
4447    if history.first() == Some(&trimmed) {
4448        return;
4449    }
4450    history.insert(0, trimmed);
4451    history.truncate(HISTORY_LIMIT);
4452    *state.history_index.lock().unwrap() = -1;
4453    *state.history_draft.lock().unwrap() = None;
4454}
4455
4456/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
4457/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
4458/// current editor text as the draft; navigating back past the newest entry
4459/// restores that draft.
4460fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4461    let history = state.history.lock().unwrap();
4462    if history.is_empty() {
4463        return;
4464    }
4465    let mut index = state.history_index.lock().unwrap();
4466    let new_index = *index - direction as isize;
4467    if new_index < -1 || new_index >= history.len() as isize {
4468        return;
4469    }
4470    if *index == -1 && new_index >= 0 {
4471        // Entering browse mode: stash the current input.
4472        *state.history_draft.lock().unwrap() = Some(editor.get_text());
4473    }
4474    *index = new_index;
4475    if new_index == -1 {
4476        // Exited browse mode: restore the draft (or clear if there was none).
4477        let draft = state.history_draft.lock().unwrap().take();
4478        match draft {
4479            Some(d) => {
4480                let len = d.len();
4481                editor.set_text(&d);
4482                editor.set_cursor(0, len);
4483            }
4484            None => editor.set_text(""),
4485        }
4486    } else {
4487        let text = history[new_index as usize].clone();
4488        let len = text.len();
4489        editor.set_text(&text);
4490        editor.set_cursor(0, len);
4491    }
4492}
4493
4494impl TuiState {
4495    fn begin_js_preparation(&self) -> CancellationToken {
4496        let cancellation = CancellationToken::new();
4497        if let Some(previous) = self
4498            .js_preparation_cancel
4499            .lock()
4500            .unwrap()
4501            .replace(cancellation.clone())
4502        {
4503            previous.cancel();
4504        }
4505        cancellation
4506    }
4507
4508    fn finish_js_preparation(&self) {
4509        self.js_preparation_cancel.lock().unwrap().take();
4510    }
4511
4512    fn cancel_js_preparation(&self) -> bool {
4513        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4514        if let Some(cancellation) = cancellation {
4515            cancellation.cancel();
4516            true
4517        } else {
4518            false
4519        }
4520    }
4521
4522    fn set_status(&self, status: RunStatus) {
4523        *self.status.lock().unwrap() = status;
4524        self.apply_status(status);
4525    }
4526
4527    /// Atomically reserve the single interactive run slot. The editor callback
4528    /// runs on a different thread from the async prompt loop, so checking and
4529    /// setting in separate steps would allow rapid Enter presses to queue more
4530    /// than one operation.
4531    fn try_start_working(&self) -> bool {
4532        let mut status = self.status.lock().unwrap();
4533        if *status != RunStatus::Idle {
4534            return false;
4535        }
4536        *status = RunStatus::Working;
4537        drop(status);
4538        self.apply_status(RunStatus::Working);
4539        true
4540    }
4541
4542    fn apply_status(&self, status: RunStatus) {
4543        match status {
4544            RunStatus::Working => {
4545                // The status dock already shows the active loader. Keep the
4546                // footer focused on the model and shortcuts instead of
4547                // repeating a second `Working…` at the bottom of the TUI.
4548                self.footer.set_status("");
4549                // Reflect the in-flight turn in the terminal window/tab title
4550                // (OSC 2). No-op when `tui` is absent (unit tests).
4551                if let Some(tui) = &self.tui {
4552                    tui.set_title("rpi — working");
4553                }
4554                self.status_container.clear();
4555                if self.show_terminal_progress {
4556                    self.loader.start();
4557                    self.status_container.add_child(self.loader.clone());
4558                }
4559            }
4560            RunStatus::Aborting => {
4561                self.footer.set_status("Aborting…");
4562                // Do not leave a frozen "Working" spinner on screen after the
4563                // render tick intentionally stops advancing in this state.
4564                self.loader.stop();
4565                self.status_container.clear();
4566            }
4567            RunStatus::Idle => {
4568                self.footer.set_status("");
4569                if let Some(tui) = &self.tui {
4570                    tui.set_title("rpi");
4571                }
4572                self.loader.stop();
4573                self.status_container.clear();
4574            }
4575        }
4576    }
4577
4578    /// The bash panel has its own `Running...` spinner. Keep the global
4579    /// `Working...` loader out of the status slot while any bash tool is active
4580    /// so the same operation is not presented as two simultaneous loaders.
4581    fn sync_working_loader_with_bash(&self) {
4582        if *self.status.lock().unwrap() != RunStatus::Working {
4583            return;
4584        }
4585
4586        self.status_container.clear();
4587        if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4588            self.status_container.add_child(self.loader.clone());
4589        }
4590    }
4591
4592    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4593        *self.status.lock().unwrap() = RunStatus::Working;
4594        self.footer.set_status("");
4595        if let Some(tui) = &self.tui {
4596            tui.set_title("rpi — retrying");
4597        }
4598        self.loader.stop();
4599        self.status_container.clear();
4600        self.status_container
4601            .add_child(Arc::new(StatusIndicator::retry(
4602                attempt,
4603                max_retries,
4604                std::time::Duration::from_millis(delay_ms),
4605            )));
4606    }
4607
4608    /// Whether a selector overlay is currently open (routes keys to it first).
4609    fn selector_open(&self) -> bool {
4610        self.active_selector.lock().unwrap().is_some()
4611    }
4612
4613    fn extension_editor_open(&self) -> bool {
4614        self.active_extension_editor.lock().unwrap().is_some()
4615    }
4616
4617    fn extension_input_open(&self) -> bool {
4618        self.active_extension_input.lock().unwrap().is_some()
4619    }
4620
4621    fn extension_dialog_open(&self) -> bool {
4622        self.extension_editor_open() || self.extension_input_open()
4623    }
4624
4625    fn set_hide_thinking(&self, hide: bool) {
4626        *self.hide_thinking.lock().unwrap() = hide;
4627        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4628            comp.set_hide_thinking(hide);
4629        }
4630    }
4631
4632    fn hide_thinking(&self) -> bool {
4633        *self.hide_thinking.lock().unwrap()
4634    }
4635
4636    fn toggle_thinking(&self) -> bool {
4637        let next = !self.hide_thinking();
4638        self.set_hide_thinking(next);
4639        next
4640    }
4641
4642    fn toggle_tool_outputs(&self) -> bool {
4643        let next = !*self.tool_outputs_expanded.lock().unwrap();
4644        *self.tool_outputs_expanded.lock().unwrap() = next;
4645        for comp in self.tool_components.lock().unwrap().values() {
4646            comp.set_expanded(next);
4647        }
4648        for comp in self.bash_components.lock().unwrap().values() {
4649            comp.set_expanded(next);
4650        }
4651        next
4652    }
4653
4654    /// The model id currently tracked as active (footer + Ctrl+M anchor).
4655    fn current_model_id(&self) -> String {
4656        self.current_model_id.lock().unwrap().clone()
4657    }
4658
4659    /// Update the tracked model id + footer label after a switch (live or
4660    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
4661    fn set_current_model(&self, model: &rpi_ai::Model) {
4662        *self.current_model_id.lock().unwrap() = model.id.clone();
4663        self.footer.set_model(&short_model_name(&model.id));
4664    }
4665
4666    /// B5e: read a clone of the current assistant-markdown transformer (if any).
4667    /// New assistant components call this at construction so they render with
4668    /// whatever plugin `register_markdown_transformer` handlers are live.
4669    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4670        self.markdown_transformer.lock().unwrap().clone()
4671    }
4672
4673    /// B5e: swap the live transformer. Used at startup (install the first
4674    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
4675    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
4676    /// transform should take effect on the VISIBLE streaming message too, so
4677    /// this re-installs on the in-flight `current_assistant` component — its
4678    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
4679    /// `None` clears the transform (identity), e.g. a reload that unregisters
4680    /// every markdown transformer.
4681    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4682        *self.markdown_transformer.lock().unwrap() = transformer.clone();
4683        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4684            comp.set_markdown_transformer(transformer);
4685        }
4686    }
4687
4688    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4689        self.pending_images.lock().unwrap().push(image);
4690    }
4691
4692    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4693        std::mem::take(&mut *self.pending_images.lock().unwrap())
4694    }
4695}
4696
4697// ===========================================================================
4698// interactive_tui — the entry point
4699// ===========================================================================
4700
4701#[derive(Debug, PartialEq)]
4702struct TuiStartupSettings {
4703    editor_padding_x: usize,
4704    autocomplete_max_visible: usize,
4705    hide_thinking: bool,
4706    quiet_startup: bool,
4707    show_terminal_progress: bool,
4708}
4709
4710fn preferred_project_setting<T>(
4711    project_settings: &[crate::settings::Settings],
4712    field: impl Fn(&crate::settings::Settings) -> Option<T>,
4713) -> Option<T> {
4714    project_settings.iter().find_map(field)
4715}
4716
4717fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4718    verbose || !quiet_startup
4719}
4720
4721fn git_only_update_resources(
4722    mut resources: crate::packages::PackageResources,
4723) -> crate::packages::PackageResources {
4724    resources
4725        .packages
4726        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4727    resources
4728}
4729
4730/// Resolve TUI-only settings with native Pi's project-over-global precedence.
4731/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
4732/// wins even when the value is `false` or zero.
4733fn resolve_tui_startup_settings(
4734    global: &crate::settings::Settings,
4735    project_settings: &[crate::settings::Settings],
4736    project_trusted: bool,
4737) -> TuiStartupSettings {
4738    let project_settings = if project_trusted {
4739        project_settings
4740    } else {
4741        &[]
4742    };
4743    let editor_padding_x =
4744        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4745            .or(global.editor_padding_x)
4746            .unwrap_or(1)
4747            .min(16);
4748    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4749        settings.autocomplete_max_visible
4750    })
4751    .or(global.autocomplete_max_visible)
4752    .unwrap_or(5)
4753    .clamp(1, 20);
4754    let hide_thinking =
4755        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4756            .or(global.hide_thinking_block)
4757            .unwrap_or(false);
4758    let quiet_startup =
4759        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4760            .or(global.quiet_startup)
4761            .unwrap_or(false);
4762    let show_terminal_progress =
4763        preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4764            .or(global.show_terminal_progress)
4765            .unwrap_or(true);
4766
4767    TuiStartupSettings {
4768        editor_padding_x,
4769        autocomplete_max_visible,
4770        hide_thinking,
4771        quiet_startup,
4772        show_terminal_progress,
4773    }
4774}
4775
4776/// TUI-based interactive mode.
4777///
4778/// `event_rx` carries the live `AgentEvent` stream (installed by
4779/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
4780/// fn), it falls back to a blocking, await-final-text path.
4781///
4782/// `model_catalog` is the read-only catalog the `/model` selector displays.
4783///
4784/// This implementation mirrors the TypeScript `InteractiveMode` class:
4785/// build the layout root once, drain `AgentEvent`s into UI mutations that
4786/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
4787/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
4788/// autocomplete are layered on via the editor-container swap pattern.
4789pub async fn interactive_tui(
4790    harness: &AgentHarness,
4791    event_rx: Option<broadcast::Receiver<AgentEvent>>,
4792    args: &Args,
4793    model_catalog: Vec<rpi_ai::Model>,
4794    initial: Option<String>,
4795    extra_messages: &[String],
4796    initial_images: Vec<rpi_ai::types::ImageContent>,
4797    theme: Option<&str>,
4798    no_themes: bool,
4799    reload_context: &crate::session::ReloadContext,
4800) -> i32 {
4801    let lane: Arc<dyn AgentLane> = harness.lane("main");
4802    // Reuse the startup snapshot captured before provider/session setup. An
4803    // extension may mutate the process cwd while loading; that must not change
4804    // which project settings or package update roots this TUI observes.
4805    let cwd = reload_context.cwd.clone();
4806    let saved_settings = crate::settings::load_settings().unwrap_or_default();
4807    let project_trusted = reload_context.project_trusted;
4808    let project_settings = if project_trusted {
4809        crate::settings::load_project_settings(&cwd)
4810    } else {
4811        Vec::new()
4812    };
4813    let tui_settings =
4814        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4815    let editor_padding_x = tui_settings.editor_padding_x;
4816    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4817    let hide_thinking = tui_settings.hide_thinking;
4818    let show_terminal_progress = tui_settings.show_terminal_progress;
4819    let quiet_startup = tui_settings.quiet_startup;
4820    let update_checks_enabled =
4821        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4822
4823    // Resolve the active model once, up front. The full id feeds the TuiState
4824    // tracking field + the selectors/key loop (which run on a blocking thread
4825    // and can't await `lane.get_model()`); the short name feeds the footer.
4826    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4827    let model_name = short_model_name(&lane_model_id);
4828
4829    // Snapshot startup capabilities for the welcome screen. Both accessors
4830    // return defensive clones, so rendering this summary does not retain a
4831    // harness lock or trigger a second resource scan.
4832    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4833    // AgentHarness uses an empty active-name list as the default "all tools"
4834    // state. Do not expose that implementation sentinel as `Tools (0) none`
4835    // in the welcome banner (it is especially visible on the first prompt).
4836    if active_tool_names.is_empty() {
4837        active_tool_names = harness
4838            .get_tools()
4839            .await
4840            .unwrap_or_default()
4841            .into_iter()
4842            .map(|tool| tool.tool.schema().name.clone())
4843            .collect();
4844    }
4845    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4846    let skill_names: Vec<String> = resources_snapshot
4847        .skills
4848        .as_deref()
4849        .unwrap_or(&[])
4850        .iter()
4851        .map(|skill| skill.name.clone())
4852        .collect();
4853
4854    // Resolve package resources for @file autocomplete + session discovery.
4855    let package_resources = reload_context.package_resources.clone();
4856
4857    // Channel between the key/callback threads and the main async loop.
4858    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4859
4860    // Apply the saved theme before constructing transcript components. Some
4861    // components keep styled text, so doing this after the welcome banner left
4862    // the first screen in the dark palette until it was rebuilt.
4863    let theme_manager = Arc::new(ThemeManager::new());
4864    if let Some(preset) = match theme {
4865        Some("light") => Some(ThemePreset::Light),
4866        Some("monochrome") => Some(ThemePreset::Monochrome),
4867        Some("dark") => Some(ThemePreset::Dark),
4868        _ => None,
4869    } {
4870        apply_theme_preset(preset);
4871        theme_manager.apply_preset(preset);
4872    } else if !no_themes {
4873        if let Some(name) = theme {
4874            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4875                Ok(custom) => {
4876                    rpi_tui::global_theme_manager().set(custom.clone());
4877                    theme_manager.set(custom);
4878                }
4879                Err(error) => {
4880                    eprintln!("warning: could not load package theme `{name}`: {error}");
4881                }
4882            }
4883        }
4884    }
4885
4886    // ---- TUI + containers ----
4887    let terminal = Box::new(ProcessTerminal::new());
4888    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4889    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4890    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
4891    // session as interactive so a detached/headless host fails loudly instead
4892    // of returning a fabricated answer. The mailbox is session-scoped (held in
4893    // `ReloadContext`) so in-flight prompts survive a `/reload`.
4894    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
4895    ask_user_bridge.attach();
4896    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4897
4898    if let Some(js) = &reload_context.js_extension_session {
4899        if let Err(error) = js.install_ui_runtime(tui.clone()) {
4900            eprintln!("warning: could not enable JS custom UI bridge: {error}");
4901        } else if let Some(js_active) = js.active_tools() {
4902            // Apply the discovery-time JS subset while preserving Rust
4903            // built-ins already active in the harness lane. The real TUI
4904            // lifecycle reconciliation runs after the key worker starts below.
4905            let js_names = js.tool_names();
4906            let mut active = lane.get_active_tools().await.unwrap_or_default();
4907            active.retain(|name| {
4908                crate::session::tool_name_allowed(name, args)
4909                    && !js_names.iter().any(|js_name| js_name == name)
4910            });
4911            active.extend(js_active.into_iter().filter(|name| {
4912                js_names.iter().any(|js_name| js_name == name)
4913                    && crate::session::tool_name_allowed(name, args)
4914            }));
4915            active = crate::session::filter_active_tool_names(active, args);
4916            let _ = lane.set_active_tools(active).await;
4917            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4918        }
4919    }
4920
4921    let chat_container = Arc::new(Container::new());
4922    if let Some(js) = &reload_context.js_extension_session {
4923        // Tool contexts do not have a command-result envelope. Route
4924        // `ctx.ui.notify()` through the live transcript so notifications from
4925        // tools such as ask_user_question are visible immediately.
4926        let chat_notify = chat_container.clone();
4927        let tui_notify = tui.clone();
4928        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4929            if action != "ui.notify" {
4930                return Err(format!("unsupported capability: {action}"));
4931            }
4932            let message = args
4933                .get("message")
4934                .and_then(serde_json::Value::as_str)
4935                .unwrap_or_default();
4936            if !message.is_empty() {
4937                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4938                    add_error_message(&chat_notify, message);
4939                } else {
4940                    add_note_message(&chat_notify, message);
4941                }
4942                tui_notify.request_render(false);
4943            }
4944            Ok(serde_json::json!(true))
4945        })) {
4946            if args.verbose {
4947                eprintln!("warning: could not enable JS notification bridge: {error}");
4948            }
4949        }
4950    }
4951    if should_show_startup_listing(args.verbose, quiet_startup) {
4952        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
4953    }
4954
4955    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
4956    // banner + the earendil announcement once, then write the sentinel. The TS
4957    // original is a multi-step dialog (theme picker + analytics opt-in); this
4958    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
4959    // analytics deferred — no telemetry wiring). See `extras.rs`.
4960    crate::extras::maybe_first_time_setup(&chat_container);
4961
4962    // A --continue/--resume/--session launch opens on an existing JSONL
4963    // session — render its prior user/assistant transcript so the user sees
4964    // where they left off (tool executions are skipped: their live display
4965    // belongs to the current run, and replaying old results would be noise).
4966    let initial_transformer = build_markdown_transformer(
4967        reload_context
4968            .extension_session
4969            .lock()
4970            .unwrap()
4971            .snapshot_arc(),
4972    );
4973    // A normal launch creates a fresh session and must not replay records from
4974    // another/project harness. Only explicit restore/fork modes render prior
4975    // conversation history. This fixes stale prompts appearing every startup.
4976    if launch_restores_history(args) {
4977        render_session_history(
4978            &harness,
4979            &chat_container,
4980            initial_transformer.clone(),
4981            Some(reload_context.extension_session.clone()),
4982        )
4983        .await;
4984    }
4985
4986    // `document_container` wraps the welcome header + chat so the scrollview
4987    // follows the whole transcript (mirrors TS `documentContainer`).
4988    let document_container = Arc::new(Container::new());
4989    document_container.add_child(chat_container.clone());
4990
4991    let scroll_view = Arc::new(ScrollView::new(
4992        document_container.clone(),
4993        ScrollViewOptions {
4994            follow: FollowMode::End,
4995            primary: true,
4996            overscroll: OverscrollMode::Chain,
4997            // Native pi keeps transcript chrome out of the way. Our Auto mode
4998            // has no hide timer yet and therefore became effectively permanent
4999            // after the first wheel event, unlike the upstream experience.
5000            scrollbar: ScrollbarMode::Hidden,
5001            ..Default::default()
5002        },
5003    ));
5004
5005    // ---- Editor ----
5006    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
5007    // editor renders full-width `─` top/bottom borders with padding-only lines
5008    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
5009    let keybindings = configured_keybindings();
5010    let editor = Arc::new(Editor::new(
5011        EditorOptions {
5012            padding_x: editor_padding_x,
5013            autocomplete_max_visible,
5014            ..Default::default()
5015        },
5016        EditorStyle::default(),
5017        keybindings.clone(),
5018    ));
5019
5020    // ---- Footer + status ----
5021    let footer = Arc::new(FooterComponent::new());
5022    footer.set_model(&model_name);
5023    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");
5024
5025    let status_container = Arc::new(Container::new());
5026    let loader = Arc::new(Loader::with_text("Working…"));
5027
5028    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5029    // Prompt templates discovered at session build (Part A2) are surfaced as
5030    // `/`-prefixed entries alongside the built-in slash commands: typing
5031    // `/<name>` in the editor expands the template (mirrors pi
5032    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5033    // the template's frontmatter description (or a fallback) so the autocomplete
5034    // popover shows what each template does.
5035    //
5036    // We snapshot the full resources once (skills + prompt-templates): the
5037    // autocomplete builder consumes the templates, and the `/context` command
5038    // (fired from the blocking submit handler, which can't `.await`) reads the
5039    // snapshot to render the discovered-resources panel without touching the
5040    // harness async accessor.
5041    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5042        .prompt_templates
5043        .clone()
5044        .unwrap_or_default()
5045        .iter()
5046        .map(|t| SlashCommandEntry {
5047            name: format!("/{}", t.name),
5048            description: t
5049                .description
5050                .clone()
5051                .unwrap_or_else(|| "Expand prompt template".to_string()),
5052        })
5053        .collect();
5054    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5055        Arc::new(resources_snapshot);
5056    // Build the built-in command registry once — the single source of truth for
5057    // both dispatch and the built-in autocomplete entries. The discovered
5058    // prompt-template commands are merged into the autocomplete list separately
5059    // (they dispatch via template expansion, not the registry); built-ins come
5060    // first so they win on a fuzzy tie.
5061    let mut command_registry = build_builtin_registry();
5062    register_extension_commands(
5063        &mut command_registry,
5064        reload_context.extension_session.clone(),
5065    );
5066    register_js_extension_commands(
5067        &mut command_registry,
5068        reload_context.js_extension_session.clone(),
5069    );
5070    let registry = Arc::new(command_registry);
5071    let mut all_slash_commands = registry.visible_entries();
5072    all_slash_commands.extend(template_slash_commands);
5073    let autocomplete = AutocompleteManager::new();
5074    {
5075        let mut combined = CombinedAutocompleteProvider::new();
5076        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5077            all_slash_commands,
5078        )));
5079        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5080            cwd.clone(),
5081        )));
5082        autocomplete.set_provider(Arc::new(combined));
5083    }
5084    let autocomplete_container = Arc::new(Container::new());
5085
5086    let state = Arc::new(TuiState {
5087        current_assistant: std::sync::Mutex::new(None),
5088        tool_components: std::sync::Mutex::new(HashMap::new()),
5089        bash_components: std::sync::Mutex::new(HashMap::new()),
5090        themes_enabled: !no_themes,
5091        hide_thinking: std::sync::Mutex::new(hide_thinking),
5092        tool_outputs_expanded: std::sync::Mutex::new(false),
5093        show_terminal_progress,
5094        status: std::sync::Mutex::new(RunStatus::Idle),
5095        js_preparation_cancel: std::sync::Mutex::new(None),
5096        footer: footer.clone(),
5097        status_container: status_container.clone(),
5098        chat_container: chat_container.clone(),
5099        loader: loader.clone(),
5100        last_assistant_text: std::sync::Mutex::new(String::new()),
5101        active_selector: std::sync::Mutex::new(None),
5102        active_extension_editor: std::sync::Mutex::new(None),
5103        active_extension_input: std::sync::Mutex::new(None),
5104        active_extension_cancel: std::sync::Mutex::new(None),
5105        autocomplete,
5106        autocomplete_container: autocomplete_container.clone(),
5107        autocomplete_max_visible,
5108        pending_images: std::sync::Mutex::new(Vec::new()),
5109        theme_manager,
5110        tui: Some(tui.clone()),
5111        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5112        show_images: std::sync::Mutex::new(true),
5113        history: std::sync::Mutex::new(Vec::new()),
5114        history_index: std::sync::Mutex::new(-1),
5115        history_draft: std::sync::Mutex::new(None),
5116        last_input_tokens: std::sync::Mutex::new(0),
5117        scoped_edit: std::sync::Mutex::new(None),
5118        markdown_transformer: std::sync::Mutex::new(initial_transformer),
5119        extension_session: reload_context.extension_session.clone(),
5120    });
5121
5122    // Capture the model catalog + cwd for the selector builders + the key loop
5123    // (the callbacks fire on blocking threads and need owned data).
5124    let model_catalog_arc = Arc::new(model_catalog.clone());
5125    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5126
5127    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5128    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5129    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5130    //
5131    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5132    // it at zero height and grows it to fill the space the dock does not need
5133    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5134    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5135    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5136    // never clip the input panel below its minimum.
5137    let editor_container = Arc::new(Container::new());
5138    editor_container.add_child(editor.clone());
5139
5140    let dock = Arc::new(VStack::from_children(vec![
5141        StackChild::Entry(StackEntry::new(status_container.clone())),
5142        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5143        StackChild::Entry(
5144            StackEntry::new(editor_container.clone())
5145                .shrink(0)
5146                .min_size(3),
5147        ),
5148        StackChild::Entry(StackEntry::new(footer.clone())),
5149    ]));
5150
5151    let root = VStack::from_children(vec![
5152        StackChild::Entry(
5153            StackEntry::new(scroll_view.clone())
5154                .basis(0)
5155                .grow(1)
5156                .shrink(1)
5157                .min_size(1),
5158        ),
5159        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5160    ]);
5161
5162    tui.set_layout_root(Some(Arc::new(root)));
5163    tui.set_focus(Some(editor.clone()));
5164    editor.set_focused(true);
5165
5166    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5167    //
5168    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5169    // the old version made individually) + the registry, then routes `/`-text
5170    // through `dispatch_slash` and sends plain text directly. Each command's
5171    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5172    // chat mutation) — the handler itself stays a thin router.
5173    //
5174    // One `CommandContext` is built and cloned for both the submit handler and
5175    // the key loop (Ctrl+L routes `/model` through the same registry); all
5176    // fields are `Arc`/cheap, so the clones are free.
5177    let ctx = CommandContext {
5178        chat: chat_container.clone(),
5179        tui: tui.clone(),
5180        tx: tx.clone(),
5181        state: state.clone(),
5182        editor: editor.clone(),
5183        editor_container: editor_container.clone(),
5184        lane: lane.clone(),
5185        model_catalog: model_catalog_arc.clone(),
5186        lane_model_id: lane_model_id.clone(),
5187        cwd: cwd.clone(),
5188        package_resources: package_resources.clone(),
5189        resources: resources_arc.clone(),
5190        reload_context: Arc::new(reload_context.clone()),
5191    };
5192
5193    if let Some(js) = &reload_context.js_extension_session {
5194        let bridge = js_dialog_bridge.clone();
5195        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5196            bridge.handle_runtime_request(action, args)
5197        })) {
5198            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5199        }
5200    }
5201
5202    let ctx_for_cb = ctx.clone();
5203    let registry_for_cb = registry.clone();
5204    editor.on_submit(Arc::new(move |text: &str| {
5205        let text = text.trim();
5206        if text.is_empty() {
5207            return;
5208        }
5209
5210        if text.starts_with('/') {
5211            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
5212            return;
5213        }
5214
5215        let run_status = *ctx_for_cb.state.status.lock().unwrap();
5216        if run_status != RunStatus::Idle {
5217            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5218            let lane = ctx_for_cb.lane.clone();
5219            let chat = ctx_for_cb.chat.clone();
5220            let tui = ctx_for_cb.tui.clone();
5221            tokio::spawn(async move {
5222                // Queue immediately while the agent loop is still running.
5223                // Routing this through the TUI's main channel delayed it until
5224                // `prompt_text()` returned, after the loop's drain points had
5225                // passed, so the queued message appeared to disappear.
5226                //
5227                // Native pi's `steer()` enqueues unconditionally, even after
5228                // the run ends, and the message stays in the queue for the
5229                // next run. This matches the TS design and avoids the race
5230                // between `activeRun` clearing and the status check.
5231                if let Err(error) = lane.steer(message).await {
5232                    add_error_message(&chat, &format!("Could not queue message: {error}"));
5233                    tui.render_now(false);
5234                }
5235            });
5236            // A queued prompt is still the user's message. Render it through
5237            // the same transcript path as an immediately sent prompt rather
5238            // than as a system-note row, so its bubble/markdown presentation
5239            // stays consistent while the agent is busy.
5240            add_user_message(&ctx_for_cb.chat, text);
5241            ctx_for_cb.tui.request_render(false);
5242            return;
5243        }
5244
5245        if !ctx_for_cb.state.try_start_working() {
5246            return;
5247        }
5248
5249        add_user_message(&ctx_for_cb.chat, text);
5250        // A new prompt starts a fresh interaction at the tail even when the
5251        // user had scrolled up to inspect older output.
5252        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5253            scroll.scroll_to_end();
5254        }
5255        ctx_for_cb.tui.request_render(false);
5256        // Remember the message for ↑ recall (slash commands are not part of
5257        // the replayable message history).
5258        push_history(&ctx_for_cb.state, text);
5259        if ctx_for_cb
5260            .tx
5261            .send(TuiMessage::UserInput(text.to_string()))
5262            .is_err()
5263        {
5264            ctx_for_cb.state.set_status(RunStatus::Idle);
5265        }
5266    }));
5267
5268    // Turn on the native-pi frame throttle before the first frame: from here on
5269    // `request_render(false)` parks a frame and the scheduler thread paints at
5270    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
5271    // `MessageUpdate` per delta would otherwise force a full transcript
5272    // re-render + full-viewport terminal write per token, which is what made
5273    // long output saturate the terminal (and its GPU compositor). Immediate
5274    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
5275    tui.start_render_scheduler();
5276    tui.start_readerless();
5277
5278    // Consume local self-update results even when network checks are disabled.
5279    // Keep package discovery separate so package managers and Git remotes
5280    // cannot delay an rpi update notice or a prior helper failure.
5281    let mut update_check_handles = Vec::new();
5282    let rpi_chat = chat_container.clone();
5283    let rpi_tui = tui.clone();
5284    update_check_handles.push(tokio::spawn(async move {
5285        let report = crate::updates::check_rpi_startup().await;
5286        if !report.is_empty() {
5287            add_update_notices(&rpi_chat, &report);
5288        }
5289        rpi_tui.request_render(false);
5290    }));
5291
5292    if update_checks_enabled {
5293        let update_args = args.clone();
5294        let update_cwd = cwd.clone();
5295        let update_project_trusted = project_trusted;
5296        let chat = chat_container.clone();
5297        let tui = tui.clone();
5298        update_check_handles.push(tokio::spawn(async move {
5299            let resources = crate::session::package_resources_for_update_check(
5300                &update_args,
5301                &update_cwd,
5302                update_project_trusted,
5303            );
5304            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
5305            {
5306                Ok(npm_command) => {
5307                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
5308                        Some(&resources),
5309                        &npm_command,
5310                        update_project_trusted.then_some(update_cwd.as_path()),
5311                    )
5312                    .await
5313                }
5314                Err(error) => {
5315                    add_error_message(
5316                        &chat,
5317                        &format!("npm package update checks disabled: {error}"),
5318                    );
5319                    let git_resources = git_only_update_resources(resources);
5320                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
5321                }
5322            };
5323            if !report.is_empty() {
5324                add_update_notices(&chat, &report);
5325            }
5326            tui.request_render(false);
5327        }));
5328    }
5329
5330    // ---- Streaming drain task ----
5331    let drain_handle = if let Some(rx) = event_rx {
5332        let tui_drain = tui.clone();
5333        let state_drain = state.clone();
5334        let chat_drain = chat_container.clone();
5335        Some(tokio::spawn(async move {
5336            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
5337        }))
5338    } else {
5339        None
5340    };
5341
5342    // ---- B5d: plugin→TUI reload bridge ----
5343    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
5344    // (its cdylib would be unmapped while the call frame is still on the stack).
5345    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
5346    // (an `UnboundedSender<()>`); this task drains those signals and forwards
5347    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
5348    // `reload_extension_resources` routine asynchronously. The mailbox is the
5349    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
5350    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
5351    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5352    reload_context.mailbox.install(reload_sig_tx);
5353    let reload_tx = tx.clone();
5354    let reload_bridge_handle = tokio::spawn(async move {
5355        while reload_sig_rx.recv().await.is_some() {
5356            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5357                break; // main loop gone — stop forwarding
5358            }
5359        }
5360    });
5361
5362    // ---- Render-tick task (advances the loader spinner while Working) ----
5363    //
5364    // The `Loader` only advances its frame on render; without a periodic
5365    // `request_render` the spinner visibly freezes between events.
5366    let tui_tick = tui.clone();
5367    let state_tick = state.clone();
5368    let tick_handle = tokio::spawn(async move {
5369        // 80ms — pi's loader DEFAULT_INTERVAL_MS (the spinner would visibly
5370        // stutter at the old 120ms).
5371        let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
5372        interval.tick().await; // discard immediate
5373        loop {
5374            interval.tick().await;
5375            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5376            if working {
5377                if state_tick.bash_components.lock().unwrap().is_empty() {
5378                    // Only the dock loader animates. Keep the already-rendered
5379                    // transcript instead of rebuilding a long history at 12.5
5380                    // frames per second.
5381                    tui_tick.request_render_reusing_scroll_content();
5382                } else {
5383                    // A running bash panel owns a loader inside the transcript.
5384                    tui_tick.request_render(false);
5385                }
5386            }
5387        }
5388    });
5389
5390    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
5391    let running = Arc::new(std::sync::Mutex::new(true));
5392    let running_key = running.clone();
5393    let tx_for_key = tx.clone();
5394    let tui_for_key = tui.clone();
5395    let editor_for_key = editor.clone();
5396    let scroll_for_key = scroll_view.clone();
5397    let lane_for_key = lane.clone();
5398    let state_for_key = state.clone();
5399    let model_catalog_for_key = model_catalog_arc.clone();
5400    let js_for_key = reload_context.js_extension_session.clone();
5401    let js_dialog_for_key = js_dialog_bridge.clone();
5402    let ask_user_for_key = ask_user_bridge.clone();
5403    // Ctrl+L routes through the same registry as `/model` (one path, not two),
5404    // so the key loop needs the same `CommandContext` + registry the submit
5405    // handler uses. All fields are `Arc`/cheap, so this clone is free.
5406    let ctx_for_key = ctx.clone();
5407    let registry_for_key = registry.clone();
5408    let keybindings_for_key = keybindings.clone();
5409    let double_escape_action = crate::settings::load_settings()
5410        .ok()
5411        .and_then(|settings| settings.double_escape_action)
5412        .unwrap_or_else(|| "tree".to_string())
5413        .to_ascii_lowercase();
5414
5415    let key_handle = tokio::task::spawn_blocking(move || {
5416        let mut last_escape_time = None;
5417        loop {
5418            if !*running_key.lock().unwrap() {
5419                break;
5420            }
5421            if !state_for_key.selector_open()
5422                && !state_for_key.extension_dialog_open()
5423                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
5424            {
5425                if let Some(request) = js_dialog_for_key.take_pending() {
5426                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
5427                } else if let Some(request) = ask_user_for_key.take_pending() {
5428                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5429                }
5430            }
5431            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
5432            // `event::read()` blocks indefinitely. Poll first so shutdown can
5433            // stop and join this worker even when no further key arrives.
5434            match crossterm::event::poll(std::time::Duration::from_millis(50)) {
5435                Ok(true) => {}
5436                Ok(false) => continue,
5437                Err(_) => {
5438                    state_for_key.cancel_js_preparation();
5439                    let _ = tx_for_key.send(TuiMessage::Exit);
5440                    break;
5441                }
5442            }
5443            let Ok(ev) = crossterm::event::read() else {
5444                state_for_key.cancel_js_preparation();
5445                let _ = tx_for_key.send(TuiMessage::Exit);
5446                break;
5447            };
5448            // `Event::Resize` is delivered as its own event (not a Key). With
5449            // `start_readerless` there is no competing terminal-reader thread to
5450            // handle it, so refresh the cached terminal size here and force a
5451            // full redraw so the constrained layout re-fits the new dimensions.
5452            if let Event::Resize(_cols, _rows) = ev {
5453                tui_for_key.refresh_size();
5454                if let Some(js) = &js_for_key {
5455                    if js.custom_active() {
5456                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
5457                        tui_for_key.request_render(false);
5458                    }
5459                }
5460                continue;
5461            }
5462            // Mouse wheel scrolls the transcript (pi supports wheel
5463            // scrolling). Previously every non-Key event was dropped, so a
5464            // wheel had zero effect — "滚动还是不行".
5465            if let Event::Mouse(m) = ev {
5466                use crossterm::event::MouseEventKind;
5467                match m.kind {
5468                    MouseEventKind::ScrollUp => {
5469                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
5470                        if scroll_for_key.scroll_by(delta) != delta {
5471                            tui_for_key.request_render_reusing_scroll_content();
5472                        }
5473                    }
5474                    MouseEventKind::ScrollDown => {
5475                        let delta = MOUSE_WHEEL_SCROLL_LINES;
5476                        if scroll_for_key.scroll_by(delta) != delta {
5477                            tui_for_key.request_render_reusing_scroll_content();
5478                        }
5479                    }
5480                    _ => {}
5481                }
5482                continue;
5483            }
5484            let Event::Key(key) = ev else {
5485                if let Event::Paste(text) = ev {
5486                    let candidate = text.trim().trim_matches(['\"', '\'']);
5487                    let path = std::path::PathBuf::from(candidate);
5488                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5489                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5490                            add_image_preview(&state_for_key.chat_container, &image);
5491                            state_for_key.queue_image(image);
5492                            add_note_message(
5493                                &state_for_key.chat_container,
5494                                "Dropped image attached to the next prompt.",
5495                            );
5496                            tui_for_key.request_render(false);
5497                            continue;
5498                        }
5499                    }
5500                    editor_for_key.insert(&text);
5501                    refresh_autocomplete(&state_for_key, &editor_for_key);
5502                    tui_for_key.request_render_reusing_scroll_content();
5503                }
5504                continue;
5505            };
5506            // Drop releases but preserve Repeat so holding arrows, Backspace,
5507            // PageUp, etc. behaves naturally. Windows emits Press + Release
5508            // for a tap; terminals with keyboard enhancement may additionally
5509            // emit Repeat while a key is held.
5510            if !should_dispatch_key(key.kind) {
5511                continue;
5512            }
5513
5514            // Prompt preparation runs on a blocking worker before the agent
5515            // lane owns the turn. Cancel it directly: an abort queued only to
5516            // the lane cannot wake a JS factory or lifecycle hook that never
5517            // resolves. This check precedes custom/dialog routing because
5518            // those components may themselves have been opened by the hook.
5519            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5520                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5521                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5522            if prompt_abort && state_for_key.cancel_js_preparation() {
5523                state_for_key.set_status(RunStatus::Aborting);
5524                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5525                    close_extension_editor(
5526                        &state_for_key,
5527                        &ctx_for_key.editor_container,
5528                        &editor_for_key,
5529                        &tui_for_key,
5530                    );
5531                }
5532                js_dialog_for_key.cancel_open_requests();
5533                tui_for_key.set_render_suspended(false);
5534                tui_for_key.request_render(false);
5535                continue;
5536            }
5537
5538            if let Some(js) = &js_for_key {
5539                if js.custom_active() {
5540                    let visible = js.custom_accepts_input();
5541                    let data = key_event_to_input(key);
5542                    if !data.is_empty() {
5543                        // A visible custom owns the whole key stream, so its
5544                        // acknowledgement is unnecessary and would add a
5545                        // synchronous round-trip to every keystroke. Hidden
5546                        // overlays need the consume result to decide whether the
5547                        // outer editor should see the key.
5548                        if visible {
5549                            let _ = js.send_custom_input(&data);
5550                            continue;
5551                        }
5552                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5553                        // A hidden component only keeps raw listeners alive (for
5554                        // example ask_user_question's reopen shortcut); an
5555                        // unconsumed key continues through the outer editor.
5556                        if consumed {
5557                            tui_for_key.request_render_reusing_scroll_content();
5558                            continue;
5559                        }
5560                    }
5561                }
5562            }
5563
5564            // Ctrl+C cancels an open selector before it reaches the global
5565            // abort/exit handler. Route through Esc so selector callbacks run.
5566            if key.modifiers == KeyModifiers::CONTROL
5567                && key.code == KeyCode::Char('c')
5568                && state_for_key.selector_open()
5569            {
5570                let selector = state_for_key
5571                    .active_selector
5572                    .lock()
5573                    .unwrap()
5574                    .clone()
5575                    .expect("selector_open guaranteed Some")
5576                    .0;
5577                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5578                tui_for_key.request_render_reusing_scroll_content();
5579                continue;
5580            }
5581
5582            // Extension dialogs own the input slot while awaiting a result.
5583            // Esc and Ctrl+C both resolve the pending command with cancel;
5584            // all other keys go to the active native editor/input widget.
5585            if state_for_key.extension_dialog_open() {
5586                let cancel = key.code == KeyCode::Esc
5587                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5588                if cancel {
5589                    if !run_extension_cancel(&state_for_key) {
5590                        close_extension_editor(
5591                            &state_for_key,
5592                            &ctx_for_key.editor_container,
5593                            &editor_for_key,
5594                            &tui_for_key,
5595                        );
5596                    }
5597                } else if let Some(extension_editor) = state_for_key
5598                    .active_extension_editor
5599                    .lock()
5600                    .unwrap()
5601                    .clone()
5602                {
5603                    extension_editor.handle_key(key);
5604                } else if let Some(extension_input) =
5605                    state_for_key.active_extension_input.lock().unwrap().clone()
5606                {
5607                    extension_input.handle_key(key);
5608                }
5609                tui_for_key.request_render_reusing_scroll_content();
5610                continue;
5611            }
5612
5613            // 0. Ctrl+C: copy the selection when the editor has one (pi
5614            //    `tui.input.copy`); otherwise abort an active run, or exit
5615            //    when idle. Open selectors and extension dialogs are handled
5616            //    above so their cancellation callbacks get first chance.
5617            if keybinding_matches(
5618                &keybindings_for_key,
5619                &key,
5620                rpi_tui::keybindings::keys::CLEAR,
5621            ) {
5622                if !state_for_key.selector_open() && editor_for_key.has_selection() {
5623                    editor_for_key.copy_selection();
5624                    continue;
5625                }
5626                let status = *state_for_key.status.lock().unwrap();
5627                match status {
5628                    RunStatus::Working => {
5629                        state_for_key.set_status(RunStatus::Aborting);
5630                        // Drop any outstanding ask_user prompt from this turn.
5631                        ask_user_for_key.cancel_all();
5632                        // Restore the editor if an extension dialog was occupying it.
5633                        if !run_extension_cancel(&state_for_key)
5634                            && state_for_key.extension_dialog_open()
5635                        {
5636                            close_extension_editor(
5637                                &state_for_key,
5638                                &ctx_for_key.editor_container,
5639                                &editor_for_key,
5640                                &tui_for_key,
5641                            );
5642                        }
5643                        let lane = lane_for_key.clone();
5644                        tokio::spawn(async move {
5645                            let _ = lane.abort().await;
5646                        });
5647                    }
5648                    // A held Ctrl+C can emit Repeat immediately after Press.
5649                    // Keep waiting for the in-flight cancellation instead of
5650                    // treating that repeat as a request to exit the process.
5651                    RunStatus::Aborting => {}
5652                    RunStatus::Idle => {
5653                        let _ = tx_for_key.send(TuiMessage::Exit);
5654                    }
5655                }
5656                continue;
5657            }
5658
5659            // 1. A selector overlay is open → route to it first. Only Esc
5660            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
5661            //    escape to the selector; on done/cancel the selector callbacks
5662            //    restore the editor and clear `active_selector`.
5663            if state_for_key.selector_open() {
5664                // Esc always cancels the selector (even with modifiers off).
5665                // Route through `SelectList::handle_key(Esc)` so the list's
5666                // `on_cancel` fires (the `/scoped-models` toggle selector saves
5667                // its edits there) — the old shortcut called `close_selector`
5668                // directly and skipped the callback.
5669                if key.code == KeyCode::Esc {
5670                    let (selector, _kind) = state_for_key
5671                        .active_selector
5672                        .lock()
5673                        .unwrap()
5674                        .clone()
5675                        .expect("selector_open guaranteed Some");
5676                    selector.handle_key(key);
5677                    continue;
5678                }
5679                let (selector, _kind) = state_for_key
5680                    .active_selector
5681                    .lock()
5682                    .unwrap()
5683                    .clone()
5684                    .expect("selector_open guaranteed Some");
5685                selector.handle_key(key);
5686                tui_for_key.request_render_reusing_scroll_content();
5687                continue;
5688            }
5689
5690            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
5691            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
5692            //     editor. With a run active, abort it first (same as Ctrl+C)
5693            //     so the key is never a no-op while a stuck command runs.
5694            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5695                let status = *state_for_key.status.lock().unwrap();
5696                match status {
5697                    RunStatus::Working => {
5698                        state_for_key.set_status(RunStatus::Aborting);
5699                        ask_user_for_key.cancel_all();
5700                        if !run_extension_cancel(&state_for_key)
5701                            && state_for_key.extension_dialog_open()
5702                        {
5703                            close_extension_editor(
5704                                &state_for_key,
5705                                &ctx_for_key.editor_container,
5706                                &editor_for_key,
5707                                &tui_for_key,
5708                            );
5709                        }
5710                        let lane = lane_for_key.clone();
5711                        tokio::spawn(async move {
5712                            let _ = lane.abort().await;
5713                        });
5714                        continue;
5715                    }
5716                    RunStatus::Aborting => continue,
5717                    RunStatus::Idle => {}
5718                }
5719                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5720                    // Editor holds text — delete the char forward (pi parity).
5721                    editor_for_key.handle_key(key);
5722                    refresh_autocomplete(&state_for_key, &editor_for_key);
5723                    tui_for_key.request_render_reusing_scroll_content();
5724                    continue;
5725                }
5726                let _ = tx_for_key.send(TuiMessage::Exit);
5727                continue;
5728            }
5729
5730            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
5731            //     selector is open Esc already cancelled it above; when idle,
5732            //     Esc falls through to the editor (no-op-ish). Only fire while
5733            //     Working so an idle Esc doesn't abort a non-existent run.
5734            if keybinding_matches(
5735                &keybindings_for_key,
5736                &key,
5737                rpi_tui::keybindings::keys::INTERRUPT,
5738            ) {
5739                let status = *state_for_key.status.lock().unwrap();
5740                if status == RunStatus::Working {
5741                    state_for_key.set_status(RunStatus::Aborting);
5742                    ask_user_for_key.cancel_all();
5743                    if !run_extension_cancel(&state_for_key)
5744                        && state_for_key.extension_dialog_open()
5745                    {
5746                        close_extension_editor(
5747                            &state_for_key,
5748                            &ctx_for_key.editor_container,
5749                            &editor_for_key,
5750                            &tui_for_key,
5751                        );
5752                    }
5753                    let lane = lane_for_key.clone();
5754                    tokio::spawn(async move {
5755                        let _ = lane.abort().await;
5756                    });
5757                    continue;
5758                }
5759                if status == RunStatus::Idle
5760                    && editor_for_key.get_text().trim().is_empty()
5761                    && double_escape_action != "none"
5762                {
5763                    let now = std::time::Instant::now();
5764                    if double_escape_trigger(last_escape_time, now) {
5765                        last_escape_time = None;
5766                        match double_escape_action.as_str() {
5767                            "tree" => {
5768                                let _ = tx_for_key.send(TuiMessage::OpenTree);
5769                            }
5770                            "fork" => {
5771                                let _ = tx_for_key.send(TuiMessage::ForkSession);
5772                            }
5773                            _ => {}
5774                        }
5775                    } else {
5776                        last_escape_time = Some(now);
5777                    }
5778                }
5779                continue;
5780            }
5781
5782            // 2c. Ctrl+G: edit the current draft in the user's external
5783            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
5784            if keybinding_matches(
5785                &keybindings_for_key,
5786                &key,
5787                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5788            ) {
5789                launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5790                continue;
5791            }
5792
5793            // 2d. Ctrl+O: toggle all tool output panels between compact and
5794            // expanded rendering (native Pi's global output toggle).
5795            if keybinding_matches(
5796                &keybindings_for_key,
5797                &key,
5798                rpi_tui::keybindings::keys::TOOLS_EXPAND,
5799            ) {
5800                state_for_key.toggle_tool_outputs();
5801                tui_for_key.request_render(false);
5802                continue;
5803            }
5804
5805            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
5806            if keybinding_matches(
5807                &keybindings_for_key,
5808                &key,
5809                rpi_tui::keybindings::keys::THINKING_TOGGLE,
5810            ) {
5811                state_for_key.toggle_thinking();
5812                tui_for_key.request_render(false);
5813                continue;
5814            }
5815
5816            // 2f. Ctrl+M: cycle to the next model in the catalog after the one
5817            //     currently tracked in `current_model_id`, apply it live via
5818            //     `lane.set_model` (takes effect on the next user message — the
5819            //     in-flight run's config is already snapshotted), and update the
5820            //     footer. `set_model` is async so it runs on a spawned task.
5821            if keybinding_matches(
5822                &keybindings_for_key,
5823                &key,
5824                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5825            ) {
5826                let current = state_for_key.current_model_id();
5827                // Cycle within the `/scoped-models` set (settings.json) when
5828                // configured; otherwise the full catalog.
5829                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
5830                if let Some(next) = cycle_next_model(&scope, &current) {
5831                    state_for_key.set_current_model(&next);
5832                    let lane = lane_for_key.clone();
5833                    tokio::spawn(async move {
5834                        let _ = lane.set_model(next).await;
5835                    });
5836                    tui_for_key.request_render_reusing_scroll_content();
5837                }
5838                continue;
5839            }
5840
5841            // 2f. Shift+Tab / BackTab: cycle the current model's supported
5842            // thinking levels, matching native Pi's thinking-level shortcut.
5843            if keybinding_matches(
5844                &keybindings_for_key,
5845                &key,
5846                rpi_tui::keybindings::keys::THINKING_CYCLE,
5847            ) {
5848                let lane = lane_for_key.clone();
5849                let catalog = model_catalog_for_key.clone();
5850                let state = state_for_key.clone();
5851                tokio::spawn(async move {
5852                    let Ok(current_model) = lane.get_model().await else {
5853                        return;
5854                    };
5855                    let levels = catalog
5856                        .iter()
5857                        .find(|model| {
5858                            model.provider == current_model.provider && model.id == current_model.id
5859                        })
5860                        .map(|model| model.supported_thinking_levels())
5861                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5862                    if levels.is_empty() {
5863                        return;
5864                    }
5865                    let current = lane
5866                        .get_thinking_level()
5867                        .await
5868                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
5869                    let next = levels
5870                        .iter()
5871                        .position(|level| *level == current)
5872                        .map(|index| levels[(index + 1) % levels.len()])
5873                        .unwrap_or(levels[0]);
5874                    if lane.set_thinking_level(next).await.is_ok() {
5875                        state
5876                            .footer
5877                            .set_thinking_level(Some(thinking_level_name(next)));
5878                        state.tui.as_ref().map(|tui| tui.request_render(false));
5879                    }
5880                });
5881                continue;
5882            }
5883
5884            // Ctrl+V (or a configured paste-image key) keeps normal text yank
5885            // behavior when the clipboard has no bitmap, but queues an image
5886            // for the next prompt when one is available.
5887            if keybinding_matches(
5888                &keybindings_for_key,
5889                &key,
5890                rpi_tui::keybindings::keys::PASTE_IMAGE,
5891            ) {
5892                match read_clipboard_image() {
5893                    Ok(Some(image)) => {
5894                        add_image_preview(&state_for_key.chat_container, &image);
5895                        state_for_key.queue_image(image);
5896                        add_note_message(
5897                            &state_for_key.chat_container,
5898                            "Clipboard image attached to the next prompt.",
5899                        );
5900                        tui_for_key.request_render(false);
5901                        continue;
5902                    }
5903                    Ok(None) | Err(_) => {}
5904                }
5905            }
5906
5907            // 3. Ctrl+L: open the model selector. Routed through the `/model`
5908            //    command so the hotkey and the slash command share one path
5909            //    (TS binds Ctrl+L to model-select).
5910            if keybinding_matches(
5911                &keybindings_for_key,
5912                &key,
5913                rpi_tui::keybindings::keys::MODEL_SELECT,
5914            ) {
5915                if let Some(cmd) = registry_for_key.find("/model") {
5916                    cmd.execute(&ctx_for_key, "");
5917                }
5918                continue;
5919            }
5920
5921            // 4. Tab: accept the top autocomplete suggestion (if any).
5922            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
5923                if accept_top_suggestion(&state_for_key, &editor_for_key) {
5924                    tui_for_key.request_render_reusing_scroll_content();
5925                }
5926                continue;
5927            }
5928
5929            // 5. Global transcript scroll. PageUp/PageDown use the actual
5930            // viewport height with four rows of overlap (upstream behavior),
5931            // while Home/End jump to the transcript boundaries.
5932            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
5933                let delta = -transcript_page_size(scroll_for_key.viewport_height());
5934                if scroll_for_key.scroll_by(delta) != delta {
5935                    tui_for_key.request_render_reusing_scroll_content();
5936                }
5937                continue;
5938            }
5939            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
5940                let delta = transcript_page_size(scroll_for_key.viewport_height());
5941                if scroll_for_key.scroll_by(delta) != delta {
5942                    tui_for_key.request_render_reusing_scroll_content();
5943                }
5944                continue;
5945            }
5946            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
5947                scroll_for_key.scroll_to_start();
5948                tui_for_key.request_render_reusing_scroll_content();
5949                continue;
5950            }
5951            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
5952                scroll_for_key.scroll_to_end();
5953                tui_for_key.request_render_reusing_scroll_content();
5954                continue;
5955            }
5956
5957            // 5b. ↑/↓ browse submitted-message history when the editor is
5958            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
5959            //     without the surprise of replacing typed text. When the
5960            //     editor holds content, ↑/↓ fall through to cursor movement
5961            //     (typing "hello", pressing ↑ at the start, must never swap
5962            //     the draft for a history entry — reported as "text
5963            //     disappeared"). Once browsing, ↓ walks back and restores the
5964            //     draft.
5965            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
5966                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5967                if editor_for_key.get_text().is_empty() || browsing {
5968                    navigate_history(&state_for_key, &editor_for_key, -1);
5969                    tui_for_key.request_render_reusing_scroll_content();
5970                    continue;
5971                }
5972            }
5973            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
5974                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
5975                if editor_for_key.get_text().is_empty() || browsing {
5976                    navigate_history(&state_for_key, &editor_for_key, 1);
5977                    tui_for_key.request_render_reusing_scroll_content();
5978                    continue;
5979                }
5980            }
5981
5982            // Alt+Enter queues a follow-up while a run is active. It is
5983            // handled here because Editor treats only a bare Enter as submit;
5984            // idle Alt+Enter keeps the normal prompt behavior.
5985            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
5986                let prompt = editor_for_key.get_text().trim().to_string();
5987                if prompt.is_empty() {
5988                    continue;
5989                }
5990                editor_for_key.clear();
5991                let status = *state_for_key.status.lock().unwrap();
5992                if status == RunStatus::Idle {
5993                    if state_for_key.try_start_working() {
5994                        add_user_message(&state_for_key.chat_container, &prompt);
5995                        push_history(&state_for_key, &prompt);
5996                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
5997                    }
5998                } else {
5999                    // Follow-ups are user prompts too; retain the normal
6000                    // submitted-message rendering while they wait in queue.
6001                    add_user_message(&state_for_key.chat_container, &prompt);
6002                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
6003                    let lane = lane_for_key.clone();
6004                    let chat = state_for_key.chat_container.clone();
6005                    let tui = tui_for_key.clone();
6006                    tokio::spawn(async move {
6007                        if let Err(error) = lane.follow_up(message).await {
6008                            add_error_message(&chat, &format!("Could not queue message: {error}"));
6009                            tui.request_render(false);
6010                        }
6011                    });
6012                }
6013                tui_for_key.request_render(false);
6014                continue;
6015            }
6016
6017            // 6. Otherwise forward to the editor + refresh autocomplete.
6018            editor_for_key.handle_key(key);
6019            refresh_autocomplete(&state_for_key, &editor_for_key);
6020            tui_for_key.request_render_reusing_scroll_content();
6021        }
6022    });
6023
6024    // ---- Initial prompts (run before reading from the channel) ----
6025    let mut prompts: Vec<String> = Vec::new();
6026    if let Some(init) = initial {
6027        prompts.push(init);
6028    }
6029    for m in extra_messages {
6030        prompts.push(m.clone());
6031    }
6032    let mut images = initial_images;
6033    for prompt in prompts {
6034        if !*running.lock().unwrap() {
6035            break;
6036        }
6037        add_user_message(&chat_container, &prompt);
6038        tui.request_render(false);
6039        run_prompt_streaming(
6040            &lane,
6041            &prompt,
6042            &tui,
6043            &state,
6044            drain_handle.is_some(),
6045            reload_context.js_extension_session.as_ref(),
6046            &js_dialog_bridge,
6047            args,
6048            std::mem::take(&mut images),
6049        )
6050        .await;
6051    }
6052
6053    // ---- Main loop: process submitted input + lifecycle messages ----
6054    loop {
6055        if !*running.lock().unwrap() {
6056            break;
6057        }
6058        match rx.recv().await {
6059            Some(TuiMessage::UserInput(prompt)) => {
6060                // Clear the editor so the next prompt starts fresh (the submit
6061                // handler runs on the blocking key thread and can't mutate the
6062                // editor state safely there; clearing here, on the async loop,
6063                // keeps it on one thread).
6064                editor.clear();
6065                let prompt_images = state.take_pending_images();
6066                if !prompt_images.is_empty() {
6067                    add_note_message(
6068                        &chat_container,
6069                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6070                    );
6071                }
6072                run_prompt_streaming(
6073                    &lane,
6074                    &prompt,
6075                    &tui,
6076                    &state,
6077                    drain_handle.is_some(),
6078                    reload_context.js_extension_session.as_ref(),
6079                    &js_dialog_bridge,
6080                    args,
6081                    prompt_images,
6082                )
6083                .await;
6084            }
6085            Some(TuiMessage::ExternalEditorResult(result)) => {
6086                match result {
6087                    Ok(text) => {
6088                        let cursor = text.chars().count();
6089                        editor.set_text(&text);
6090                        editor.set_cursor(0, cursor);
6091                        add_note_message(&chat_container, "Draft updated from external editor.");
6092                    }
6093                    Err(error) => add_error_message(&chat_container, &error),
6094                }
6095                tui.request_render(false);
6096            }
6097            Some(TuiMessage::OpenTree) => {
6098                if *state.status.lock().unwrap() != RunStatus::Idle {
6099                    add_note_message(
6100                        &chat_container,
6101                        "Wait for the current run to finish before opening the tree.",
6102                    );
6103                    tui.request_render(false);
6104                } else {
6105                    open_tree_selector(
6106                        &harness,
6107                        &state,
6108                        &editor_container,
6109                        &editor,
6110                        &tui,
6111                        &chat_container,
6112                        &tx,
6113                    )
6114                    .await;
6115                }
6116            }
6117            Some(TuiMessage::NavigateTree(entry_id)) => {
6118                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6119                    Ok(result) => match result.outcome {
6120                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6121                            chat_container.clear();
6122                            add_welcome_message(&chat_container);
6123                            render_session_history(
6124                                &harness,
6125                                &chat_container,
6126                                state.markdown_transformer(),
6127                                Some(state.extension_session.clone()),
6128                            )
6129                            .await;
6130                            add_note_message(
6131                                &chat_container,
6132                                "Moved to the selected session entry.",
6133                            );
6134                        }
6135                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6136                            add_error_message(&chat_container, &error.message);
6137                        }
6138                        _ => add_note_message(
6139                            &chat_container,
6140                            "The selected entry could not be opened.",
6141                        ),
6142                    },
6143                    Err(error) => add_error_message(
6144                        &chat_container,
6145                        &format!("Could not navigate session tree: {error}"),
6146                    ),
6147                }
6148                tui.request_render(false);
6149            }
6150            Some(TuiMessage::ClearChat) => {
6151                chat_container.clear();
6152                add_welcome_message(&chat_container);
6153                tui.request_render(false);
6154            }
6155            Some(TuiMessage::Compact) => {
6156                run_compact(&lane, &tui, &state).await;
6157            }
6158            Some(TuiMessage::Copy) => {
6159                copy_last_assistant(&state, &chat_container);
6160                tui.request_render(false);
6161            }
6162            Some(TuiMessage::Exit) => {
6163                *running.lock().unwrap() = false;
6164                break;
6165            }
6166            Some(TuiMessage::SwitchSession(id)) => {
6167                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6168                tui.request_render(false);
6169            }
6170            Some(TuiMessage::ImportSession(path)) => {
6171                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6172                tui.request_render(false);
6173            }
6174            Some(TuiMessage::ShareSession) => {
6175                share_session(&harness, &chat_container).await;
6176                tui.request_render(false);
6177            }
6178            Some(TuiMessage::SetSessionName(name)) => {
6179                let outcome = harness.session().set_name(Some(&name)).await;
6180                match outcome {
6181                    Ok(_) => add_note_message(
6182                        &chat_container,
6183                        &format!("Session renamed to \"{name}\"."),
6184                    ),
6185                    Err(e) => add_error_message(
6186                        &chat_container,
6187                        &format!("Could not rename session: {e}"),
6188                    ),
6189                }
6190                tui.request_render(false);
6191            }
6192            Some(TuiMessage::ExportSession) => {
6193                export_session(&harness, &chat_container, &cwd).await;
6194                tui.request_render(false);
6195            }
6196            Some(TuiMessage::ForkSession) => {
6197                fork_session(&harness, &cwd, &chat_container, &state).await;
6198                tui.request_render(false);
6199            }
6200            Some(TuiMessage::ReloadExtensions) => {
6201                // B5d: drive the shared reload routine on the async runtime,
6202                // then surface the outcome. `reload_context` was passed into
6203                // `interactive_tui` and is the same `Arc<ReloadContext>` the
6204                // `ReloadCommand` + the plugin mailbox both route through —
6205                // clone the `Arc` out so the borrow of `harness` (the main
6206                // loop's `&AgentHarness`) lives across the await.
6207                let reload_ctx = ctx.reload_context.clone();
6208                add_note_message(&chat_container, "Reloading extensions + resources…");
6209                tui.request_render(false);
6210                let outcome =
6211                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6212                // B5e: the reload swapped a fresh `ExtensionSession` into the
6213                // context's cell. Rebuild the markdown transformer from that
6214                // fresh snapshot and install it on the in-flight streaming
6215                // component (so a reloaded plugin's transformer takes effect on
6216                // the visible message immediately) + future components (they
6217                // read `state.markdown_transformer()` at construction). The old
6218                // closure no-ops once its snapshot's `active` flag flips false
6219                // (reload already did that before the swap).
6220                let fresh_transformer = build_markdown_transformer(
6221                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6222                );
6223                state.set_markdown_transformer_with_reinstall(fresh_transformer);
6224                if outcome.had_errors {
6225                    // Hard failure: the summary itself carries the error detail
6226                    // (e.g. "Settings reload failed: …"), so render it as an
6227                    // error without an extra stderr pointer.
6228                    add_error_message(&chat_container, &outcome.summary);
6229                } else if outcome.had_warnings {
6230                    // Reload succeeded but produced soft diagnostics (skill
6231                    // shadowing, name validation, …). The details are printed
6232                    // to stderr; keep this as an informational note so a
6233                    // successful reload isn't shown as a red ✗ error.
6234                    add_note_message(
6235                        &chat_container,
6236                        &format!(
6237                            "{} (with warnings — see stderr for details).",
6238                            outcome.summary
6239                        ),
6240                    );
6241                } else {
6242                    add_note_message(&chat_container, &outcome.summary);
6243                }
6244                tui.request_render(false);
6245            }
6246            None => break,
6247        }
6248    }
6249
6250    // ---- Shutdown ----
6251    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
6252    // bridge before joining the key worker and restoring the terminal.
6253    js_dialog_bridge.cancel_all();
6254    ask_user_bridge.shutdown();
6255    *running.lock().unwrap() = false;
6256    for handle in update_check_handles {
6257        handle.abort();
6258        let _ = handle.await;
6259    }
6260    // A hidden custom input listener can leave the key worker blocked on a
6261    // synchronous Node response. Stop the host first so transport shutdown
6262    // wakes that request before we join the worker.
6263    if let Some(js) = &reload_context.js_extension_session {
6264        js.shutdown();
6265    }
6266    // The input worker checks `running` at least every 50ms. Join it before
6267    // restoring cooked mode so no late event read races terminal cleanup.
6268    let _ = key_handle.await;
6269    tick_handle.abort();
6270    if let Some(handle) = drain_handle {
6271        handle.abort();
6272    }
6273    // Drop the reload bridge: clearing the mailbox closes the signal channel,
6274    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
6275    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
6276    reload_context.mailbox.clear();
6277    reload_bridge_handle.abort();
6278    tui.stop(Default::default());
6279    println!("\nGoodbye!");
6280    let _ = args;
6281
6282    0
6283}
6284
6285// ===========================================================================
6286// Run a single prompt (streaming or blocking)
6287// ===========================================================================
6288
6289/// Prepare the session's persistent Node host immediately before a real prompt
6290/// enters the agent loop. The first call starts the lazy host; later calls run
6291/// `before_agent_start` again on that host so each prompt sees current state.
6292/// Keeping startup here leaves an idle TUI free of a Node child while still
6293/// giving the lifecycle hook the fully installed UI bridge.
6294async fn ensure_js_runtime_before_prompt(
6295    js: Option<&crate::js_extensions::JsExtensionSession>,
6296    lane: &Arc<dyn AgentLane>,
6297    state: &Arc<TuiState>,
6298    dialog_bridge: &JsDialogBridge,
6299    args: &Args,
6300) -> bool {
6301    let Some(js) = js else {
6302        return true;
6303    };
6304    let cancellation = state.begin_js_preparation();
6305    let worker_cancellation = cancellation.clone();
6306    let js_for_start = js.clone();
6307    let mut worker = tokio::task::spawn_blocking(move || {
6308        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6309    });
6310    let result = tokio::select! {
6311        result = &mut worker => result,
6312        _ = cancellation.cancelled() => {
6313            dialog_bridge.cancel_open_requests();
6314            let js_for_cancel = js.clone();
6315            let _ = tokio::task::spawn_blocking(move || {
6316                js_for_cancel.cancel_prompt_preparation();
6317            }).await;
6318            worker.await
6319        }
6320    };
6321    let was_cancelled = cancellation.is_cancelled();
6322    if was_cancelled {
6323        dialog_bridge.cancel_open_requests();
6324        let js_for_cancel = js.clone();
6325        let _ = tokio::task::spawn_blocking(move || {
6326            js_for_cancel.cancel_prompt_preparation();
6327        })
6328        .await;
6329        state.finish_js_preparation();
6330        dialog_bridge.reopen();
6331        return false;
6332    }
6333    state.finish_js_preparation();
6334    match result {
6335        Ok(Ok(())) => {
6336            // The lifecycle hook can change the JS-only active tool set once
6337            // it sees the real TUI context. Merge that subset with the Rust
6338            // built-ins while applying the command-line tool policy.
6339            if let Some(js_active) = js.active_tools() {
6340                let js_names = js.tool_names();
6341                let mut active = lane.get_active_tools().await.unwrap_or_default();
6342                active.retain(|name| {
6343                    crate::session::tool_name_allowed(name, args)
6344                        && !js_names.iter().any(|js_name| js_name == name)
6345                });
6346                active.extend(js_active.into_iter().filter(|name| {
6347                    js_names.iter().any(|js_name| js_name == name)
6348                        && crate::session::tool_name_allowed(name, args)
6349                }));
6350                active = crate::session::filter_active_tool_names(active, args);
6351                let _ = lane.set_active_tools(active).await;
6352            }
6353        }
6354        Ok(Err(error)) => {
6355            if args.verbose {
6356                eprintln!("warning: could not start JS extension runtime: {error}");
6357            }
6358        }
6359        Err(error) => {
6360            if args.verbose {
6361                eprintln!("warning: JS extension runtime worker failed: {error}");
6362            }
6363        }
6364    }
6365    true
6366}
6367
6368fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6369    std::thread::spawn(move || {
6370        let file = match tempfile::Builder::new()
6371            .prefix("rpi-draft-")
6372            .suffix(".md")
6373            .tempfile()
6374        {
6375            Ok(file) => file,
6376            Err(error) => {
6377                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6378                    "Could not create editor file: {error}"
6379                ))));
6380                return;
6381            }
6382        };
6383        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6384            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6385                "Could not write editor file: {error}"
6386            ))));
6387            return;
6388        }
6389        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6390            .ok()
6391            .filter(|value| !value.trim().is_empty())
6392            .or_else(|| std::env::var("VISUAL").ok())
6393            .or_else(|| std::env::var("EDITOR").ok())
6394            .unwrap_or_else(|| {
6395                if cfg!(windows) {
6396                    "notepad".to_string()
6397                } else {
6398                    "nano".to_string()
6399                }
6400            });
6401        let status = std::process::Command::new(&editor)
6402            .arg(file.path())
6403            .status();
6404        let result = match status {
6405            Ok(status) if status.success() => std::fs::read_to_string(file.path())
6406                .map_err(|error| format!("Could not read editor file: {error}")),
6407            Ok(status) => Err(format!("External editor exited with {status}")),
6408            Err(error) => Err(format!(
6409                "Could not launch external editor `{editor}`: {error}"
6410            )),
6411        };
6412        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6413    });
6414}
6415
6416/// Apply the authoritative run result when it wins the race with the async
6417/// event drain, then detach the live component from further partial updates.
6418fn reconcile_streamed_assistant_completion(
6419    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6420    last_assistant_text: &Mutex<String>,
6421    final_message: Option<&AssistantMessage>,
6422) {
6423    let final_blocks = final_message.map(assistant_blocks);
6424    let final_text = final_message.map(assistant_text);
6425    let component = current_assistant.lock().unwrap().take();
6426
6427    if let Some(component) = component {
6428        if let Some(blocks) = final_blocks.as_deref() {
6429            component.update_blocks(blocks);
6430        }
6431        component.set_streaming(false);
6432    }
6433
6434    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6435        *last_assistant_text.lock().unwrap() = text;
6436    }
6437}
6438
6439/// Drive a single prompt through the lane. When `streaming` is true, the
6440/// `AgentEvent` drain task renders the response live and the completed outcome
6441/// reconciles its final snapshot. When false (no `event_rx`), this falls back
6442/// to the blocking await-final-text path.
6443async fn run_prompt_streaming(
6444    lane: &Arc<dyn AgentLane>,
6445    prompt: &str,
6446    tui: &Arc<TuiAltScreen>,
6447    state: &Arc<TuiState>,
6448    streaming: bool,
6449    js: Option<&crate::js_extensions::JsExtensionSession>,
6450    dialog_bridge: &JsDialogBridge,
6451    args: &Args,
6452    images: Vec<rpi_ai::types::ImageContent>,
6453) {
6454    // The persistent Node host is intentionally started at the first real
6455    // prompt. By this point the TUI key worker and all UI/runtime handlers are
6456    // live, so a `before_agent_start` hook may safely open a native dialog. A
6457    // session with no prompt never starts Node merely to render its welcome
6458    // screen; JS commands/tools still trigger the same lazy ensure path.
6459    // Preparation is part of the active turn. Mark it working before Node can
6460    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
6461    // semantics for initial argv prompts as well as editor submissions.
6462    state.set_status(RunStatus::Working);
6463    tui.request_render(false);
6464    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6465        state.set_status(RunStatus::Idle);
6466        tui.request_render(false);
6467        return;
6468    }
6469
6470    let outcome = lane.prompt_text(prompt, images).await;
6471
6472    if streaming {
6473        // Broadcast delivery is asynchronous: the harness result can resolve
6474        // before the drain task processes MessageEnd. Reconcile from the
6475        // authoritative outcome before detaching the component so the final
6476        // streamed tail cannot be left at an earlier partial snapshot.
6477        let final_message = match &outcome {
6478            Ok(result) => match &result.outcome {
6479                HarnessRunOutcome::Completed { final_message, .. }
6480                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6481                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6482                HarnessRunOutcome::Suspended { .. } => None,
6483            },
6484            Err(_) => None,
6485        };
6486        reconcile_streamed_assistant_completion(
6487            &state.current_assistant,
6488            &state.last_assistant_text,
6489            final_message,
6490        );
6491    }
6492
6493    state.set_status(RunStatus::Idle);
6494
6495    match outcome {
6496        Ok(result) => match &result.outcome {
6497            HarnessRunOutcome::Failed {
6498                error,
6499                final_message,
6500                ..
6501            } => {
6502                // Only add an error line if the stream did NOT already render
6503                // an assistant message for it (drain task leaves
6504                // current_assistant Some only on an abrupt end).
6505                // A final assistant error is emitted by the event drain only
6506                // in streaming mode. In regular mode there is no drain task,
6507                // so suppressing this branch merely hides 405/auth/network
6508                // diagnostics from the user.
6509                let already_rendered = streaming && final_message.is_some();
6510                if !already_rendered {
6511                    let msg = final_message
6512                        .as_ref()
6513                        .and_then(|m| m.error_message.clone())
6514                        .unwrap_or_else(|| format!("{error:?}"));
6515                    add_error_message(&state.chat_container, &msg);
6516                }
6517            }
6518            HarnessRunOutcome::Suspended { .. } => {
6519                add_error_message(
6520                    &state.chat_container,
6521                    "Run suspended (deferred) — resume is not supported in v1.",
6522                );
6523            }
6524            HarnessRunOutcome::Aborted { final_message, .. } => {
6525                // Aborted runs render their own partial/final message via the
6526                // stream; only add a note on the blocking fallback path.
6527                if !streaming {
6528                    add_error_message(&state.chat_container, "Request aborted.");
6529                    let _ = final_message; // (rendered by the stream in streaming mode)
6530                }
6531            }
6532            HarnessRunOutcome::Completed { final_message, .. } => {
6533                if !streaming {
6534                    let text = assistant_text(final_message);
6535                    if !text.is_empty() {
6536                        add_assistant_message_blocking(
6537                            &state.chat_container,
6538                            &text,
6539                            state.markdown_transformer(),
6540                        );
6541                        *state.last_assistant_text.lock().unwrap() = text;
6542                    }
6543                }
6544            }
6545        },
6546        Err(e) => {
6547            add_error_message(&state.chat_container, &e.to_string());
6548        }
6549    }
6550
6551    tui.request_render(false);
6552}
6553
6554/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
6555/// Reports the outcome as a transcript note; v1's compaction summarizes the
6556/// session in place, so no streaming display is wired (compaction emits no
6557/// `AgentEvent`s — only the harness bus `RunEnd`).
6558async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6559    state.set_status(RunStatus::Working);
6560    tui.request_render(false);
6561    match lane.compact(None).await {
6562        Ok(_) => {
6563            add_note_message(&state.chat_container, "Conversation compacted.");
6564        }
6565        Err(e) => {
6566            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6567        }
6568    }
6569    state.set_status(RunStatus::Idle);
6570    tui.request_render(false);
6571}
6572
6573/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
6574/// when no clipboard is available (or the `clipboard` feature is off), prints a
6575/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
6576fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6577    let text = state.last_assistant_text.lock().unwrap().clone();
6578    if text.is_empty() {
6579        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6580        return;
6581    }
6582    if copy_to_clipboard(&text) {
6583        add_note_message(chat, "Copied last reply to the clipboard.");
6584    } else {
6585        // Clipboard unavailable — print the text to the transcript so the user
6586        // can select/copy it manually (degrades gracefully in headless envs).
6587        let preview: String = text.chars().take(200).collect();
6588        add_note_message(
6589            chat,
6590            &format!(
6591                "Clipboard unavailable. Last reply: {preview}{}",
6592                if text.chars().count() > 200 {
6593                    "…"
6594                } else {
6595                    ""
6596                }
6597            ),
6598        );
6599    }
6600}
6601
6602/// Best-effort clipboard write. Enabled only with the `clipboard` feature
6603/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
6604#[cfg(feature = "clipboard")]
6605fn copy_to_clipboard(text: &str) -> bool {
6606    match arboard::Clipboard::new() {
6607        Ok(mut cb) => cb.set_text(text).is_ok(),
6608        Err(_) => false,
6609    }
6610}
6611
6612#[cfg(not(feature = "clipboard"))]
6613fn copy_to_clipboard(_text: &str) -> bool {
6614    false
6615}
6616
6617/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
6618/// `ImageContent` contract. The optional clipboard feature keeps headless
6619/// builds free of platform clipboard dependencies.
6620#[cfg(feature = "clipboard")]
6621fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6622    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6623    let image = match clipboard.get_image() {
6624        Ok(image) => image,
6625        Err(_) => return Ok(None),
6626    };
6627    let width =
6628        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6629    let height =
6630        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6631    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6632        return Err("clipboard image dimensions are outside the supported range".into());
6633    }
6634    let mut bytes = Vec::new();
6635    {
6636        let mut encoder = png::Encoder::new(&mut bytes, width, height);
6637        encoder.set_color(png::ColorType::Rgba);
6638        encoder.set_depth(png::BitDepth::Eight);
6639        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6640        writer
6641            .write_image_data(&image.bytes)
6642            .map_err(|e| e.to_string())?;
6643    }
6644    Ok(Some(rpi_ai::types::ImageContent {
6645        kind: rpi_ai::types::ImageContentType,
6646        data: base64::engine::general_purpose::STANDARD.encode(bytes),
6647        mime_type: "image/png".into(),
6648    }))
6649}
6650
6651fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6652    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6653        let mut options = ImageOptions::default();
6654        options.width = Some(48);
6655        options.alt_text = Some("Attached image".into());
6656        chat.add_child(Arc::new(Image::from_data(bytes, options)));
6657        chat.add_child(Arc::new(Spacer::new(1)));
6658    }
6659}
6660
6661#[cfg(not(feature = "clipboard"))]
6662fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6663    Ok(None)
6664}
6665
6666/// Blocking fallback (no `event_rx`): render the final assistant text as a
6667/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
6668/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
6669/// the identity path. The blocking path only fires when `event_rx` is absent,
6670/// so it shares the same transformer the streaming path installs on its
6671/// components.
6672fn add_assistant_message_blocking(
6673    container: &Arc<Container>,
6674    text: &str,
6675    transformer: Option<MarkdownTransformer>,
6676) {
6677    if text.is_empty() {
6678        return;
6679    }
6680    let msg = Arc::new(AssistantMessageComponent::new(
6681        AssistantMessageOptions::default(),
6682    ));
6683    if let Some(t) = &transformer {
6684        msg.set_markdown_transformer(Some(t.clone()));
6685    }
6686    msg.update_text(text);
6687    container.add_child(msg);
6688    container.add_child(Arc::new(Spacer::new(1)));
6689}
6690
6691// ===========================================================================
6692// AgentEvent drain task — the streaming core
6693// ===========================================================================
6694
6695/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
6696/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
6697/// lifetime of the TUI.
6698async fn drain_agent_events(
6699    mut rx: broadcast::Receiver<AgentEvent>,
6700    tui: Arc<TuiAltScreen>,
6701    state: Arc<TuiState>,
6702    chat: Arc<Container>,
6703) {
6704    loop {
6705        match rx.recv().await {
6706            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6707            Err(broadcast::error::RecvError::Lagged(_)) => {
6708                // We dropped some intermediate deltas; the next MessageUpdate/
6709                // MessageEnd carries a full partial snapshot so the UI re-syncs.
6710                continue;
6711            }
6712            Err(broadcast::error::RecvError::Closed) => break,
6713        }
6714    }
6715}
6716
6717/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
6718/// (`interactive-mode.ts:3068-3396`).
6719async fn handle_agent_event(
6720    event: AgentEvent,
6721    tui: &Arc<TuiAltScreen>,
6722    state: &Arc<TuiState>,
6723    chat: &Arc<Container>,
6724) {
6725    match event {
6726        AgentEvent::AgentStart => {
6727            state.set_status(RunStatus::Working);
6728            tui.request_render(false);
6729        }
6730
6731        AgentEvent::AgentEnd { .. } => {
6732            // Finalize any still-streaming assistant message.
6733            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6734                comp.set_streaming(false);
6735            }
6736            state.set_status(RunStatus::Idle);
6737            tui.request_render(false);
6738        }
6739
6740        AgentEvent::RetryScheduled {
6741            attempt,
6742            max_retries,
6743            delay_ms,
6744            ..
6745        } => {
6746            state.show_retry(attempt, max_retries, delay_ms);
6747            tui.request_render(false);
6748        }
6749
6750        AgentEvent::TurnStart => {
6751            // A new turn: reset the streaming-assistant guard so the next
6752            // MessageStart creates a fresh component.
6753            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6754                comp.set_streaming(false);
6755            }
6756        }
6757
6758        AgentEvent::TurnEnd {
6759            message,
6760            tool_results,
6761        } => {
6762            // Finalize the assistant message for this turn.
6763            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6764                if let AgentMessage::Assistant(a) = &message {
6765                    comp.update_blocks(&assistant_blocks(a));
6766                }
6767                comp.set_streaming(false);
6768            }
6769            // Any tool results whose components were never ended by a
6770            // ToolExecutionEnd get a static rendering here (best-effort). The
6771            // normal path removes the component via ToolExecutionEnd; this is
6772            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
6773            let tools = state.tool_components.lock().unwrap();
6774            for tr in &tool_results {
6775                if tools.contains_key(&tr.tool_call_id) {
6776                    // Will be removed below via ToolExecutionEnd in the normal
6777                    // path; leave as-is if still present.
6778                    let _ = tr;
6779                }
6780            }
6781            drop(tools);
6782            tui.request_render(false);
6783        }
6784
6785        AgentEvent::MessageStart { message } => match message {
6786            AgentMessage::Assistant(a) => {
6787                let comp = Arc::new(AssistantMessageComponent::new(
6788                    AssistantMessageOptions::default(),
6789                ));
6790                // B5e: install the live markdown transformer so the plugin's
6791                // `register_markdown_transformer` handlers apply from the very
6792                // first streamed delta. `set_streaming` before the transform
6793                // install is fine (transform fires on `update_blocks`, below).
6794                if let Some(t) = state.markdown_transformer() {
6795                    comp.set_markdown_transformer(Some(t));
6796                }
6797                comp.set_hide_thinking(state.hide_thinking());
6798                comp.set_streaming(true);
6799                // Render text AND thinking blocks in order (the old path fed
6800                // only the concatenated text, so thinking blocks never showed).
6801                comp.update_blocks(&assistant_blocks(&a));
6802                chat.add_child(comp.clone());
6803                // Spacer(1) separates this assistant turn from the next entry;
6804                // the component itself adds no leading spacer.
6805                chat.add_child(Arc::new(Spacer::new(1)));
6806                *state.current_assistant.lock().unwrap() = Some(comp);
6807                tui.request_render(false);
6808            }
6809            AgentMessage::Custom(custom) => {
6810                let payload = serde_json::json!({
6811                    "customType": custom.role,
6812                    "content": custom.content,
6813                    "details": custom.data,
6814                    "expanded": false,
6815                    "outputPad": 1,
6816                });
6817                if let Some(component) = extension_message_component(
6818                    &state.extension_session,
6819                    &custom.role,
6820                    &payload,
6821                    state.markdown_transformer(),
6822                ) {
6823                    chat.add_child(component);
6824                    chat.add_child(Arc::new(Spacer::new(1)));
6825                    tui.request_render(false);
6826                } else {
6827                    add_note_message(chat, &custom_message_fallback(&custom));
6828                    tui.request_render(false);
6829                }
6830            }
6831            // User / ToolResult / Custom starts are echoed at submit time or
6832            // via the tool-execution components; ignore user/tool dupes.
6833            _ => {}
6834        },
6835
6836        AgentEvent::MessageUpdate {
6837            message,
6838            assistant_message_event,
6839        } => {
6840            if let AgentMessage::Assistant(a) = &message {
6841                let text = assistant_text(a);
6842                let mut saw_bash_tool_call = false;
6843                // Scan content for finalized tool calls → proactively create
6844                // tool components (TS shows the tool as soon as the assistant
6845                // emits the ToolCall; ToolExecutionStart coalesces if it
6846                // already exists).
6847                for c in &a.content {
6848                    if let Content::ToolCall(tc) = c {
6849                        // Streaming providers may expose a placeholder tool
6850                        // call before its name has arrived. It is not a real
6851                        // tool panel and must not leave an empty first row.
6852                        if tc.name.trim().is_empty() {
6853                            continue;
6854                        }
6855                        if tc.name == "bash" {
6856                            // Bash has a dedicated component. Create it here as
6857                            // well as on ToolExecutionStart because the tool
6858                            // call can become visible in a MessageUpdate first.
6859                            // Keeping it in the bash map lets Start coalesce
6860                            // with this panel instead of appending a second one.
6861                            let command = tc
6862                                .arguments
6863                                .get("command")
6864                                .and_then(|v| v.as_str())
6865                                .unwrap_or("");
6866                            // Streaming tool-call arguments may still be `{}`
6867                            // here. Do not create a running bash panel until
6868                            // the lifecycle start event provides the command;
6869                            // otherwise the spinner renders first and the
6870                            // actual `$ command` header appears one frame
6871                            // later.
6872                            if command.trim().is_empty() {
6873                                continue;
6874                            }
6875                            saw_bash_tool_call = true;
6876                            let mut bash = state.bash_components.lock().unwrap();
6877                            if let Some(existing) = bash.get(&tc.id) {
6878                                // Tool-call arguments arrive incrementally.
6879                                // Refresh the running panel from every full
6880                                // assistant snapshot instead of leaving its
6881                                // header on the first partial command.
6882                                existing.set_command(command);
6883                            } else {
6884                                let comp = Arc::new(BashExecutionComponent::new(command));
6885                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6886                                chat.add_child(comp.clone());
6887                                bash.insert(tc.id.clone(), comp);
6888                            }
6889                        } else {
6890                            let mut tools = state.tool_components.lock().unwrap();
6891                            let display_args = if is_ask_user_tool(&tc.name) {
6892                                ask_user_args_display(&tc.arguments)
6893                            } else {
6894                                tc.arguments.to_string()
6895                            };
6896                            if let Some(existing) = tools.get(&tc.id) {
6897                                // Regular tool arguments stream incrementally
6898                                // too, so refresh their live header as soon as
6899                                // a more complete snapshot arrives.
6900                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
6901                                    existing.set_args(&display_args);
6902                                }
6903                            } else {
6904                                let comp = Arc::new(ToolExecutionComponent::new(
6905                                    &tc.name,
6906                                    &display_args,
6907                                ));
6908                                if is_ask_user_tool(&tc.name) {
6909                                    comp.set_display_title("ASK USER");
6910                                }
6911                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6912                                comp.set_running();
6913                                chat.add_child(comp.clone());
6914                                tools.insert(tc.id.clone(), comp);
6915                            }
6916                        }
6917                    }
6918                }
6919                // MessageUpdate can expose the finalized bash call before
6920                // ToolExecutionStart arrives. Hide the global `Working…`
6921                // loader immediately when creating that bash panel; otherwise
6922                // it briefly appears alongside the panel's `Running…` spinner.
6923                if saw_bash_tool_call {
6924                    state.sync_working_loader_with_bash();
6925                }
6926                let _ = assistant_message_event; // snapshot already applied via `a`
6927                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6928                    // Stream the full block list (text + thinking) each update
6929                    // so thinking blocks render live as they arrive.
6930                    comp.update_blocks(&assistant_blocks(a));
6931                }
6932                *state.last_assistant_text.lock().unwrap() = text;
6933                tui.request_render(false);
6934            }
6935        }
6936
6937        AgentEvent::MessageEnd { message } => {
6938            if let AgentMessage::Assistant(a) = &message {
6939                let text = assistant_text(a);
6940                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6941                    comp.update_blocks(&assistant_blocks(a));
6942                    comp.set_streaming(false);
6943                }
6944                // Cache the finalized text for `/copy`.
6945                if !text.is_empty() {
6946                    *state.last_assistant_text.lock().unwrap() = text;
6947                }
6948                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
6949                // the previous turn's input established a cacheable prefix; a
6950                // large input this turn that read nothing from cache means the
6951                // prefix was re-billed. No cost display — v1 has no per-run
6952                // cost tracking here.
6953                let usage = &a.usage;
6954                let prev_input = *state.last_input_tokens.lock().unwrap();
6955                if prev_input > 0
6956                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6957                    && usage.cache_read == 0
6958                {
6959                    add_note_message(
6960                        &state.chat_container,
6961                        &format!(
6962                            "Cache miss: {} tokens re-billed",
6963                            format_tokens(usage.input)
6964                        ),
6965                    );
6966                }
6967                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6968                    add_note_message(chat, &text);
6969                }
6970                // Error assistant messages carry the provider diagnostic in
6971                // `error_message`, not in text content. The assistant
6972                // component is empty for these messages, so surface the
6973                // diagnostic as a visible error row in the transcript.
6974                if let Some(error) = assistant_error_text(a) {
6975                    add_error_message(chat, &error);
6976                }
6977                *state.last_input_tokens.lock().unwrap() = usage.input;
6978            }
6979            tui.request_render(false);
6980        }
6981
6982        AgentEvent::ToolExecutionStart {
6983            tool_call_id,
6984            tool_name,
6985            args,
6986        } => {
6987            // Ignore placeholder lifecycle events emitted before the
6988            // provider has supplied a tool name.
6989            if tool_name.trim().is_empty() {
6990                return;
6991            }
6992            if tool_name == "bash" {
6993                // Bash streams into a dedicated BashExecutionComponent (command
6994                // header + live preview + exit/truncation status) rather than a
6995                // generic ToolExecutionComponent. The command comes from the
6996                // `command` field of the bash tool args.
6997                let command = args
6998                    .get("command")
6999                    .and_then(|v| v.as_str())
7000                    .unwrap_or("")
7001                    .to_string();
7002                // Bash arguments can still be `{}` when the lifecycle event
7003                // races the streamed tool-call argument finalization. Defer
7004                // the panel until a later start event carries the command.
7005                if command.trim().is_empty() {
7006                    return;
7007                }
7008                let mut bash_map = state.bash_components.lock().unwrap();
7009                if let Some(existing) = bash_map.get(&tool_call_id) {
7010                    // A ToolExecutionUpdate already created the panel (fast
7011                    // command — Update can arrive before Start); backfill the
7012                    // command header instead of adding a SECOND panel, which
7013                    // used to stack an empty "$ " box above the real one.
7014                    existing.set_command(&command);
7015                } else {
7016                    let comp = Arc::new(BashExecutionComponent::new(command));
7017                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7018                    chat.add_child(comp.clone());
7019                    bash_map.insert(tool_call_id.clone(), comp);
7020                }
7021            } else {
7022                let _comp = {
7023                    let mut tools = state.tool_components.lock().unwrap();
7024                    if let Some(existing) = tools.get(&tool_call_id) {
7025                        if is_ask_user_tool(&tool_name) {
7026                            existing.set_display_title("ASK USER");
7027                            existing.set_args(&ask_user_args_display(&args));
7028                        } else {
7029                            existing.set_args(&args.to_string());
7030                        }
7031                        existing.clone()
7032                    } else {
7033                        let display_args = if is_ask_user_tool(&tool_name) {
7034                            ask_user_args_display(&args)
7035                        } else {
7036                            args.to_string()
7037                        };
7038                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7039                        if is_ask_user_tool(&tool_name) {
7040                            comp.set_display_title("ASK USER");
7041                        }
7042                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7043                        // A `read` of a SKILL.md renders as native Pi's
7044                        // `[skill] <name>` invocation box (custom-message
7045                        // background, collapsed to one line, Ctrl+O expands the
7046                        // skill markdown) instead of a generic READ tool panel.
7047                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
7048                            comp.set_skill_name(skill);
7049                        }
7050                        comp.set_running();
7051                        chat.add_child(comp.clone());
7052                        tools.insert(tool_call_id.clone(), comp.clone());
7053                        comp
7054                    }
7055                };
7056            }
7057            state.sync_working_loader_with_bash();
7058            tui.request_render(false);
7059        }
7060
7061        AgentEvent::ToolExecutionUpdate {
7062            tool_call_id,
7063            tool_name,
7064            args,
7065            partial_result,
7066        } => {
7067            if tool_name.trim().is_empty() {
7068                return;
7069            }
7070            let partial_text = if is_ask_user_tool(&tool_name) {
7071                ask_user_progress_text(&args, &partial_result)
7072            } else {
7073                tool_result_text(&partial_result)
7074            };
7075            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7076            if tool_name == "bash" {
7077                // Append the streamed chunk to the bash component's preview.
7078                // RAW text (no single-line collapsing) — the old
7079                // `summarize_tool_result` folded every newline into a `⏎`
7080                // glyph, cramming e.g. `ls -la`'s listing onto one line.
7081                let chunk = partial_text;
7082                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7083                    // Lifecycle updates can contain a more complete args object
7084                    // than the snapshot that created this running panel.
7085                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7086                        bash.set_command(command);
7087                    }
7088                    if has_partial_payload {
7089                        bash.append_output(&chunk);
7090                    }
7091                } else if has_partial_payload {
7092                    // ToolExecutionStart is emitted before a tool can run.
7093                    // Ignore an out-of-order partial until that event gives us
7094                    // the real command, rather than showing a spinner above an
7095                    // empty `$ ` header. Normal updates are handled by the
7096                    // component created in ToolExecutionStart.
7097                }
7098            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7099                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7100                    comp.set_skill_name(skill);
7101                }
7102                if is_ask_user_tool(&tool_name) {
7103                    comp.set_display_title("ASK USER");
7104                    comp.set_args(&ask_user_args_display(&args));
7105                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7106                    comp.set_args(&args.to_string());
7107                }
7108                // Raw multi-line text — read/ls-style tools must show their
7109                // full content, not the single-line ⏎-folded summary.
7110                if has_partial_payload {
7111                    comp.set_result(&partial_text, false);
7112                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7113                }
7114            } else if has_partial_payload {
7115                // No component yet — create a running one so the partial shows.
7116                // Empty callbacks are common before ToolExecutionStart; wait
7117                // for Start so the first panel has the real arguments instead
7118                // of an empty `TOOLS` box.
7119                let display_args = if is_ask_user_tool(&tool_name) {
7120                    ask_user_args_display(&args)
7121                } else {
7122                    args.to_string()
7123                };
7124                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7125                if is_ask_user_tool(&tool_name) {
7126                    comp.set_display_title("ASK USER");
7127                }
7128                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7129                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7130                    comp.set_skill_name(skill);
7131                }
7132                comp.set_running();
7133                comp.set_result(&partial_text, false);
7134                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7135                chat.add_child(comp.clone());
7136                state
7137                    .tool_components
7138                    .lock()
7139                    .unwrap()
7140                    .insert(tool_call_id.clone(), comp.clone());
7141            }
7142            state.sync_working_loader_with_bash();
7143            tui.request_render(false);
7144        }
7145
7146        AgentEvent::ToolExecutionEnd {
7147            tool_call_id,
7148            tool_name,
7149            result,
7150            is_error,
7151        } => {
7152            if tool_name.trim().is_empty() {
7153                return;
7154            }
7155            if tool_name == "bash" {
7156                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7157                if let Some(bash) = bash {
7158                    finalize_bash(&bash, &result, is_error);
7159                } else {
7160                    // Bash ended without a Start/Update — render a finalized
7161                    // component directly from the result text.
7162                    let command = result
7163                        .details
7164                        .get("command")
7165                        .and_then(|v| v.as_str())
7166                        .unwrap_or("")
7167                        .to_string();
7168                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7169                        state.sync_working_loader_with_bash();
7170                        // Edge case: empty bash result. Still render immediately
7171                        // to clear the panel state for the user.
7172                        tui.render_now(false);
7173                        return;
7174                    }
7175                    let comp = Arc::new(BashExecutionComponent::new(command));
7176                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7177                    comp.append_output(&tool_result_text(&result));
7178                    finalize_bash(&comp, &result, is_error);
7179                    chat.add_child(comp);
7180                }
7181            } else {
7182                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7183                if let Some(comp) = comp {
7184                    let result_text = if is_ask_user_tool(&tool_name) {
7185                        ask_user_result_text(&result)
7186                    } else {
7187                        tool_result_text(&result)
7188                    };
7189                    comp.set_result(&result_text, is_error);
7190                    if !is_ask_user_tool(&tool_name) {
7191                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7192                    }
7193                } else {
7194                    // Tool ended without a Start/Update (e.g. a very fast tool):
7195                    // render a finalized component directly.
7196                    let comp = Arc::new(ToolExecutionComponent::new(
7197                        &tool_name,
7198                        &if is_ask_user_tool(&tool_name) {
7199                            ask_user_args_display(&serde_json::Value::Null)
7200                        } else {
7201                            "".to_string()
7202                        },
7203                    ));
7204                    if is_ask_user_tool(&tool_name) {
7205                        comp.set_display_title("ASK USER");
7206                    }
7207                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7208                    let result_text = if is_ask_user_tool(&tool_name) {
7209                        ask_user_result_text(&result)
7210                    } else {
7211                        tool_result_text(&result)
7212                    };
7213                    comp.set_result(&result_text, is_error);
7214                    if !is_ask_user_tool(&tool_name) {
7215                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7216                    }
7217                    chat.add_child(comp.clone());
7218                }
7219            }
7220            state.sync_working_loader_with_bash();
7221            // Bash tool completion is user-visible: immediate render ensures the
7222            // result is displayed even when the scheduler thread is busy or the
7223            // throttle window has not elapsed. This single immediate frame does
7224            // not regress the streaming-output throttle that keeps CPU/GPU use
7225            // low (bash completions are discrete events, not per-token bursts).
7226            tui.render_now(false);
7227        }
7228    }
7229}
7230
7231/// Return the diagnostic carried by a failed or aborted provider request.
7232/// Providers may omit `error_message`; keep a stable fallback so a terminal
7233/// request failure can never render as an empty transcript turn.
7234fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7235    let fallback = match message.stop_reason {
7236        rpi_ai::StopReason::Error => "Provider request failed.",
7237        rpi_ai::StopReason::Aborted => "Request aborted.",
7238        _ => return None,
7239    };
7240    Some(
7241        message
7242            .error_message
7243            .as_deref()
7244            .filter(|text| !text.trim().is_empty())
7245            .unwrap_or(fallback)
7246            .to_string(),
7247    )
7248}
7249
7250/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
7251/// tool result and mark the component complete. Mirrors the TS bash finalize
7252/// path; only the fields `BashExecutionComponent` needs are read.
7253fn finalize_bash(
7254    comp: &Arc<BashExecutionComponent>,
7255    result: &rpi_agent::AgentToolResult,
7256    is_error: bool,
7257) {
7258    // The exit code isn't in details directly (TS carries it elsewhere); use
7259    // `is_error` as the error signal and 0/1 as a best-effort exit code.
7260    let exit_code = if is_error { Some(1) } else { Some(0) };
7261    let truncated = result
7262        .details
7263        .get("truncation")
7264        .and_then(|t| t.get("truncated"))
7265        .and_then(|v| v.as_bool())
7266        .unwrap_or(false);
7267    let full_output_path = result
7268        .details
7269        .get("full_output_path")
7270        .and_then(|v| v.as_str())
7271        .map(|s| s.to_string());
7272    let truncation = BashTruncation {
7273        truncated,
7274        full_output_path,
7275    };
7276    // Some tool backends attach the authoritative command to result details.
7277    // Backfill it before completing so this is a fallback, never the first
7278    // opportunity for the UI to show the whole command.
7279    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7280        comp.set_command(command);
7281    }
7282    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
7283    comp.set_complete(exit_code, cancelled, truncation);
7284}
7285
7286/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
7287/// display-diff string, render it with colors and attach to the component so
7288/// the changes show in the transcript. `write` has no diff (details: Null) and
7289/// stays a plain summary.
7290fn apply_edit_diff(
7291    comp: &Arc<ToolExecutionComponent>,
7292    tool_name: &str,
7293    details: &serde_json::Value,
7294    tui: &Arc<TuiAltScreen>,
7295) {
7296    if tool_name != "edit" {
7297        return;
7298    }
7299    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7300        return;
7301    };
7302    if diff_text.is_empty() {
7303        return;
7304    }
7305    let width = tui.width();
7306    let lines = render_diff(diff_text, width);
7307    comp.set_diff(lines);
7308}
7309
7310/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
7311/// `None`. The name is the `SKILL.md` parent directory's basename (matching
7312/// native Pi's skill-file convention). Ordinary markdown/document reads
7313/// return `None` and remain regular `READ` tool panels.
7314fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7315    if tool_name != "read" {
7316        return None;
7317    }
7318    let path = args.get("path").and_then(|value| value.as_str())?;
7319    let normalized = path.replace('\\', "/");
7320    let file_name = normalized.rsplit('/').next()?;
7321    if !file_name.eq_ignore_ascii_case("SKILL.md") {
7322        return None;
7323    }
7324    normalized
7325        .trim_end_matches('/')
7326        .rsplit('/')
7327        .nth(1)
7328        .filter(|name| !name.is_empty())
7329        .map(str::to_string)
7330}
7331
7332/// Render an `AgentToolResult` as a single-line summary for the
7333/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
7334fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7335    use rpi_agent::TextContentOrImage;
7336    let mut parts: Vec<String> = Vec::new();
7337    for c in &result.content {
7338        if let TextContentOrImage::Text(t) = c {
7339            parts.push(t.text.clone());
7340        }
7341    }
7342    let joined = parts.join("\n");
7343    // Keep the tool line compact: collapse to a single line, trim length.
7344    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7345    if one_line.chars().count() > 200 {
7346        let truncated: String = one_line.chars().take(200).collect();
7347        format!("{truncated}…")
7348    } else {
7349        one_line
7350    }
7351}
7352
7353/// The raw multi-line text of a tool result (no single-line collapsing). The
7354/// bash panel needs the original line structure — the old path fed it through
7355/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
7356/// crammed e.g. `ls -la`'s whole listing onto one line.
7357fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7358    use rpi_agent::TextContentOrImage;
7359    let mut parts: Vec<String> = Vec::new();
7360    for c in &result.content {
7361        if let TextContentOrImage::Text(t) = c {
7362            parts.push(t.text.clone());
7363        }
7364    }
7365    parts.join("\n")
7366}
7367
7368/// Empty progress callbacks are valid (notably before a tool's start event),
7369/// but they do not contain anything useful to render. Defer those callbacks so
7370/// the first tool panel is created from `ToolExecutionStart` with real args.
7371fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7372    !text.trim().is_empty() || !result.details.is_null()
7373}
7374
7375/// Tool names rendered through the dedicated `ASK USER` panel instead of a
7376/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
7377fn is_ask_user_tool(tool_name: &str) -> bool {
7378    matches!(tool_name, "ask_user" | "ask_user_question")
7379}
7380
7381/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
7382/// transport JSON must never leak into the transcript.
7383fn ask_user_args_display(args: &serde_json::Value) -> String {
7384    if args.is_null() {
7385        return String::new();
7386    }
7387    if let Some(summary) = args
7388        .get("summary")
7389        .and_then(serde_json::Value::as_str)
7390        .map(str::trim)
7391        .filter(|text| !text.is_empty())
7392    {
7393        return format!("Confirmation requested: {summary}");
7394    }
7395
7396    let mut lines: Vec<String> = Vec::new();
7397    let questions: Vec<&serde_json::Value> = args
7398        .get("questions")
7399        .and_then(serde_json::Value::as_array)
7400        .filter(|items| !items.is_empty())
7401        .map(|items| items.iter().collect())
7402        .unwrap_or_else(|| vec![args]);
7403
7404    for question in questions {
7405        let prompt = question
7406            .get("question")
7407            .and_then(serde_json::Value::as_str)
7408            .or_else(|| {
7409                args.get("question")
7410                    .and_then(serde_json::Value::as_str)
7411            })
7412            .map(str::trim)
7413            .unwrap_or_default();
7414        if prompt.is_empty() {
7415            continue;
7416        }
7417        if let Some(header) = question
7418            .get("header")
7419            .and_then(serde_json::Value::as_str)
7420            .map(str::trim)
7421            .filter(|text| !text.is_empty())
7422        {
7423            lines.push(format!("{header}: {prompt}"));
7424        } else {
7425            lines.push(prompt.to_string());
7426        }
7427        let context = question
7428            .get("context")
7429            .and_then(serde_json::Value::as_str)
7430            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7431            .map(str::trim)
7432            .filter(|text| !text.is_empty());
7433        if let Some(context) = context {
7434            lines.push(format!("  {context}"));
7435        }
7436        let options = question
7437            .get("options")
7438            .and_then(serde_json::Value::as_array)
7439            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7440        if let Some(options) = options {
7441            let labels: Vec<String> = options
7442                .iter()
7443                .filter_map(|option| {
7444                    option
7445                        .get("title")
7446                        .and_then(serde_json::Value::as_str)
7447                        .or_else(|| option.as_str())
7448                        .map(str::trim)
7449                        .filter(|label| !label.is_empty())
7450                        .map(str::to_string)
7451                })
7452                .collect();
7453            if !labels.is_empty() {
7454                lines.push(format!("Choices: {}", labels.join(", ")));
7455            }
7456        }
7457        if let Some(suggest) = question
7458            .get("suggest")
7459            .and_then(serde_json::Value::as_str)
7460            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7461            .map(str::trim)
7462            .filter(|text| !text.is_empty())
7463        {
7464            lines.push(format!("Suggestions: {suggest}"));
7465        }
7466    }
7467
7468    lines.join("\n")
7469}
7470
7471/// Progress text while an ask_user tool is pending. Prefer the plugin's own
7472/// readable `content`, otherwise show the question so the panel is not blank.
7473fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7474    let text = tool_result_text(result);
7475    if !text.trim().is_empty() {
7476        return text;
7477    }
7478    let args_text = ask_user_args_display(args);
7479    if args_text.trim().is_empty() {
7480        "Waiting for your answer…".to_string()
7481    } else {
7482        args_text
7483    }
7484}
7485
7486/// Final result text for an ask_user tool. The plugin returns a human-readable
7487/// answer summary in `content`; never surface the transport JSON.
7488fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7489    let text = tool_result_text(result);
7490    if text.trim().is_empty() {
7491        "Answer recorded.".to_string()
7492    } else {
7493        text
7494    }
7495}
7496
7497// ===========================================================================
7498// Selectors — editor-container swap (TS showSelector pattern)
7499// ===========================================================================
7500
7501/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
7502/// hiding the editor while the selector is open. Records the selector in
7503/// `state.active_selector` so the key loop routes to it.
7504fn open_selector(
7505    state: &Arc<TuiState>,
7506    editor_container: &Arc<Container>,
7507    editor: &Arc<Editor>,
7508    tui: &Arc<TuiAltScreen>,
7509    list: Arc<SelectList>,
7510    kind: SelectorKind,
7511) {
7512    open_selector_with_view(
7513        state,
7514        editor_container,
7515        editor,
7516        tui,
7517        list.clone(),
7518        list,
7519        kind,
7520    );
7521}
7522
7523/// Open a selector with an optional framed view. Native extension selectors
7524/// wrap the list with a title and hint while built-in selectors keep the list
7525/// as the complete view.
7526fn open_selector_with_view<C: Component + 'static>(
7527    state: &Arc<TuiState>,
7528    editor_container: &Arc<Container>,
7529    editor: &Arc<Editor>,
7530    tui: &Arc<TuiAltScreen>,
7531    list: Arc<SelectList>,
7532    view: Arc<C>,
7533    kind: SelectorKind,
7534) {
7535    // Unfocus the editor so its cursor marker doesn't render behind the list.
7536    editor.set_focused(false);
7537    // A selector replaces the editor slot. Drop stale slash/@file
7538    // suggestions so they cannot reappear after the selector closes.
7539    state.autocomplete_container.clear();
7540    // Swap: clear the container and add the selector view.
7541    editor_container.clear();
7542    editor_container.add_child(view.clone());
7543    *state.active_selector.lock().unwrap() = Some((list, kind));
7544    let focused: Arc<dyn Component> = view;
7545    tui.set_focus(Some(focused));
7546    tui.request_render(false);
7547}
7548
7549/// Restore the editor into the `editor_container` and clear the active
7550/// selector. Called by selector `on_cancel` and the Esc handler.
7551fn close_selector(
7552    state: &Arc<TuiState>,
7553    editor_container: &Arc<Container>,
7554    editor: &Arc<Editor>,
7555    tui: &Arc<TuiAltScreen>,
7556) {
7557    editor_container.clear();
7558    editor_container.add_child(editor.clone());
7559    state.autocomplete_container.clear();
7560    editor.set_focused(true);
7561    *state.active_selector.lock().unwrap() = None;
7562    *state.active_extension_cancel.lock().unwrap() = None;
7563    tui.set_focus(Some(editor.clone()));
7564    tui.request_render(false);
7565}
7566
7567/// Build + open the `/model` selector. Items are the resolved catalog (display
7568/// label = model name; description = id), with the current model marked.
7569/// Selecting applies the model **live** via `lane.set_model` (takes effect on
7570/// the next user message — the in-flight run's config is already snapshotted),
7571/// updates the footer, and notes the next-prompt effect.
7572fn open_model_selector(
7573    state: &Arc<TuiState>,
7574    editor_container: &Arc<Container>,
7575    editor: &Arc<Editor>,
7576    tui: &Arc<TuiAltScreen>,
7577    catalog: &[rpi_ai::Model],
7578    lane: &Arc<dyn AgentLane>,
7579    lane_model_id: &str,
7580    chat: &Arc<Container>,
7581) {
7582    let items = model_selector_items(catalog, lane_model_id);
7583    if items.is_empty() {
7584        add_note_message(
7585            chat,
7586            "No models in the catalog. Use --model at startup to select one.",
7587        );
7588        tui.request_render(false);
7589        return;
7590    }
7591    let list = Arc::new(SelectList::new(items, 10));
7592
7593    // Capture the catalog + lane so the on_select closure can resolve the
7594    // chosen Model and apply it. `on_select` fires on the blocking key thread,
7595    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
7596    let catalog_arc = catalog.to_vec();
7597    let state_sel = state.clone();
7598    let ec_sel = editor_container.clone();
7599    let editor_sel = editor.clone();
7600    let tui_sel = tui.clone();
7601    let chat_sel = chat.clone();
7602    let lane_sel = lane.clone();
7603    list.on_select(Arc::new(move |item| {
7604        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7605            add_note_message(
7606                &chat_sel,
7607                &format!("Model {} not found in catalog.", item.label),
7608            );
7609            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7610            return;
7611        };
7612        state_sel.set_current_model(&model);
7613        let lane = lane_sel.clone();
7614        tokio::spawn(async move {
7615            let _ = lane.set_model(model).await;
7616        });
7617        add_note_message(
7618            &chat_sel,
7619            &format!(
7620                "Model set to {} — applies to the next message.",
7621                short_model_name(&item.value)
7622            ),
7623        );
7624        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7625    }));
7626    let state_cancel = state.clone();
7627    let ec_cancel = editor_container.clone();
7628    let editor_cancel = editor.clone();
7629    let tui_cancel = tui.clone();
7630    list.on_cancel(Arc::new(move || {
7631        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7632    }));
7633
7634    open_selector(
7635        state,
7636        editor_container,
7637        editor,
7638        tui,
7639        list,
7640        SelectorKind::Model,
7641    );
7642}
7643
7644/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
7645/// Returns `None` only when the catalog is empty or the current id isn't
7646/// found (in which case the first entry is returned — a no-op if it IS the
7647/// current). Used by the Ctrl+M model-cycle hotkey.
7648fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
7649    if catalog.is_empty() {
7650        return None;
7651    }
7652    let idx = catalog
7653        .iter()
7654        .position(|m| m.id.eq_ignore_ascii_case(current_id));
7655    match idx {
7656        Some(i) => {
7657            let next = (i + 1) % catalog.len();
7658            Some(catalog[next].clone())
7659        }
7660        None => Some(catalog[0].clone()),
7661    }
7662}
7663
7664/// Build + open the `/session` selector. Lists JSONL session files under the
7665/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
7666/// fallback). Selecting reports "restore not
7667/// implemented in v1" (existing constraint) but shows the list for
7668/// discoverability.
7669fn open_session_selector(
7670    state: &Arc<TuiState>,
7671    editor_container: &Arc<Container>,
7672    editor: &Arc<Editor>,
7673    tui: &Arc<TuiAltScreen>,
7674    cwd: &std::path::Path,
7675    tx: &mpsc::UnboundedSender<TuiMessage>,
7676) {
7677    let dir = crate::session::default_session_dir(cwd);
7678    let mut items: Vec<SelectItem> = Vec::new();
7679    if let Ok(entries) = std::fs::read_dir(&dir) {
7680        for entry in entries.flatten() {
7681            let path = entry.path();
7682            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7683                continue;
7684            }
7685            let stem = path
7686                .file_stem()
7687                .and_then(|s| s.to_str())
7688                .unwrap_or("(unnamed)")
7689                .to_string();
7690            let display = path
7691                .file_name()
7692                .and_then(|s| s.to_str())
7693                .unwrap_or(&stem)
7694                .to_string();
7695            items.push(SelectItem::new(&stem, &display));
7696        }
7697    }
7698    if items.is_empty() {
7699        add_note_message(
7700            &state.chat_container,
7701            "No saved sessions found. Sessions are created automatically in interactive mode.",
7702        );
7703        tui.request_render(false);
7704        return;
7705    }
7706    let list = Arc::new(SelectList::new(items, 10));
7707
7708    let state_sel = state.clone();
7709    let ec_sel = editor_container.clone();
7710    let editor_sel = editor.clone();
7711    let tui_sel = tui.clone();
7712    let tx_sel = tx.clone();
7713    list.on_select(Arc::new(move |item| {
7714        // Close the selector first, then ask the async loop to hot-switch:
7715        // opening the session file + swapping the harness backing is async
7716        // (repo list/open) and must not run on the blocking key thread.
7717        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7718        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
7719    }));
7720    let state_cancel = state.clone();
7721    let ec_cancel = editor_container.clone();
7722    let editor_cancel = editor.clone();
7723    let tui_cancel = tui.clone();
7724    list.on_cancel(Arc::new(move || {
7725        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7726    }));
7727
7728    open_selector(
7729        state,
7730        editor_container,
7731        editor,
7732        tui,
7733        list,
7734        SelectorKind::Session,
7735    );
7736}
7737
7738fn custom_entry_display_text(
7739    custom_type: &str,
7740    data: Option<&serde_json::Value>,
7741) -> Option<String> {
7742    let data = data?;
7743    let text = data
7744        .get("summary")
7745        .or_else(|| data.get("text"))
7746        .or_else(|| data.get("output"))
7747        .and_then(|value| value.as_str())
7748        .filter(|value| !value.trim().is_empty())?;
7749    let label = match custom_type {
7750        "compactionSummary" => "Compaction summary",
7751        "branchSummary" => "Branch summary",
7752        "bashExecution" => "Command output",
7753        other => other,
7754    };
7755    Some(format!("{label}: {text}"))
7756}
7757
7758/// Open a selector for the current session's persisted entry tree. Selecting a
7759/// message moves the main lane leaf to that entry, then the caller reloads the
7760/// visible branch from durable storage.
7761async fn open_tree_selector(
7762    harness: &AgentHarness,
7763    state: &Arc<TuiState>,
7764    editor_container: &Arc<Container>,
7765    editor: &Arc<Editor>,
7766    tui: &Arc<TuiAltScreen>,
7767    chat: &Arc<Container>,
7768    tx: &mpsc::UnboundedSender<TuiMessage>,
7769) {
7770    let entries = match harness
7771        .session()
7772        .view("main")
7773        .find_entries(&EntryQuery {
7774            order: Some(EntryOrder::OldestFirst),
7775            ..Default::default()
7776        })
7777        .await
7778    {
7779        Ok(entries) => entries,
7780        Err(error) => {
7781            add_error_message(chat, &format!("Could not read session tree: {error}"));
7782            tui.request_render(false);
7783            return;
7784        }
7785    };
7786    let current = harness.session().get_leaf_id().await.ok().flatten();
7787    let items: Vec<SelectItem> = entries
7788        .iter()
7789        .map(|entry| {
7790            let marker = if current.as_deref() == Some(entry.id()) {
7791                " (current)"
7792            } else {
7793                ""
7794            };
7795            SelectItem::new(
7796                entry.id(),
7797                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7798            )
7799            .with_description(&entry.id()[..entry.id().len().min(12)])
7800        })
7801        .collect();
7802    if items.is_empty() {
7803        add_note_message(chat, "The current session has no entries to navigate.");
7804        tui.request_render(false);
7805        return;
7806    }
7807    let list = Arc::new(SelectList::new(items, 12));
7808    let state_sel = state.clone();
7809    let ec_sel = editor_container.clone();
7810    let editor_sel = editor.clone();
7811    let tui_sel = tui.clone();
7812    let tx_sel = tx.clone();
7813    list.on_select(Arc::new(move |item| {
7814        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7815        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7816    }));
7817    let state_cancel = state.clone();
7818    let ec_cancel = editor_container.clone();
7819    let editor_cancel = editor.clone();
7820    let tui_cancel = tui.clone();
7821    list.on_cancel(Arc::new(move || {
7822        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7823    }));
7824    open_selector(
7825        state,
7826        editor_container,
7827        editor,
7828        tui,
7829        list,
7830        SelectorKind::Tree,
7831    );
7832}
7833
7834/// Build + open the `/theme` selector. Built-in presets and enabled package
7835/// themes are shown; selecting applies the theme live and re-renders.
7836fn open_theme_selector(
7837    state: &Arc<TuiState>,
7838    editor_container: &Arc<Container>,
7839    editor: &Arc<Editor>,
7840    tui: &Arc<TuiAltScreen>,
7841    cwd: &std::path::Path,
7842    package_resources: &Arc<crate::packages::PackageResources>,
7843) {
7844    let mut items = vec![
7845        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7846        SelectItem::new("light", "Light").with_description("Light background"),
7847        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7848    ];
7849    if state.themes_enabled {
7850        for path in package_resources.theme_files() {
7851            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7852                items.push(SelectItem::new(name, name).with_description("Package theme"));
7853            }
7854        }
7855    }
7856    let list = Arc::new(SelectList::new(items, 10));
7857
7858    let state_sel = state.clone();
7859    let ec_sel = editor_container.clone();
7860    let editor_sel = editor.clone();
7861    let tui_sel = tui.clone();
7862    let chat_sel = state.chat_container.clone();
7863    let cwd_sel = cwd.to_path_buf();
7864    let package_resources_sel = package_resources.clone();
7865    list.on_select(Arc::new(move |item| {
7866        let preset = match item.value.as_str() {
7867            "light" => Some(ThemePreset::Light),
7868            "monochrome" => Some(ThemePreset::Monochrome),
7869            "dark" => Some(ThemePreset::Dark),
7870            name => {
7871                if state_sel.themes_enabled {
7872                    if let Ok(custom) = crate::packages::load_theme_with_resources(
7873                        &cwd_sel,
7874                        name,
7875                        &package_resources_sel,
7876                    ) {
7877                        rpi_tui::global_theme_manager().set(custom.clone());
7878                        state_sel.theme_manager.set(custom);
7879                    }
7880                }
7881                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7882                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7883                tui_sel.render_now(true);
7884                return;
7885            }
7886        };
7887        let Some(preset) = preset else { return };
7888        apply_theme_preset(preset);
7889        state_sel.theme_manager.apply_preset(preset);
7890        // A quick accent note so the user sees the change registered even if
7891        // the terminal's own colors mask the preset difference.
7892        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7893        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7894        tui_sel.render_now(true);
7895    }));
7896    let state_cancel = state.clone();
7897    let ec_cancel = editor_container.clone();
7898    let editor_cancel = editor.clone();
7899    let tui_cancel = tui.clone();
7900    list.on_cancel(Arc::new(move || {
7901        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7902    }));
7903
7904    open_selector(
7905        state,
7906        editor_container,
7907        editor,
7908        tui,
7909        list,
7910        SelectorKind::Theme,
7911    );
7912}
7913
7914// ===========================================================================
7915// Feasible selectors — /thinking, /tools, /images
7916// ===========================================================================
7917
7918/// One-line descriptions for each thinking level, ported from
7919/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
7920fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7921    use rpi_ai::types::ThinkingLevel::*;
7922    match level {
7923        Off => "Off — No reasoning",
7924        Minimal => "Minimal — Brief reasoning (~1k tokens)",
7925        Low => "Low — Light reasoning (~1k tokens)",
7926        Medium => "Medium — Moderate reasoning (~80% of max)",
7927        High => "High — Extensive reasoning (~95% of max)",
7928        Xhigh => "Xhigh — Near-maximal reasoning",
7929        Max => "Max — Maximum reasoning",
7930    }
7931}
7932
7933/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
7934/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
7935fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7936    use rpi_ai::types::ThinkingLevel::*;
7937    match level {
7938        Off => "off",
7939        Minimal => "minimal",
7940        Low => "low",
7941        Medium => "medium",
7942        High => "high",
7943        Xhigh => "xhigh",
7944        Max => "max",
7945    }
7946}
7947
7948/// Parse a thinking-level name back to the enum (case-insensitive). Returns
7949/// `None` for an unknown name; used by the `/thinking` selector callback.
7950fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7951    use rpi_ai::types::ThinkingLevel::*;
7952    match name.to_ascii_lowercase().as_str() {
7953        "off" => Some(Off),
7954        "minimal" => Some(Minimal),
7955        "low" => Some(Low),
7956        "medium" => Some(Medium),
7957        "high" => Some(High),
7958        "xhigh" => Some(Xhigh),
7959        "max" => Some(Max),
7960        _ => None,
7961    }
7962}
7963
7964/// Build + open the `/thinking` selector. Items are the levels the current
7965/// model supports (`Model::supported_thinking_levels`), each with a
7966/// description; the current level (read beforehand via `lane.get_thinking_level`)
7967/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
7968///
7969/// `on_select` fires on the blocking key thread, so it can't await
7970/// `lane.get_thinking_level()` to know the current level — the opener resolves
7971/// it first (best-effort) and preselects; the toggle on_select just applies
7972/// whatever was picked.
7973fn open_thinking_selector(
7974    state: &Arc<TuiState>,
7975    editor_container: &Arc<Container>,
7976    editor: &Arc<Editor>,
7977    tui: &Arc<TuiAltScreen>,
7978    lane: &Arc<dyn AgentLane>,
7979    catalog: &[rpi_ai::Model],
7980    lane_model_id: &str,
7981    chat: &Arc<Container>,
7982) {
7983    // Find the current model in the catalog to read its supported levels. If
7984    // absent, fall back to all levels so the selector still opens.
7985    let model = catalog
7986        .iter()
7987        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7988    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7989        .map(|m| m.supported_thinking_levels())
7990        .unwrap_or_else(|| {
7991            use rpi_ai::types::ThinkingLevel::*;
7992            vec![Off, Minimal, Low, Medium, High]
7993        });
7994    let mut items: Vec<SelectItem> = Vec::new();
7995    for lvl in &levels {
7996        let name = thinking_level_name(*lvl);
7997        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7998    }
7999    if items.is_empty() {
8000        add_note_message(chat, "This model has no supported thinking levels.");
8001        tui.request_render(false);
8002        return;
8003    }
8004    let list = Arc::new(SelectList::new(items, 10));
8005
8006    let state_sel = state.clone();
8007    let ec_sel = editor_container.clone();
8008    let editor_sel = editor.clone();
8009    let tui_sel = tui.clone();
8010    let chat_sel = chat.clone();
8011    let lane_sel = lane.clone();
8012    list.on_select(Arc::new(move |item| {
8013        let Some(level) = thinking_level_from_name(&item.value) else {
8014            add_note_message(
8015                &chat_sel,
8016                &format!("Unknown thinking level: {}.", item.label),
8017            );
8018            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8019            return;
8020        };
8021        let lane = lane_sel.clone();
8022        let footer_sel = state_sel.footer.clone();
8023        tokio::spawn(async move {
8024            let _ = lane.set_thinking_level(level).await;
8025        });
8026        // Reflect the chosen level in the footer's model suffix (pi parity:
8027        // `model • thinking off` / `model • medium`). The shown text for the
8028        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
8029        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8030        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8031        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8032    }));
8033    let state_cancel = state.clone();
8034    let ec_cancel = editor_container.clone();
8035    let editor_cancel = editor.clone();
8036    let tui_cancel = tui.clone();
8037    list.on_cancel(Arc::new(move || {
8038        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8039    }));
8040
8041    open_selector(
8042        state,
8043        editor_container,
8044        editor,
8045        tui,
8046        list,
8047        SelectorKind::Thinking,
8048    );
8049}
8050
8051/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
8052/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
8053/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
8054/// — the blocking key thread can't await) and selecting a tool **toggles** it
8055/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
8056fn open_tools_selector(
8057    state: &Arc<TuiState>,
8058    editor_container: &Arc<Container>,
8059    editor: &Arc<Editor>,
8060    tui: &Arc<TuiAltScreen>,
8061    lane: &Arc<dyn AgentLane>,
8062    chat: &Arc<Container>,
8063) {
8064    // Best-effort read of the current active set. The opener runs on the async
8065    // runtime (it's called from the main loop's channel dispatch or the submit
8066    // closure that lives on the blocking thread — but `handle.block_on` is safe
8067    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
8068    let mut active = match tokio::runtime::Handle::try_current() {
8069        Ok(h) => h
8070            .block_on(async { lane.get_active_tools().await })
8071            .unwrap_or_default(),
8072        Err(_) => Vec::new(),
8073    };
8074    // An empty active set is the harness sentinel for "all registered tools"
8075    // (the selector only exposes built-ins). Expand it before rendering and
8076    // toggling so the first `/tools` visit does not show every tool as off or
8077    // accidentally reduce the active set to the one item selected.
8078    if active.is_empty() {
8079        active = crate::session::BUILTIN_TOOL_NAMES
8080            .iter()
8081            .map(|name| (*name).to_string())
8082            .collect();
8083    }
8084    let mut items: Vec<SelectItem> = Vec::new();
8085    for name in crate::session::BUILTIN_TOOL_NAMES {
8086        let on = active.iter().any(|a| a == name);
8087        let label = if on {
8088            format!("{name} (on)")
8089        } else {
8090            (*name).to_string()
8091        };
8092        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8093    }
8094    let list = Arc::new(SelectList::new(items, 10));
8095
8096    // Capture the active set so on_select can toggle without re-reading.
8097    let active_captured = active.clone();
8098    let state_sel = state.clone();
8099    let ec_sel = editor_container.clone();
8100    let editor_sel = editor.clone();
8101    let tui_sel = tui.clone();
8102    let chat_sel = chat.clone();
8103    let lane_sel = lane.clone();
8104    list.on_select(Arc::new(move |item| {
8105        let mut next = active_captured.clone();
8106        if let Some(pos) = next.iter().position(|a| a == &item.value) {
8107            next.remove(pos);
8108        } else {
8109            next.push(item.value.clone());
8110        }
8111        let on = next.iter().any(|a| a == &item.value);
8112        let lane = lane_sel.clone();
8113        let next_clone = next.clone();
8114        tokio::spawn(async move {
8115            let _ = lane.set_active_tools(next_clone).await;
8116        });
8117        let list_str = if next.is_empty() {
8118            "(none)".to_string()
8119        } else {
8120            next.join(", ")
8121        };
8122        add_note_message(
8123            &chat_sel,
8124            &format!(
8125                "{} {} — active tools: {}",
8126                item.value,
8127                if on { "enabled" } else { "disabled" },
8128                list_str
8129            ),
8130        );
8131        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8132    }));
8133    let state_cancel = state.clone();
8134    let ec_cancel = editor_container.clone();
8135    let editor_cancel = editor.clone();
8136    let tui_cancel = tui.clone();
8137    list.on_cancel(Arc::new(move || {
8138        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8139    }));
8140
8141    open_selector(
8142        state,
8143        editor_container,
8144        editor,
8145        tui,
8146        list,
8147        SelectorKind::Tools,
8148    );
8149}
8150
8151/// Build + open the `/images` selector (Yes/No). Stores the choice in
8152/// `state.show_images` and notes it. Image wiring is minimal this pass — the
8153/// flag is consulted where images would be shown and echoed back here.
8154fn open_images_selector(
8155    state: &Arc<TuiState>,
8156    editor_container: &Arc<Container>,
8157    editor: &Arc<Editor>,
8158    tui: &Arc<TuiAltScreen>,
8159    chat: &Arc<Container>,
8160) {
8161    let current = *state.show_images.lock().unwrap();
8162    let items = vec![
8163        SelectItem::new("yes", "Yes").with_description(if current {
8164            "Inline images (current)"
8165        } else {
8166            "Inline images"
8167        }),
8168        SelectItem::new("no", "No").with_description(if current {
8169            "Placeholder only"
8170        } else {
8171            "Placeholder only (current)"
8172        }),
8173    ];
8174    let list = Arc::new(SelectList::new(items, 5));
8175
8176    let state_sel = state.clone();
8177    let ec_sel = editor_container.clone();
8178    let editor_sel = editor.clone();
8179    let tui_sel = tui.clone();
8180    let chat_sel = chat.clone();
8181    list.on_select(Arc::new(move |item| {
8182        let on = item.value == "yes";
8183        *state_sel.show_images.lock().unwrap() = on;
8184        add_note_message(
8185            &chat_sel,
8186            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8187        );
8188        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8189    }));
8190    let state_cancel = state.clone();
8191    let ec_cancel = editor_container.clone();
8192    let editor_cancel = editor.clone();
8193    let tui_cancel = tui.clone();
8194    list.on_cancel(Arc::new(move || {
8195        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8196    }));
8197
8198    open_selector(
8199        state,
8200        editor_container,
8201        editor,
8202        tui,
8203        list,
8204        SelectorKind::Images,
8205    );
8206}
8207
8208// ===========================================================================
8209// Autocomplete
8210// ===========================================================================
8211
8212/// Refresh the autocomplete suggestion list from the current editor text +
8213/// cursor. Renders the suggestions into `autocomplete_container` (above the
8214/// editor) or clears it when there are none.
8215fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8216    let text = editor.get_text();
8217    let cursor = editor_cursor_offset(editor, &text);
8218    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8219    render_autocomplete(state, suggestions);
8220}
8221
8222/// Convert the editor's logical `(row, byte-column)` caret into the absolute
8223/// byte offset expected by autocomplete providers.
8224fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8225    let (row, col) = editor.cursor_position();
8226    let mut offset = 0;
8227    for (index, line) in text.split('\n').enumerate() {
8228        if index == row {
8229            return (offset + col.min(line.len())).min(text.len());
8230        }
8231        offset = offset.saturating_add(line.len() + 1);
8232    }
8233    text.len()
8234}
8235
8236/// Restore an editor caret from an absolute byte offset after autocomplete
8237/// replaces a span in a multi-line draft.
8238fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8239    let offset = offset.min(text.len());
8240    let mut consumed = 0;
8241    for (row, line) in text.split('\n').enumerate() {
8242        let end = consumed + line.len();
8243        if offset <= end {
8244            editor.set_cursor(row, offset - consumed);
8245            return;
8246        }
8247        consumed = end + 1;
8248    }
8249    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8250    editor.set_cursor(
8251        last_row,
8252        text.rsplit('\n').next().map(str::len).unwrap_or(0),
8253    );
8254}
8255
8256/// Render (or clear) the autocomplete suggestion list into the container.
8257fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8258    state.autocomplete_container.clear();
8259    let Some(sugg) = suggestions else {
8260        return;
8261    };
8262    if sugg.items.is_empty() {
8263        return;
8264    }
8265    // Build a compact list: top item marked with `→`, rest with `  `.
8266    // Cap the list so the dock doesn't swallow the transcript.
8267    let accent = state.theme_manager.get().colors.accent;
8268    let muted = state.theme_manager.get().colors.muted;
8269    for (i, item) in sugg
8270        .items
8271        .iter()
8272        .take(state.autocomplete_max_visible)
8273        .enumerate()
8274    {
8275        let prefix = if i == 0 { "→ " } else { "  " };
8276        let label = item.display_text();
8277        let line = if i == 0 {
8278            format!(
8279                "{prefix}{} {}",
8280                accent.fg(label),
8281                muted.fg(item.description.as_deref().unwrap_or(""))
8282            )
8283        } else {
8284            format!(
8285                "{prefix}{} {}",
8286                muted.fg(label),
8287                muted.fg(item.description.as_deref().unwrap_or(""))
8288            )
8289        };
8290        state
8291            .autocomplete_container
8292            .add_child(Arc::new(Text::new(line, 1, 0)));
8293    }
8294}
8295
8296/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
8297/// suggestion text, reposition the caret, and clear the suggestion list.
8298/// Returns `true` if a suggestion was accepted.
8299fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8300    let text = editor.get_text();
8301    let cursor = editor_cursor_offset(editor, &text);
8302    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8303        return false;
8304    };
8305    let Some(top) = sugg.items.first() else {
8306        return false;
8307    };
8308    // Replace the [start, end) span with the suggestion text. `start`/`end`
8309    // are byte offsets emitted by the providers on char boundaries, so the
8310    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
8311    let start = sugg.start.min(text.len());
8312    let end = sugg.end.min(text.len());
8313    let mut replaced = String::with_capacity(text.len() + top.text.len());
8314    replaced.push_str(&text[..start]);
8315    replaced.push_str(&top.text);
8316    // Keep the text AFTER the replaced span (mid-line completion: replacing
8317    // `[start, end)` must not drop the rest of the line).
8318    replaced.push_str(&text[end..]);
8319    if top.insert_space && !replaced.ends_with('/') {
8320        replaced.push(' ');
8321    }
8322    // New caret position: after the inserted text (byte offset; the editor
8323    // snaps `set_cursor` to a char boundary as a safety net).
8324    let new_cursor = replaced.len().min(
8325        start
8326            + top.text.len()
8327            + if top.insert_space && !top.text.ends_with('/') {
8328                1
8329            } else {
8330                0
8331            },
8332    );
8333    editor.set_text(&replaced);
8334    set_editor_cursor_offset(editor, &replaced, new_cursor);
8335    state.autocomplete_container.clear();
8336    true
8337}
8338
8339// ===========================================================================
8340// Transcript message helpers
8341// ===========================================================================
8342
8343/// Add the welcome header to the chat container.
8344fn add_welcome_message(container: &Arc<Container>) {
8345    add_welcome_message_with_capabilities(container, &[], &[]);
8346}
8347
8348/// Add the startup welcome header and a compact snapshot of active tools and
8349/// discovered skills. The snapshot reflects the harness configuration used by
8350/// the first turn, including tools contributed by extensions.
8351fn add_welcome_message_with_capabilities(
8352    container: &Arc<Container>,
8353    active_tools: &[String],
8354    skills: &[String],
8355) {
8356    let c = current_theme().colors;
8357    // Accent logotype + a dim tagline, separated from the rest by a thin
8358    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
8359    // to the body text, so the header didn't read as a header.
8360    let title = format!(
8361        "{} {}",
8362        c.accent.fg(&tui_bold("rpi")),
8363        c.muted.fg("interactive TUI")
8364    );
8365    container.add_child(Arc::new(Text::new(title, 1, 0)));
8366    container.add_child(Arc::new(Spacer::new(1)));
8367    container.add_child(Arc::new(Text::new(
8368        c.dim.fg("Type your message and press Enter to send."),
8369        1,
8370        0,
8371    )));
8372    let hint = c
8373        .dim
8374        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8375    container.add_child(Arc::new(Text::new(hint, 1, 0)));
8376    container.add_child(Arc::new(Spacer::new(1)));
8377    container.add_child(Arc::new(Text::new(
8378        welcome_capability_line("Tools", active_tools),
8379        1,
8380        0,
8381    )));
8382    container.add_child(Arc::new(Text::new(
8383        welcome_capability_line("Skills", skills),
8384        1,
8385        0,
8386    )));
8387    container.add_child(Arc::new(DynamicBorder::new()));
8388}
8389
8390/// Append update notices inside the live transcript. Fullscreen mode clears
8391/// pre-TUI stdout/stderr, so update state must be represented by components.
8392fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8393    let colors = current_theme().colors;
8394    let group = Arc::new(Container::new());
8395
8396    for warning in &report.warnings {
8397        let body = format!(
8398            "{}\n{} {}{}",
8399            colors.error.fg(&warning.message),
8400            colors.muted.fg("Run"),
8401            colors.accent.fg(&warning.command),
8402            colors.muted.fg(" to retry.")
8403        );
8404        add_update_panel(&group, "Update Failed", &body, colors.error);
8405    }
8406
8407    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8408        let body = format!(
8409            "{} {}{}",
8410            colors
8411                .muted
8412                .fg(&format!("New version {} is available. Run", notice.latest)),
8413            colors.accent.fg(&notice.command),
8414            colors.muted.fg(".")
8415        );
8416        add_update_panel(&group, "Update Available", &body, colors.warning);
8417    }
8418
8419    let package_notices = report
8420        .notices
8421        .iter()
8422        .filter(|notice| notice.name != "rpi")
8423        .collect::<Vec<_>>();
8424    if !package_notices.is_empty() {
8425        let command = package_notices[0].command.as_str();
8426        let mut lines = vec![format!(
8427            "{} {}{}",
8428            colors.muted.fg("Package updates are available. Run"),
8429            colors.accent.fg(command),
8430            colors.muted.fg(".")
8431        )];
8432        lines.push(colors.muted.fg("Packages:"));
8433        lines.extend(
8434            package_notices
8435                .into_iter()
8436                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8437        );
8438        add_update_panel(
8439            &group,
8440            "Package Updates Available",
8441            &lines.join("\n"),
8442            colors.warning,
8443        );
8444    }
8445
8446    // Other transcript producers append concurrently. Add the fully built
8447    // group in one operation so card borders and content cannot interleave
8448    // with user, assistant, tool, or extension messages.
8449    if group.child_count() > 0 {
8450        container.add_child(group);
8451    }
8452}
8453
8454fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8455    container.add_child(Arc::new(Spacer::new(1)));
8456    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8457    container.add_child(Arc::new(Text::new(
8458        format!("{}\n{body}", color.fg(&tui_bold(title))),
8459        1,
8460        0,
8461    )));
8462    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8463}
8464
8465fn welcome_capability_line(label: &str, names: &[String]) -> String {
8466    let c = current_theme().colors;
8467    let value = if names.is_empty() {
8468        "none".to_string()
8469    } else {
8470        names.join(" · ")
8471    };
8472    format!(
8473        "{} {}",
8474        c.accent.fg(&format!("{label} ({})", names.len())),
8475        c.muted.fg(&value)
8476    )
8477}
8478
8479/// Add the `/help` command listing to the chat container.
8480fn add_help_message(container: &Arc<Container>) {
8481    let c = current_theme().colors;
8482    // Section header + a thin themed rule, then a two-column command table:
8483    // `cmd` in accent, `— desc` in muted. The old single-space layout made
8484    // the description column wander depending on command length.
8485    container.add_child(Arc::new(Text::new(
8486        c.md_heading.fg(&tui_bold("📚 Available Commands")),
8487        1,
8488        0,
8489    )));
8490    container.add_child(Arc::new(Spacer::new(1)));
8491
8492    let cmds: &[(&str, &str)] = &[
8493        ("/help, /?", "Show this help message"),
8494        ("/clear, /new", "Clear the conversation"),
8495        ("/exit, /quit, /q", "Exit the application"),
8496        ("/version, /v", "Show version information"),
8497        ("/changelog", "Show recent release changes"),
8498        ("/model, /m", "Choose a model (live switch)"),
8499        ("/thinking, /think", "Set reasoning depth (selector)"),
8500        ("/tools", "Toggle built-in tools on/off"),
8501        ("/images", "Toggle inline image rendering"),
8502        ("/session", "List saved sessions"),
8503        ("/theme", "Choose a theme (selector)"),
8504        ("/compact", "Compact the conversation"),
8505        ("/copy", "Copy last reply to clipboard"),
8506        ("/hotkeys", "Show keyboard shortcuts"),
8507        ("/armin", "🐾 Easter egg"),
8508        ("/earendil", "Earendil announcement"),
8509    ];
8510    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8511    for (cmd, desc) in cmds {
8512        let row = format!(
8513            "  {:<cmd_w$}  {}  {}",
8514            c.accent.fg(cmd),
8515            c.dim.fg("—"),
8516            c.muted.fg(desc)
8517        );
8518        container.add_child(Arc::new(Text::new(row, 1, 0)));
8519    }
8520    container.add_child(Arc::new(Spacer::new(1)));
8521}
8522
8523/// Add the `/version` block to the chat container.
8524fn add_version_message(container: &Arc<Container>) {
8525    let c = current_theme().colors;
8526    container.add_child(Arc::new(Text::new(
8527        c.md_heading.fg(&tui_bold("📦 Version Information")),
8528        1,
8529        0,
8530    )));
8531    container.add_child(Arc::new(Spacer::new(1)));
8532    // Use the crate version (kept in sync via `version.workspace = true`)
8533    // instead of the stale hardcoded "v0.1.2".
8534    container.add_child(Arc::new(Text::new(
8535        format!(
8536            "  {} {}",
8537            c.muted.fg("rpi-cli"),
8538            c.text.fg(&format!("v{}", crate::VERSION))
8539        ),
8540        1,
8541        0,
8542    )));
8543    container.add_child(Arc::new(Text::new(
8544        format!(
8545            "  {}",
8546            c.dim.fg("Rust implementation of pi coding agent TUI")
8547        ),
8548        1,
8549        0,
8550    )));
8551    container.add_child(Arc::new(Spacer::new(1)));
8552}
8553
8554/// Add a compact `/changelog` block to the chat container. Keep this local to
8555/// the binary so the command remains useful in installed builds without a
8556/// source checkout or a network request.
8557fn add_changelog_message(container: &Arc<Container>) {
8558    let c = current_theme().colors;
8559    container.add_child(Arc::new(Text::new(
8560        c.md_heading.fg(&tui_bold("Recent Changes")),
8561        1,
8562        0,
8563    )));
8564    container.add_child(Arc::new(Spacer::new(1)));
8565    let entries = [
8566        (
8567            "Native parity phase 1",
8568            "models, images, trust, export, and JSON events",
8569        ),
8570        (
8571            "TUI controls",
8572            "external editor, thinking levels, and tool output toggles",
8573        ),
8574        (
8575            "Provider auth",
8576            "OpenAI-compatible API key aliases and gateway headers",
8577        ),
8578    ];
8579    for (release, summary) in entries {
8580        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
8581        container.add_child(Arc::new(Text::new(row, 1, 0)));
8582    }
8583    container.add_child(Arc::new(Text::new(
8584        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8585        1,
8586        0,
8587    )));
8588    container.add_child(Arc::new(Spacer::new(1)));
8589}
8590
8591/// Add the `/hotkeys` block to the chat container.
8592fn add_hotkeys_message(container: &Arc<Container>) {
8593    let c = current_theme().colors;
8594    container.add_child(Arc::new(Text::new(
8595        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
8596        1,
8597        0,
8598    )));
8599    container.add_child(Arc::new(Spacer::new(1)));
8600    let keys: &[(&str, &str)] = &[
8601        ("Enter", "Send message"),
8602        ("Shift+Enter", "New line"),
8603        ("Tab", "Accept autocomplete suggestion"),
8604        ("Ctrl+A / Ctrl+E", "Line start / end"),
8605        (
8606            "Ctrl+K / Ctrl+U",
8607            "Kill to end / start of line (Ctrl+Y yanks)",
8608        ),
8609        ("Ctrl+- / Ctrl+R", "Undo / redo"),
8610        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8611        ("Alt+Backspace", "Kill previous word"),
8612        ("Ctrl+C", "Abort a run, or exit when idle"),
8613        ("Esc", "Abort a running prompt"),
8614        ("Ctrl+L", "Open model selector"),
8615        ("Ctrl+M", "Cycle to the next model (live)"),
8616        ("Ctrl+O", "Expand/collapse all tool output"),
8617        ("Ctrl+T", "Show/hide reasoning blocks"),
8618        ("PageUp/Down", "Scroll transcript by one page"),
8619        ("Home / End", "Jump to transcript start / latest output"),
8620    ];
8621    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8622    for (key, desc) in keys {
8623        let row = format!(
8624            "  {:<key_w$}  {}  {}",
8625            c.accent.fg(key),
8626            c.dim.fg("—"),
8627            c.muted.fg(desc)
8628        );
8629        container.add_child(Arc::new(Text::new(row, 1, 0)));
8630    }
8631    container.add_child(Arc::new(Spacer::new(1)));
8632}
8633
8634/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
8635/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
8636/// plain `> text` echo. A trailing Spacer(1) separates it from the next
8637// transcript entry (every entry contributes one trailing spacer so
8638// consecutive turns are separated by exactly one blank line).
8639fn add_user_message(container: &Arc<Container>, text: &str) {
8640    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
8641    container.add_child(Arc::new(Spacer::new(1)));
8642}
8643
8644/// Add an error message to the chat container.
8645fn add_error_message(container: &Arc<Container>, text: &str) {
8646    let c = current_theme().colors;
8647    let text = sanitize_error_message(text);
8648    container.add_child(Arc::new(Text::new(
8649        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
8650        1,
8651        0,
8652    )));
8653    container.add_child(Arc::new(Spacer::new(1)));
8654}
8655
8656/// Keep provider diagnostics printable in the transcript. HTTP error bodies
8657/// may contain carriage returns, terminal escapes, or an unexpectedly large
8658/// JSON payload; letting those bytes reach the renderer can corrupt the input
8659/// row or make the whole frame exceed terminal limits.
8660fn sanitize_error_message(text: &str) -> String {
8661    const MAX_ERROR_CHARS: usize = 16 * 1024;
8662    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
8663    let mut count = 0;
8664    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
8665    let mut escape_mode = 0u8;
8666    for ch in text.chars() {
8667        if escape_mode != 0 {
8668            match escape_mode {
8669                1 if ch == '[' => escape_mode = 2,
8670                1 if ch == ']' => escape_mode = 3,
8671                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
8672                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
8673                3 if ch == '\x07' => escape_mode = 0,
8674                _ => {}
8675            }
8676            continue;
8677        }
8678        if ch == '\x1b' {
8679            escape_mode = 1;
8680            continue;
8681        }
8682        if count >= MAX_ERROR_CHARS {
8683            result.push_str("…");
8684            break;
8685        }
8686        match ch {
8687            '\n' | '\t' => {
8688                result.push(ch);
8689                count += 1;
8690            }
8691            '\r' => {}
8692            c if c.is_control() => {}
8693            c => {
8694                result.push(c);
8695                count += 1;
8696            }
8697        }
8698    }
8699    if result.trim().is_empty() {
8700        "Provider request failed.".to_string()
8701    } else {
8702        result
8703    }
8704}
8705
8706/// Add a neutral note (e.g. unsupported-command message) to the chat container.
8707fn add_note_message(container: &Arc<Container>, text: &str) {
8708    let c = current_theme().colors;
8709    container.add_child(Arc::new(Text::new(
8710        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
8711        1,
8712        0,
8713    )));
8714    container.add_child(Arc::new(Spacer::new(1)));
8715}
8716
8717/// Render the `/context` panel: a transcript message listing the discovered
8718/// context files, skills, and prompt templates loaded for this session
8719/// (Part A resource discovery). Reads the harness resources snapshot captured
8720/// at TUI startup (the blocking submit handler can't `await get_resources()`.
8721///
8722/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
8723/// via `/reload`); here it's a transcript note rather than an overlay since the
8724/// resource set is session-static between `/reload`s (deferred).
8725fn show_context_panel(
8726    chat: &Arc<Container>,
8727    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
8728) {
8729    let skills = resources.skills.as_deref().unwrap_or(&[]);
8730    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
8731    let mut lines: Vec<String> = Vec::new();
8732    lines.push("📂 Discovered resources for this session:".into());
8733
8734    if skills.is_empty() {
8735        lines.push(
8736            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
8737        );
8738    } else {
8739        lines.push(format!("  Skills ({}):", skills.len()));
8740        for s in skills {
8741            let marker = if s.disable_model_invocation == Some(true) {
8742                " [hidden]"
8743            } else {
8744                ""
8745            };
8746            let desc: String = s.description.chars().take(72).collect();
8747            lines.push(format!("    • {}{marker} — {desc}", s.name));
8748        }
8749    }
8750
8751    if templates.is_empty() {
8752        lines.push(
8753            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8754        );
8755    } else {
8756        lines.push(format!("  Prompt templates ({}):", templates.len()));
8757        for t in templates {
8758            let desc = t
8759                .description
8760                .as_deref()
8761                .unwrap_or("(no description)")
8762                .chars()
8763                .take(72)
8764                .collect::<String>();
8765            lines.push(format!("    • /{} — {desc}", t.name));
8766        }
8767    }
8768    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8769    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8770    lines.push(
8771        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8772            .into(),
8773    );
8774    let body = lines.join("\n");
8775    container_note_block(chat, &body);
8776}
8777
8778/// Append a multi-line neutral note (header line + body) to the chat container.
8779fn container_note_block(container: &Arc<Container>, body: &str) {
8780    for line in body.lines() {
8781        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8782    }
8783    container.add_child(Arc::new(Spacer::new(1)));
8784}
8785
8786// ===========================================================================
8787// TUI support + entry detection
8788// ===========================================================================
8789
8790/// Check if the terminal supports TUI mode.
8791pub fn is_tui_supported() -> bool {
8792    std::io::stdout().is_terminal()
8793}
8794
8795// Keep the `Color` import used (theme accent rendering in autocomplete).
8796#[allow(unused_imports)]
8797use rpi_tui::Color as _Color;
8798
8799#[cfg(test)]
8800mod tests {
8801    use super::*;
8802    use rpi_tui::Component;
8803
8804    #[test]
8805    fn verbose_overrides_quiet_startup_listing() {
8806        assert!(should_show_startup_listing(false, false));
8807        assert!(should_show_startup_listing(true, false));
8808        assert!(should_show_startup_listing(true, true));
8809        assert!(!should_show_startup_listing(false, true));
8810    }
8811
8812    #[test]
8813    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8814        let tmp = tempfile::tempdir().unwrap();
8815        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8816        let local_root = tmp.path().join("local-package");
8817        std::fs::create_dir_all(git_root.join(".git")).unwrap();
8818        std::fs::create_dir_all(&local_root).unwrap();
8819        std::fs::write(
8820            git_root.join("package.json"),
8821            r#"{"name":"git-demo","version":"1.0.0"}"#,
8822        )
8823        .unwrap();
8824        std::fs::write(
8825            local_root.join("package.json"),
8826            r#"{"name":"local-demo","version":"1.0.0"}"#,
8827        )
8828        .unwrap();
8829        let resources = crate::packages::discover(
8830            tmp.path(),
8831            &[
8832                "git:github.com/example/repo".to_string(),
8833                local_root.to_string_lossy().into_owned(),
8834            ],
8835        );
8836        assert_eq!(resources.packages.len(), 2);
8837
8838        let filtered = git_only_update_resources(resources);
8839
8840        assert_eq!(filtered.packages.len(), 1);
8841        assert!(matches!(
8842            filtered.packages[0].source,
8843            crate::packages::PackageSource::Git
8844        ));
8845    }
8846
8847    #[test]
8848    fn trust_command_uses_the_session_cwd_after_process_chdir() {
8849        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8850        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8851        const TEST_NAME: &str =
8852            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8853
8854        if std::env::var_os(CHILD_ENV).is_some() {
8855            let session_cwd = std::path::PathBuf::from(
8856                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8857            );
8858            let changed_cwd = std::env::current_dir().unwrap();
8859
8860            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8861
8862            assert_eq!(
8863                crate::config::project_trust_decision(&session_cwd).unwrap(),
8864                Some(true)
8865            );
8866            assert_eq!(
8867                crate::config::project_trust_decision(&changed_cwd).unwrap(),
8868                None
8869            );
8870            return;
8871        }
8872
8873        let tmp = tempfile::tempdir().unwrap();
8874        let session_cwd = tmp.path().join("session-project");
8875        let changed_cwd = tmp.path().join("extension-cwd");
8876        let agent_dir = tmp.path().join("agent");
8877        std::fs::create_dir_all(&session_cwd).unwrap();
8878        std::fs::create_dir_all(&changed_cwd).unwrap();
8879        std::fs::create_dir_all(&agent_dir).unwrap();
8880
8881        let status = std::process::Command::new(std::env::current_exe().unwrap())
8882            .arg("--exact")
8883            .arg(TEST_NAME)
8884            .arg("--nocapture")
8885            .env(CHILD_ENV, "1")
8886            .env(SESSION_CWD_ENV, &session_cwd)
8887            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8888            .current_dir(&changed_cwd)
8889            .status()
8890            .unwrap();
8891
8892        assert!(status.success(), "child test process failed: {status}");
8893    }
8894
8895    #[test]
8896    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8897        let global = crate::settings::Settings {
8898            editor_padding_x: Some(3),
8899            autocomplete_max_visible: Some(6),
8900            hide_thinking_block: Some(false),
8901            quiet_startup: Some(true),
8902            show_terminal_progress: Some(true),
8903            ..Default::default()
8904        };
8905        let rpi_project = crate::settings::Settings {
8906            editor_padding_x: Some(0),
8907            hide_thinking_block: Some(true),
8908            quiet_startup: Some(false),
8909            show_terminal_progress: Some(false),
8910            ..Default::default()
8911        };
8912        let pi_project = crate::settings::Settings {
8913            editor_padding_x: Some(9),
8914            autocomplete_max_visible: Some(12),
8915            quiet_startup: Some(true),
8916            ..Default::default()
8917        };
8918
8919        assert_eq!(
8920            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8921            TuiStartupSettings {
8922                editor_padding_x: 0,
8923                autocomplete_max_visible: 12,
8924                hide_thinking: true,
8925                quiet_startup: false,
8926                show_terminal_progress: false,
8927            }
8928        );
8929    }
8930
8931    #[test]
8932    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8933        let global = crate::settings::Settings {
8934            quiet_startup: Some(false),
8935            ..Default::default()
8936        };
8937        let rpi_project = crate::settings::Settings::default();
8938        let pi_project = crate::settings::Settings {
8939            quiet_startup: Some(true),
8940            ..Default::default()
8941        };
8942
8943        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8944
8945        assert!(resolved.quiet_startup);
8946    }
8947
8948    #[test]
8949    fn tui_startup_settings_use_global_values_without_project_fields() {
8950        let global = crate::settings::Settings {
8951            editor_padding_x: Some(7),
8952            autocomplete_max_visible: Some(8),
8953            hide_thinking_block: Some(true),
8954            quiet_startup: Some(true),
8955            show_terminal_progress: Some(false),
8956            ..Default::default()
8957        };
8958
8959        assert_eq!(
8960            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8961            TuiStartupSettings {
8962                editor_padding_x: 7,
8963                autocomplete_max_visible: 8,
8964                hide_thinking: true,
8965                quiet_startup: true,
8966                show_terminal_progress: false,
8967            }
8968        );
8969    }
8970
8971    #[test]
8972    fn tui_startup_settings_ignore_untrusted_project_values() {
8973        let global = crate::settings::Settings {
8974            quiet_startup: Some(false),
8975            show_terminal_progress: Some(true),
8976            ..Default::default()
8977        };
8978        let project = crate::settings::Settings {
8979            quiet_startup: Some(true),
8980            show_terminal_progress: Some(false),
8981            ..Default::default()
8982        };
8983
8984        let resolved = resolve_tui_startup_settings(&global, &[project], false);
8985
8986        assert!(!resolved.quiet_startup);
8987        assert!(resolved.show_terminal_progress);
8988    }
8989
8990    #[test]
8991    fn transcript_page_uses_viewport_with_overlap() {
8992        assert_eq!(transcript_page_size(24), 20);
8993        assert_eq!(transcript_page_size(4), 1);
8994        assert_eq!(transcript_page_size(0), 1);
8995    }
8996
8997    #[test]
8998    fn key_repeat_is_dispatched_but_release_is_not() {
8999        assert!(should_dispatch_key(KeyEventKind::Press));
9000        assert!(should_dispatch_key(KeyEventKind::Repeat));
9001        assert!(!should_dispatch_key(KeyEventKind::Release));
9002    }
9003
9004    #[test]
9005    fn key_event_encoding_matches_pi_keybinding_protocol() {
9006        let key = |code, modifiers| KeyEvent::new(code, modifiers);
9007        assert_eq!(
9008            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9009            "\r"
9010        );
9011        assert_eq!(
9012            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9013            "\x1b[13;2u"
9014        );
9015        assert_eq!(
9016            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9017            "\x1b[9;2u"
9018        );
9019        assert_eq!(
9020            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9021            "\x1b[Z"
9022        );
9023        assert_eq!(
9024            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9025            "\x1b[Z"
9026        );
9027        assert_eq!(
9028            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9029            "\x03"
9030        );
9031        assert_eq!(
9032            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9033            "\x0f"
9034        );
9035        assert_eq!(
9036            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9037            "!"
9038        );
9039        assert_eq!(
9040            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9041            "1"
9042        );
9043    }
9044
9045    #[test]
9046    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9047        let bridge = JsDialogBridge::default();
9048        let (sender, receiver) = std_mpsc::channel();
9049        bridge.pending.lock().unwrap().push_back(JsDialogPending {
9050            request: JsDialogRequest {
9051                id: "dialog-1".into(),
9052                method: "input".into(),
9053                title: String::new(),
9054                message: String::new(),
9055                options: Vec::new(),
9056                placeholder: None,
9057                prefill: None,
9058            },
9059            result: sender,
9060        });
9061
9062        // Model the cancellation arriving after the queue entry has been
9063        // removed but before the TUI has installed the native widget.
9064        bridge.cancel("dialog-1");
9065        assert!(bridge.take_pending().is_none());
9066        assert_eq!(
9067            receiver.recv().unwrap(),
9068            serde_json::json!({ "cancelled": true })
9069        );
9070        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9071    }
9072
9073    #[test]
9074    fn test_layout_renders_welcome_message() {
9075        let chat = Arc::new(Container::new());
9076        add_welcome_message(&chat);
9077
9078        let scroll = Arc::new(ScrollView::new(
9079            chat.clone(),
9080            ScrollViewOptions {
9081                follow: FollowMode::End,
9082                primary: true,
9083                ..Default::default()
9084            },
9085        ));
9086
9087        let editor = Arc::new(Editor::new(
9088            EditorOptions {
9089                padding_x: 1,
9090                ..Default::default()
9091            },
9092            EditorStyle::default(),
9093            Arc::new(rpi_tui::Keybindings::new()),
9094        ));
9095        let dock = Arc::new(Container::new());
9096        dock.add_child(editor);
9097
9098        let footer = Arc::new(FooterComponent::new());
9099
9100        let root = VStack::from_children(vec![
9101            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9102            StackChild::Entry(StackEntry::new(dock)),
9103            StackChild::Entry(StackEntry::new(footer)),
9104        ]);
9105
9106        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9107
9108        let all: String = frame.lines.join("\n");
9109        assert!(
9110            all.contains("rpi"),
9111            "Welcome message not found. Rendered: {}",
9112            all
9113        );
9114        assert!(
9115            all.contains("Type your message"),
9116            "Help text not found. Rendered: {}",
9117            all
9118        );
9119    }
9120
9121    #[test]
9122    fn test_chat_container_has_welcome_content() {
9123        let chat = Arc::new(Container::new());
9124        add_welcome_message_with_capabilities(
9125            &chat,
9126            &["read".into(), "bash".into(), "web_fetch".into()],
9127            &["rust-review".into(), "release".into()],
9128        );
9129
9130        let lines = chat.render(80);
9131        let all: String = lines.join("\n");
9132        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
9133        // joined by an ANSI reset — strip ANSI before checking the substring.
9134        let plain = strip_ansi(&all);
9135        assert!(
9136            plain.contains("rpi"),
9137            "Welcome message not in chat container: {:?}",
9138            lines
9139        );
9140        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9141        assert!(
9142            plain.contains("read · bash · web_fetch"),
9143            "Tool names missing: {plain}"
9144        );
9145        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9146        assert!(
9147            plain.contains("rust-review · release"),
9148            "Skill names missing: {plain}"
9149        );
9150    }
9151
9152    #[test]
9153    fn update_notices_render_inside_the_transcript() {
9154        let chat = Arc::new(Container::new());
9155        let report = crate::updates::UpdateReport {
9156            notices: vec![
9157                crate::updates::UpdateNotice {
9158                    name: "rpi".into(),
9159                    current: "0.1.10".into(),
9160                    latest: "0.1.11".into(),
9161                    command: "rpi update".into(),
9162                },
9163                crate::updates::UpdateNotice {
9164                    name: "rpi-search".into(),
9165                    current: "0.1.0".into(),
9166                    latest: "0.1.1".into(),
9167                    command: "rpi pi-package update".into(),
9168                },
9169            ],
9170            warnings: vec![crate::updates::UpdateWarning {
9171                message: "The previously scheduled rpi update failed: access denied".into(),
9172                command: "rpi update".into(),
9173            }],
9174        };
9175
9176        add_update_notices(&chat, &report);
9177
9178        assert_eq!(chat.child_count(), 1);
9179        let plain = strip_ansi(&chat.render(80).join("\n"));
9180        assert!(plain.contains("Update Failed"), "{plain}");
9181        assert!(
9182            plain.contains("rpi update failed: access denied"),
9183            "{plain}"
9184        );
9185        assert!(plain.contains("Update Available"), "{plain}");
9186        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9187        assert!(plain.contains("rpi update"), "{plain}");
9188        assert!(plain.contains("Package Updates Available"), "{plain}");
9189        assert!(plain.contains("rpi pi-package update"), "{plain}");
9190        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9191    }
9192
9193    #[test]
9194    fn empty_update_report_does_not_add_transcript_content() {
9195        let chat = Arc::new(Container::new());
9196
9197        add_update_notices(&chat, &crate::updates::UpdateReport::default());
9198
9199        assert_eq!(chat.child_count(), 0);
9200    }
9201
9202    #[test]
9203    fn skill_reads_are_detected_by_path() {
9204        let name = skill_tool_name(
9205            "read",
9206            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9207        );
9208        assert_eq!(name.as_deref(), Some("release"));
9209
9210        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9211        assert!(name.is_none());
9212
9213        // Only `read` (not other tools) triggers the skill box.
9214        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9215    }
9216
9217    #[test]
9218    fn welcome_capabilities_show_empty_state() {
9219        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9220        assert_eq!(plain, "Skills (0) none");
9221    }
9222
9223    #[test]
9224    fn empty_tool_progress_is_deferred_until_start() {
9225        let empty = rpi_agent::AgentToolResult::default();
9226        assert!(!tool_update_has_payload("", &empty));
9227
9228        let text = rpi_agent::AgentToolResult::text("partial output");
9229        assert!(tool_update_has_payload("partial output", &text));
9230
9231        let details = rpi_agent::AgentToolResult {
9232            details: serde_json::json!({"path": "src/lib.rs"}),
9233            ..Default::default()
9234        };
9235        assert!(tool_update_has_payload("", &details));
9236    }
9237
9238    #[test]
9239    fn completed_stream_reconciles_the_final_tail_before_detaching() {
9240        let component = Arc::new(AssistantMessageComponent::new(
9241            AssistantMessageOptions::default(),
9242        ));
9243        component.set_streaming(true);
9244        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9245
9246        let current = Mutex::new(Some(component.clone()));
9247        let cached = Mutex::new("partial response".to_string());
9248        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9249        final_message.content = vec![Content::text(
9250            "partial response with the previously missing final tail",
9251        )];
9252        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9253
9254        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
9255
9256        assert!(current.lock().unwrap().is_none());
9257        assert_eq!(
9258            cached.lock().unwrap().as_str(),
9259            "partial response with the previously missing final tail"
9260        );
9261        let rendered = strip_ansi(&component.render(100).join("\n"));
9262        assert!(
9263            rendered.contains("previously missing final tail"),
9264            "{rendered}"
9265        );
9266    }
9267
9268    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
9269    /// replaces the editor text, the NEXT rendered frame must show the
9270    /// completed text (" /model " with the caret after it), not the old
9271    /// prefix. Mirrors the real dock layout (autocomplete_container above the
9272    /// bordered editor) and drives the same accept path the Tab handler uses.
9273    #[test]
9274    fn tab_accept_suggestion_reflects_in_next_render() {
9275        use rpi_tui::render_layout_frame;
9276
9277        let editor = Arc::new(Editor::new(
9278            EditorOptions {
9279                padding_x: 1,
9280                ..Default::default()
9281            },
9282            EditorStyle::default(),
9283            Arc::new(rpi_tui::Keybindings::new()),
9284        ));
9285        editor.set_focused(true);
9286        let editor_container = Arc::new(Container::new());
9287        editor_container.add_child(editor.clone());
9288        let autocomplete_container = Arc::new(Container::new());
9289        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9290        let dock = Arc::new(VStack::from_children(vec![
9291            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9292            StackChild::Entry(
9293                StackEntry::new(editor_container.clone())
9294                    .shrink(0)
9295                    .min_size(3),
9296            ),
9297            StackChild::Entry(StackEntry::new(footer)),
9298        ]));
9299
9300        // Simulate the user typing "/mo" (the popup shows suggestions).
9301        let mut manager = AutocompleteManager::new();
9302        let mut combined = CombinedAutocompleteProvider::new();
9303        combined.add_provider(Arc::new(
9304            SlashCommandAutocompleteProvider::with_default_commands(),
9305        ));
9306        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9307        manager.set_provider(Arc::new(combined));
9308        // Simulate typing "/mo" via the real insert path (advances the caret
9309        // by char length, like `handle_key` does).
9310        editor.insert("/mo");
9311        assert_eq!(editor.cursor_position(), (0, 3));
9312
9313        let frame_before = render_layout_frame(dock.clone(), 80, 10);
9314        assert!(
9315            frame_before.lines.iter().any(|l| l.contains("/mo")),
9316            "precondition: editor shows the typed prefix. Frame rows:\n{}",
9317            frame_before
9318                .lines
9319                .iter()
9320                .map(|l| format!("  [{l}]"))
9321                .collect::<Vec<_>>()
9322                .join("\n")
9323        );
9324
9325        // Tab: accept the top suggestion (the same code path as the key loop).
9326        let text = editor.get_text();
9327        let cursor = editor_cursor_offset(&editor, &text);
9328        let sugg = manager
9329            .get_suggestions(&text, cursor)
9330            .expect("slash suggestions for /mo");
9331        let top = sugg.items.first().expect("at least one suggestion");
9332        let start = sugg.start.min(text.len());
9333        let end = sugg.end.min(text.len());
9334        let mut replaced = String::new();
9335        replaced.push_str(&text[..start]);
9336        replaced.push_str(&top.text);
9337        replaced.push_str(&text[end..]);
9338        if top.insert_space && !replaced.ends_with('/') {
9339            replaced.push(' ');
9340        }
9341        let new_cursor = start + top.text.len();
9342        editor.set_text(&replaced);
9343        set_editor_cursor_offset(&editor, &replaced, new_cursor);
9344        autocomplete_container.clear();
9345        assert_eq!(editor.get_text(), "/model");
9346
9347        // The next render MUST display the completed text.
9348        let frame_after = render_layout_frame(dock, 80, 10);
9349        let all: String = frame_after.lines.join("\n");
9350        assert!(
9351            all.contains("/model"),
9352            "completed text missing from next render. Got:\n{all}"
9353        );
9354        // The caret must sit AFTER the completed command (the snap_boundary
9355        // regression put it one char early: "/mode|l" with the final char
9356        // dangling past the caret).
9357        let editor_line = frame_after
9358            .lines
9359            .iter()
9360            .find(|l| l.contains("/model"))
9361            .expect("editor row with completed text");
9362        assert!(
9363            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9364            "caret must follow the full completed text. Got: {editor_line:?}"
9365        );
9366    }
9367
9368    #[test]
9369    fn multiline_autocomplete_preserves_row_and_column() {
9370        let editor = Arc::new(Editor::simple());
9371        editor.set_text("first\n/mo");
9372        editor.set_cursor(1, 3);
9373
9374        let text = editor.get_text();
9375        assert_eq!(editor_cursor_offset(&editor, &text), 9);
9376
9377        let replaced = "first\n/model";
9378        editor.set_text(replaced);
9379        set_editor_cursor_offset(&editor, replaced, 12);
9380        assert_eq!(editor.cursor_position(), (1, 6));
9381    }
9382
9383    #[test]
9384    fn test_slash_command_dispatch() {
9385        // The registry is the single source of truth for dispatch: `find(token)`
9386        // returns the command (by name or alias) whose `name()` is the canonical
9387        // form, or `None` for an unknown token. This replaces the old enum-based
9388        // `handle_slash_command` assertions with equivalent registry lookups.
9389        let registry = build_builtin_registry();
9390
9391        // Helper: a token resolves to the command with this canonical name.
9392        let resolves_to = |token: &str, canonical: &str| {
9393            let found = registry.find(token).expect("{token} should resolve");
9394            assert_eq!(
9395                found.name(),
9396                canonical,
9397                "{token} resolved to {} (expected {canonical})",
9398                found.name()
9399            );
9400        };
9401
9402        resolves_to("/help", "/help");
9403        resolves_to("/?", "/help"); // alias → canonical
9404        resolves_to("/clear", "/clear");
9405        resolves_to("/new", "/clear"); // alias
9406        resolves_to("/q", "/exit"); // alias
9407        resolves_to("/quit", "/exit"); // alias
9408        resolves_to("/version", "/version");
9409        resolves_to("/v", "/version"); // alias
9410        resolves_to("/changelog", "/changelog");
9411        resolves_to("/hotkeys", "/hotkeys");
9412        resolves_to("/model", "/model");
9413        resolves_to("/m", "/model"); // alias
9414        resolves_to("/theme", "/theme");
9415        resolves_to("/session", "/session");
9416        resolves_to("/resume", "/session"); // alias
9417        resolves_to("/compact", "/compact");
9418        resolves_to("/copy", "/copy");
9419        resolves_to("/thinking", "/thinking");
9420        resolves_to("/think", "/thinking"); // alias
9421        resolves_to("/tools", "/tools");
9422        resolves_to("/images", "/images");
9423        resolves_to("/armin", "/armin");
9424        resolves_to("/earendil", "/earendil");
9425        resolves_to("/context", "/context");
9426        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
9427        resolves_to("/settings", "/settings");
9428        resolves_to("/name", "/name");
9429        resolves_to("/export", "/export");
9430
9431        // Unknown token → not found.
9432        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9433    }
9434
9435    #[test]
9436
9437    fn test_registry_visible_entries_cover_dispatch() {
9438        // The autocomplete list is derived from the registry, so every visible
9439        // command the dispatcher recognizes must appear in it — by construction,
9440        // but this guards against a future command being registered with
9441        // `visible()` / a non-empty description that the builder drops.
9442        let registry = build_builtin_registry();
9443        let names: Vec<String> = registry
9444            .visible_entries()
9445            .iter()
9446            .map(|c| c.name.clone())
9447            .collect();
9448        for recognized in [
9449            "/help",
9450            "/clear",
9451            "/new",
9452            "/exit",
9453            "/quit",
9454            "/version",
9455            "/changelog",
9456            "/model",
9457            "/session",
9458            "/theme",
9459            "/compact",
9460            "/copy",
9461            "/hotkeys",
9462            "/tools",
9463            "/images",
9464            "/thinking",
9465            "/armin",
9466            "/earendil",
9467        ] {
9468            assert!(
9469                names.contains(&recognized.to_string()),
9470                "{recognized} missing from autocomplete list"
9471            );
9472        }
9473        // Hidden commands stay off the list.
9474        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9475            assert!(
9476                !names.contains(&hidden.to_string()),
9477                "{hidden} should be hidden from autocomplete"
9478            );
9479        }
9480    }
9481
9482    #[test]
9483    fn test_agent_event_mapping_creates_assistant_and_tool() {
9484        // Synthetic AgentEvent sequence → UI mutations, exercised against the
9485        // real drain handler with a no-op TUI stand-in.
9486        use rpi_ai::types::{
9487            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9488            ToolCall, ToolCallType, Usage,
9489        };
9490
9491        let state = Arc::new(TuiState {
9492            current_assistant: std::sync::Mutex::new(None),
9493            tool_components: std::sync::Mutex::new(HashMap::new()),
9494            bash_components: std::sync::Mutex::new(HashMap::new()),
9495            themes_enabled: true,
9496            hide_thinking: std::sync::Mutex::new(false),
9497            tool_outputs_expanded: std::sync::Mutex::new(false),
9498            show_terminal_progress: true,
9499            status: std::sync::Mutex::new(RunStatus::Idle),
9500            js_preparation_cancel: std::sync::Mutex::new(None),
9501            footer: Arc::new(FooterComponent::new()),
9502            status_container: Arc::new(Container::new()),
9503            chat_container: Arc::new(Container::new()),
9504            loader: Arc::new(Loader::new()),
9505            last_assistant_text: std::sync::Mutex::new(String::new()),
9506            active_selector: std::sync::Mutex::new(None),
9507            active_extension_editor: std::sync::Mutex::new(None),
9508            active_extension_input: std::sync::Mutex::new(None),
9509            active_extension_cancel: std::sync::Mutex::new(None),
9510            autocomplete: AutocompleteManager::new(),
9511            autocomplete_container: Arc::new(Container::new()),
9512            autocomplete_max_visible: 5,
9513            pending_images: std::sync::Mutex::new(Vec::new()),
9514            theme_manager: Arc::new(ThemeManager::new()),
9515            tui: None,
9516            current_model_id: std::sync::Mutex::new(String::new()),
9517            show_images: std::sync::Mutex::new(true),
9518            history: std::sync::Mutex::new(Vec::new()),
9519            history_index: std::sync::Mutex::new(-1),
9520            history_draft: std::sync::Mutex::new(None),
9521            last_input_tokens: std::sync::Mutex::new(0),
9522            scoped_edit: std::sync::Mutex::new(None),
9523            markdown_transformer: std::sync::Mutex::new(None),
9524            extension_session: Arc::new(std::sync::Mutex::new(
9525                rpi_extensions::ExtensionSession::none(),
9526            )),
9527        });
9528
9529        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
9530        // terminal; instead, exercise the *mutation* half directly against a
9531        // captured chat container via a synthetic message-start event's data.
9532        let assistant = AssistantMessage {
9533            role: rpi_ai::types::AssistantRole,
9534            content: vec![
9535                Content::Thinking(ThinkingContent {
9536                    kind: ThinkingContentType,
9537                    thinking: "Reasoning about the reply.".into(),
9538                    thinking_signature: None,
9539                    redacted: false,
9540                }),
9541                Content::Text(TextContent {
9542                    kind: TextContentType,
9543                    text: "Hello.".into(),
9544                    text_signature: None,
9545                }),
9546                Content::ToolCall(ToolCall {
9547                    kind: ToolCallType,
9548                    id: "tc1".into(),
9549                    name: "bash".into(),
9550                    arguments: serde_json::json!({"command": "echo hi"}),
9551                    thought_signature: None,
9552                    namespace: None,
9553                }),
9554            ],
9555            api: rpi_ai::Api::AnthropicMessages,
9556            provider: "anthropic".into(),
9557            model: "claude-sonnet-5".into(),
9558            response_model: None,
9559            response_id: None,
9560            usage: Usage::zero(),
9561            stop_reason: StopReason::Stop,
9562            deferred: None,
9563            error_message: None,
9564            raw_stop_reason: None,
9565            end_turn: None,
9566            timestamp: 0,
9567        };
9568
9569        // Manually apply the MessageStart assistant branch logic (mirrors the
9570        // drain handler, without needing a TuiAltScreen).
9571        let comp = Arc::new(AssistantMessageComponent::new(
9572            AssistantMessageOptions::default(),
9573        ));
9574        comp.set_streaming(true);
9575        comp.update_blocks(&assistant_blocks(&assistant));
9576        let chat = Arc::new(Container::new());
9577        chat.add_child(comp.clone());
9578        *state.current_assistant.lock().unwrap() = Some(comp);
9579
9580        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
9581        for c in &assistant.content {
9582            if let Content::ToolCall(tc) = c {
9583                let mut tools = state.tool_components.lock().unwrap();
9584                if !tools.contains_key(&tc.id) {
9585                    let tc_comp = Arc::new(ToolExecutionComponent::new(
9586                        &tc.name,
9587                        &tc.arguments.to_string(),
9588                    ));
9589                    tc_comp.set_running();
9590                    chat.add_child(tc_comp.clone());
9591                    tools.insert(tc.id.clone(), tc_comp);
9592                }
9593            }
9594        }
9595
9596        // Assert: the assistant component rendered the text + the thinking
9597        // block (the update_blocks path keeps thinking visible), and a tool
9598        // component was registered.
9599        let rendered = chat.render(80);
9600        let joined: String = rendered.join("\n");
9601        assert!(
9602            joined.contains("Hello."),
9603            "assistant text not rendered: {joined}"
9604        );
9605        assert!(
9606            joined.contains("Reasoning about the reply."),
9607            "thinking block not rendered: {joined}"
9608        );
9609        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9610        assert!(state.current_assistant.lock().unwrap().is_some());
9611
9612        // Manually apply ToolExecutionEnd (mirrors drain).
9613        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
9614        ended.set_result("hi", false);
9615        assert!(state.tool_components.lock().unwrap().is_empty());
9616
9617        // A running bash panel owns the visible spinner. The global loader is
9618        // hidden until the last concurrent bash tool completes, then restored
9619        // while the agent remains in the Working state.
9620        assert!(state.try_start_working());
9621        assert!(
9622            !state.try_start_working(),
9623            "a second submit must be rejected"
9624        );
9625        state.set_status(RunStatus::Idle);
9626        state.set_status(RunStatus::Working);
9627        assert_eq!(state.status_container.child_count(), 1);
9628        assert!(state.footer.get_status().is_empty());
9629        state.show_retry(3, 10, 8_000);
9630        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
9631        assert!(retry_status.contains("Retrying (3/10)"));
9632        state.set_status(RunStatus::Working);
9633        {
9634            let mut bash = state.bash_components.lock().unwrap();
9635            bash.insert(
9636                "bash-1".into(),
9637                Arc::new(BashExecutionComponent::new("one")),
9638            );
9639            bash.insert(
9640                "bash-2".into(),
9641                Arc::new(BashExecutionComponent::new("two")),
9642            );
9643        }
9644        state.sync_working_loader_with_bash();
9645        assert_eq!(state.status_container.child_count(), 0);
9646        state.bash_components.lock().unwrap().remove("bash-1");
9647        state.sync_working_loader_with_bash();
9648        assert_eq!(state.status_container.child_count(), 0);
9649        state.bash_components.lock().unwrap().remove("bash-2");
9650        state.sync_working_loader_with_bash();
9651        assert_eq!(state.status_container.child_count(), 1);
9652
9653        state.set_status(RunStatus::Aborting);
9654        assert_eq!(state.status_container.child_count(), 0);
9655        assert!(!state.loader.is_running());
9656    }
9657
9658    #[test]
9659    fn fresh_launch_does_not_restore_old_history() {
9660        let fresh = Args::default();
9661        assert!(!launch_restores_history(&fresh));
9662
9663        let continued = Args {
9664            continue_session: true,
9665            ..Args::default()
9666        };
9667        assert!(launch_restores_history(&continued));
9668
9669        let selected = Args {
9670            session: Some("session-id".into()),
9671            ..Args::default()
9672        };
9673        assert!(launch_restores_history(&selected));
9674    }
9675
9676    #[test]
9677    fn test_short_model_name() {
9678        assert_eq!(
9679            short_model_name("anthropic:claude-sonnet-5"),
9680            "claude-sonnet-5"
9681        );
9682        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
9683    }
9684
9685    #[test]
9686    fn model_selector_items_are_deduplicated_and_provider_qualified() {
9687        use rpi_ai::{Api, Model};
9688
9689        let mut gateway = Model::new(
9690            "gpt-5.6-sol",
9691            "GPT 5.6 Sol",
9692            Api::OpenaiCompletions,
9693            "routeryo-copy",
9694            "https://gateway.example.com",
9695        );
9696        let duplicate = gateway.clone();
9697        let anthropic = Model::new(
9698            "claude-sonnet-5",
9699            "Claude Sonnet 5",
9700            Api::AnthropicMessages,
9701            "anthropic",
9702            "https://api.anthropic.com",
9703        );
9704        gateway.headers = Some(std::collections::BTreeMap::from([(
9705            "authorization".into(),
9706            "Bearer test".into(),
9707        )]));
9708
9709        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
9710        assert_eq!(items.len(), 2);
9711        assert_eq!(items[0].value, "gpt-5.6-sol");
9712        assert_eq!(items[0].label, "GPT 5.6 Sol");
9713        assert_eq!(
9714            items[0].description.as_deref(),
9715            Some("routeryo-copy/gpt-5.6-sol (current)")
9716        );
9717        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
9718    }
9719
9720    #[test]
9721    fn model_selector_match_accepts_bare_and_qualified_ids() {
9722        use rpi_ai::{Api, Model};
9723
9724        let gateway = Model::new(
9725            "gpt-5.6-sol",
9726            "GPT 5.6 Sol",
9727            Api::OpenaiCompletions,
9728            "routeryo-copy",
9729            "https://gateway.example.com",
9730        );
9731        let anthropic = Model::new(
9732            "claude-sonnet-5",
9733            "Claude Sonnet 5",
9734            Api::AnthropicMessages,
9735            "anthropic",
9736            "https://api.anthropic.com",
9737        );
9738        let catalog = [gateway, anthropic];
9739        assert_eq!(
9740            find_model_selector_match(&catalog, "gpt-5.6-sol")
9741                .unwrap()
9742                .provider,
9743            "routeryo-copy"
9744        );
9745        assert_eq!(
9746            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9747                .unwrap()
9748                .id,
9749            "gpt-5.6-sol"
9750        );
9751        assert_eq!(
9752            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9753                .unwrap()
9754                .id,
9755            "claude-sonnet-5"
9756        );
9757        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9758    }
9759
9760    #[test]
9761    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9762        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9763
9764        let failed = AssistantMessage {
9765            role: AssistantRole,
9766            content: Vec::new(),
9767            api: rpi_ai::Api::AnthropicMessages,
9768            provider: "anthropic".into(),
9769            model: "claude-sonnet-5".into(),
9770            response_model: None,
9771            response_id: None,
9772            usage: Usage::zero(),
9773            stop_reason: StopReason::Error,
9774            deferred: None,
9775            error_message: Some("upstream returned 401".into()),
9776            raw_stop_reason: None,
9777            end_turn: None,
9778            timestamp: 0,
9779        };
9780        assert_eq!(
9781            assistant_error_text(&failed).as_deref(),
9782            Some("upstream returned 401")
9783        );
9784
9785        let mut no_detail = failed;
9786        no_detail.error_message = Some("  ".into());
9787        assert_eq!(
9788            assistant_error_text(&no_detail).as_deref(),
9789            Some("Provider request failed.")
9790        );
9791
9792        let mut aborted = no_detail;
9793        aborted.stop_reason = StopReason::Aborted;
9794        aborted.error_message = Some("abort error: Request aborted".into());
9795        assert_eq!(
9796            assistant_error_text(&aborted).as_deref(),
9797            Some("abort error: Request aborted")
9798        );
9799
9800        aborted.error_message = None;
9801        assert_eq!(
9802            assistant_error_text(&aborted).as_deref(),
9803            Some("Request aborted.")
9804        );
9805    }
9806
9807    #[test]
9808    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9809        assert_eq!(
9810            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9811            "405\nMethod Not Allowed"
9812        );
9813        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9814        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
9815        let long = "x".repeat(20_000);
9816        let cleaned = sanitize_error_message(&long);
9817        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9818        assert!(cleaned.ends_with('…'));
9819    }
9820
9821    #[test]
9822    fn test_cycle_next_model_wraps_around() {
9823        use rpi_ai::{Api, Model};
9824        let mk = |id: &str| {
9825            Model::new(
9826                id,
9827                id,
9828                Api::AnthropicMessages,
9829                "anthropic",
9830                "https://api.anthropic.com",
9831            )
9832        };
9833        let catalog = [mk("a"), mk("b"), mk("c")];
9834        // Next after "a" is "b"; after "c" wraps to "a".
9835        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9836        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9837        // An unknown current id falls back to the first model.
9838        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9839        // Empty catalog yields None.
9840        let empty: Vec<Model> = vec![];
9841        assert!(cycle_next_model(&empty, "a").is_none());
9842    }
9843
9844    #[test]
9845    fn test_autocomplete_slash_suggestions_render() {
9846        // The autocomplete container should render at least one suggestion
9847        // line when the editor holds a `/` prefix, and clear when it doesn't.
9848        let state = Arc::new(TuiState {
9849            current_assistant: std::sync::Mutex::new(None),
9850            tool_components: std::sync::Mutex::new(HashMap::new()),
9851            bash_components: std::sync::Mutex::new(HashMap::new()),
9852            themes_enabled: true,
9853            hide_thinking: std::sync::Mutex::new(false),
9854            tool_outputs_expanded: std::sync::Mutex::new(false),
9855            show_terminal_progress: true,
9856            status: std::sync::Mutex::new(RunStatus::Idle),
9857            js_preparation_cancel: std::sync::Mutex::new(None),
9858            footer: Arc::new(FooterComponent::new()),
9859            status_container: Arc::new(Container::new()),
9860            chat_container: Arc::new(Container::new()),
9861            loader: Arc::new(Loader::new()),
9862            last_assistant_text: std::sync::Mutex::new(String::new()),
9863            active_selector: std::sync::Mutex::new(None),
9864            active_extension_editor: std::sync::Mutex::new(None),
9865            active_extension_input: std::sync::Mutex::new(None),
9866            active_extension_cancel: std::sync::Mutex::new(None),
9867            autocomplete: AutocompleteManager::new(),
9868            autocomplete_container: Arc::new(Container::new()),
9869            autocomplete_max_visible: 5,
9870            pending_images: std::sync::Mutex::new(Vec::new()),
9871            theme_manager: Arc::new(ThemeManager::new()),
9872            tui: None,
9873            current_model_id: std::sync::Mutex::new(String::new()),
9874            show_images: std::sync::Mutex::new(true),
9875            history: std::sync::Mutex::new(Vec::new()),
9876            history_index: std::sync::Mutex::new(-1),
9877            history_draft: std::sync::Mutex::new(None),
9878            last_input_tokens: std::sync::Mutex::new(0),
9879            scoped_edit: std::sync::Mutex::new(None),
9880            markdown_transformer: std::sync::Mutex::new(None),
9881            extension_session: Arc::new(std::sync::Mutex::new(
9882                rpi_extensions::ExtensionSession::none(),
9883            )),
9884        });
9885        {
9886            let mut combined = CombinedAutocompleteProvider::new();
9887            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9888                build_builtin_registry().visible_entries(),
9889            )));
9890            state.autocomplete.set_provider(Arc::new(combined));
9891        }
9892
9893        let editor = Arc::new(Editor::simple());
9894        editor.set_text("/he");
9895        editor.set_cursor(0, 3);
9896        refresh_autocomplete(&state, &editor);
9897        let lines = state.autocomplete_container.render(80);
9898        let joined: String = lines.join("\n");
9899        assert!(
9900            joined.contains("/help"),
9901            "slash suggestions not rendered: {joined}"
9902        );
9903
9904        // Clear: no suggestions for plain text.
9905        editor.set_text("hello");
9906        editor.set_cursor(0, 5);
9907        refresh_autocomplete(&state, &editor);
9908        assert!(state.autocomplete_container.render(80).is_empty());
9909    }
9910
9911    #[test]
9912    fn test_select_list_swap_restores_editor() {
9913        // The editor-container swap: opening a selector replaces the editor
9914        // child; closing restores it. Verify the container child count + the
9915        // active_selector flag round-trip.
9916        let state = Arc::new(TuiState {
9917            current_assistant: std::sync::Mutex::new(None),
9918            tool_components: std::sync::Mutex::new(HashMap::new()),
9919            bash_components: std::sync::Mutex::new(HashMap::new()),
9920            themes_enabled: true,
9921            hide_thinking: std::sync::Mutex::new(false),
9922            tool_outputs_expanded: std::sync::Mutex::new(false),
9923            show_terminal_progress: true,
9924            status: std::sync::Mutex::new(RunStatus::Idle),
9925            js_preparation_cancel: std::sync::Mutex::new(None),
9926            footer: Arc::new(FooterComponent::new()),
9927            status_container: Arc::new(Container::new()),
9928            chat_container: Arc::new(Container::new()),
9929            loader: Arc::new(Loader::new()),
9930            last_assistant_text: std::sync::Mutex::new(String::new()),
9931            active_selector: std::sync::Mutex::new(None),
9932            active_extension_editor: std::sync::Mutex::new(None),
9933            active_extension_input: std::sync::Mutex::new(None),
9934            active_extension_cancel: std::sync::Mutex::new(None),
9935            autocomplete: AutocompleteManager::new(),
9936            autocomplete_container: Arc::new(Container::new()),
9937            autocomplete_max_visible: 5,
9938            pending_images: std::sync::Mutex::new(Vec::new()),
9939            theme_manager: Arc::new(ThemeManager::new()),
9940            tui: None,
9941            current_model_id: std::sync::Mutex::new(String::new()),
9942            show_images: std::sync::Mutex::new(true),
9943            history: std::sync::Mutex::new(Vec::new()),
9944            history_index: std::sync::Mutex::new(-1),
9945            history_draft: std::sync::Mutex::new(None),
9946            last_input_tokens: std::sync::Mutex::new(0),
9947            scoped_edit: std::sync::Mutex::new(None),
9948            markdown_transformer: std::sync::Mutex::new(None),
9949            extension_session: Arc::new(std::sync::Mutex::new(
9950                rpi_extensions::ExtensionSession::none(),
9951            )),
9952        });
9953        let editor_container = Arc::new(Container::new());
9954        let editor = Arc::new(Editor::simple());
9955        editor_container.add_child(editor.clone());
9956        assert!(!state.selector_open());
9957
9958        let tui_terminal = Box::new(ProcessTerminal::new());
9959        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9960        let list = Arc::new(SelectList::new(
9961            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9962            5,
9963        ));
9964        open_selector(
9965            &state,
9966            &editor_container,
9967            &editor,
9968            &tui,
9969            list,
9970            SelectorKind::Theme,
9971        );
9972        assert!(state.selector_open());
9973        // list only (editor swapped out).
9974        assert_eq!(editor_container.child_count(), 1);
9975
9976        close_selector(&state, &editor_container, &editor, &tui);
9977        assert!(!state.selector_open());
9978        // editor restored.
9979        assert_eq!(editor_container.child_count(), 1);
9980    }
9981
9982    #[test]
9983    fn test_message_history_browse_restores_draft() {
9984        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
9985        // messages, browse older → newer → back past the newest restores the
9986        // draft the user was typing.
9987        let state = Arc::new(TuiState {
9988            current_assistant: std::sync::Mutex::new(None),
9989            tool_components: std::sync::Mutex::new(HashMap::new()),
9990            bash_components: std::sync::Mutex::new(HashMap::new()),
9991            themes_enabled: true,
9992            hide_thinking: std::sync::Mutex::new(false),
9993            tool_outputs_expanded: std::sync::Mutex::new(false),
9994            show_terminal_progress: true,
9995            status: std::sync::Mutex::new(RunStatus::Idle),
9996            js_preparation_cancel: std::sync::Mutex::new(None),
9997            footer: Arc::new(FooterComponent::new()),
9998            status_container: Arc::new(Container::new()),
9999            chat_container: Arc::new(Container::new()),
10000            loader: Arc::new(Loader::new()),
10001            last_assistant_text: std::sync::Mutex::new(String::new()),
10002            active_selector: std::sync::Mutex::new(None),
10003            active_extension_editor: std::sync::Mutex::new(None),
10004            active_extension_input: std::sync::Mutex::new(None),
10005            active_extension_cancel: std::sync::Mutex::new(None),
10006            autocomplete: AutocompleteManager::new(),
10007            autocomplete_container: Arc::new(Container::new()),
10008            autocomplete_max_visible: 5,
10009            pending_images: std::sync::Mutex::new(Vec::new()),
10010            theme_manager: Arc::new(ThemeManager::new()),
10011            tui: None,
10012            current_model_id: std::sync::Mutex::new(String::new()),
10013            show_images: std::sync::Mutex::new(true),
10014            history: std::sync::Mutex::new(Vec::new()),
10015            history_index: std::sync::Mutex::new(-1),
10016            history_draft: std::sync::Mutex::new(None),
10017            last_input_tokens: std::sync::Mutex::new(0),
10018            scoped_edit: std::sync::Mutex::new(None),
10019            markdown_transformer: std::sync::Mutex::new(None),
10020            extension_session: Arc::new(std::sync::Mutex::new(
10021                rpi_extensions::ExtensionSession::none(),
10022            )),
10023        });
10024        let editor = Arc::new(Editor::simple());
10025
10026        push_history(&state, "first message");
10027        push_history(&state, "second message");
10028        // Consecutive duplicate is skipped.
10029        push_history(&state, "second message");
10030        push_history(&state, "   "); // empty → skipped
10031        assert_eq!(state.history.lock().unwrap().len(), 2);
10032        assert_eq!(state.history.lock().unwrap()[0], "second message");
10033
10034        // User starts typing a fresh prompt.
10035        editor.set_text("half-typed");
10036        editor.set_cursor(0, 11);
10037
10038        // ↑ → most recent.
10039        navigate_history(&state, &editor, -1);
10040        assert_eq!(editor.get_text(), "second message");
10041        assert_eq!(*state.history_index.lock().unwrap(), 0);
10042        // ↑ → older.
10043        navigate_history(&state, &editor, -1);
10044        assert_eq!(editor.get_text(), "first message");
10045        assert_eq!(*state.history_index.lock().unwrap(), 1);
10046        // ↑ past the oldest → stays (no wrap).
10047        navigate_history(&state, &editor, -1);
10048        assert_eq!(editor.get_text(), "first message");
10049        // ↓ → newer.
10050        navigate_history(&state, &editor, 1);
10051        assert_eq!(editor.get_text(), "second message");
10052        // ↓ past the newest → restores the draft.
10053        navigate_history(&state, &editor, 1);
10054        assert_eq!(editor.get_text(), "half-typed");
10055        assert_eq!(*state.history_index.lock().unwrap(), -1);
10056    }
10057
10058    #[test]
10059    fn test_accept_top_suggestion_replaces_prefix() {
10060        // `/he` + Tab → `/help ` (slash command provider inserts a space).
10061        let state = Arc::new(TuiState {
10062            current_assistant: std::sync::Mutex::new(None),
10063            tool_components: std::sync::Mutex::new(HashMap::new()),
10064            bash_components: std::sync::Mutex::new(HashMap::new()),
10065            themes_enabled: true,
10066            hide_thinking: std::sync::Mutex::new(false),
10067            tool_outputs_expanded: std::sync::Mutex::new(false),
10068            show_terminal_progress: true,
10069            status: std::sync::Mutex::new(RunStatus::Idle),
10070            js_preparation_cancel: std::sync::Mutex::new(None),
10071            footer: Arc::new(FooterComponent::new()),
10072            status_container: Arc::new(Container::new()),
10073            chat_container: Arc::new(Container::new()),
10074            loader: Arc::new(Loader::new()),
10075            last_assistant_text: std::sync::Mutex::new(String::new()),
10076            active_selector: std::sync::Mutex::new(None),
10077            active_extension_editor: std::sync::Mutex::new(None),
10078            active_extension_input: std::sync::Mutex::new(None),
10079            active_extension_cancel: std::sync::Mutex::new(None),
10080            autocomplete: AutocompleteManager::new(),
10081            autocomplete_container: Arc::new(Container::new()),
10082            autocomplete_max_visible: 5,
10083            pending_images: std::sync::Mutex::new(Vec::new()),
10084            theme_manager: Arc::new(ThemeManager::new()),
10085            tui: None,
10086            current_model_id: std::sync::Mutex::new(String::new()),
10087            show_images: std::sync::Mutex::new(true),
10088            history: std::sync::Mutex::new(Vec::new()),
10089            history_index: std::sync::Mutex::new(-1),
10090            history_draft: std::sync::Mutex::new(None),
10091            last_input_tokens: std::sync::Mutex::new(0),
10092            scoped_edit: std::sync::Mutex::new(None),
10093            markdown_transformer: std::sync::Mutex::new(None),
10094            extension_session: Arc::new(std::sync::Mutex::new(
10095                rpi_extensions::ExtensionSession::none(),
10096            )),
10097        });
10098        {
10099            let mut combined = CombinedAutocompleteProvider::new();
10100            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10101                build_builtin_registry().visible_entries(),
10102            )));
10103            state.autocomplete.set_provider(Arc::new(combined));
10104        }
10105        let editor = Arc::new(Editor::simple());
10106        editor.set_text("/he");
10107        editor.set_cursor(0, 3);
10108        refresh_autocomplete(&state, &editor);
10109        let accepted = accept_top_suggestion(&state, &editor);
10110        assert!(accepted, "should accept the top suggestion");
10111        let text = editor.get_text();
10112        assert!(
10113            text.starts_with("/help"),
10114            "editor text should start with /help, got {text}"
10115        );
10116    }
10117
10118    #[test]
10119    fn configured_key_parser_supports_native_notation() {
10120        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10121        assert_eq!(combo.code, KeyCode::Char('g'));
10122        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10123        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10124        assert_eq!(combo.code, KeyCode::BackTab);
10125    }
10126
10127    #[test]
10128    fn double_escape_trigger_has_half_second_window() {
10129        let now = std::time::Instant::now();
10130        assert!(!double_escape_trigger(None, now));
10131        assert!(double_escape_trigger(
10132            Some(now - std::time::Duration::from_millis(500)),
10133            now
10134        ));
10135        assert!(!double_escape_trigger(
10136            Some(now - std::time::Duration::from_millis(501)),
10137            now
10138        ));
10139    }
10140
10141    // ---- ask_user TUI bridge ----
10142
10143    #[test]
10144    fn ask_user_tool_names_are_recognized() {
10145        assert!(is_ask_user_tool("ask_user"));
10146        assert!(is_ask_user_tool("ask_user_question"));
10147        assert!(!is_ask_user_tool("bash"));
10148    }
10149
10150    #[test]
10151    fn ask_user_args_display_never_leaks_transport_json() {
10152        let text = ask_user_args_display(&serde_json::json!({
10153            "question": "你的代理端口是多少?",
10154            "context": "本机",
10155            "options": ["7890", {"title": "7897", "description": "clash"}],
10156            "suggest": "1080"
10157        }));
10158        assert!(text.contains("你的代理端口是多少?"));
10159        assert!(text.contains("本机"));
10160        assert!(text.contains("Choices: 7890, 7897"));
10161        assert!(text.contains("Suggestions: 1080"));
10162        assert!(!text.trim_start().starts_with('{'));
10163
10164        let multi = ask_user_args_display(&serde_json::json!({
10165            "questions": [
10166                {"id": "a", "question": "Q1"},
10167                {"id": "b", "header": "H", "question": "Q2"}
10168            ]
10169        }));
10170        assert!(multi.contains("Q1"));
10171        assert!(multi.contains("H: Q2"));
10172
10173        assert_eq!(
10174            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10175            "Confirmation requested: Deploy?"
10176        );
10177        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10178    }
10179
10180    #[test]
10181    fn ask_user_result_and_progress_prefer_readable_content() {
10182        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10183        assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10184            .contains("端口?"));
10185        let empty = rpi_agent::AgentToolResult::default();
10186        assert!(
10187            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10188                .contains("端口?")
10189        );
10190        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10191        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10192        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10193    }
10194
10195    #[test]
10196    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10197        let flat = rpi_extensions::UiDialogRequest {
10198            request_id: "r1".into(),
10199            tool_call_id: Some("c1".into()),
10200            ui: serde_json::json!({
10201                "kind": "selector",
10202                "id": "proxy_port",
10203                "header": "Proxy",
10204                "question": "端口?",
10205                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10206                "allowMultiple": true,
10207                "allowFreeform": false,
10208                "suggest": "1080"
10209            }),
10210            raw: serde_json::json!({}),
10211        };
10212        let prompt = parse_ask_user_prompt(&flat);
10213        assert_eq!(prompt.question_id, "proxy_port");
10214        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10215        assert_eq!(prompt.options.len(), 2);
10216        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10217        assert!(prompt.allow_multiple);
10218        assert!(!prompt.allow_freeform);
10219        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10220
10221        let alias = rpi_extensions::UiDialogRequest {
10222            ui: serde_json::json!({
10223                "questions": [{"id": "q", "question": "Q?", "options": []}]
10224            }),
10225            ..flat.clone()
10226        };
10227        let prompt = parse_ask_user_prompt(&alias);
10228        assert_eq!(prompt.question_id, "q");
10229        assert!(prompt.allow_freeform, "no options defaults to freeform");
10230
10231        let confirm = rpi_extensions::UiDialogRequest {
10232            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10233            ..flat.clone()
10234        };
10235        let prompt = parse_ask_user_prompt(&confirm);
10236        assert_eq!(prompt.kind, "confirm");
10237        assert_eq!(prompt.options.len(), 2);
10238        assert_eq!(prompt.options[0].0, "Yes");
10239    }
10240
10241    #[test]
10242    fn ask_user_bridge_answers_and_cancels_by_request_id() {
10243        let mailbox = rpi_extensions::UiDialogMailbox::new();
10244        mailbox.attach();
10245        let bridge = AskUserBridge::new(mailbox.clone());
10246
10247        // Two concurrent requests must not cross answers.
10248        mailbox
10249            .handle(serde_json::json!({
10250                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10251                "ui": {"kind": "input", "question": "Port?"}
10252            }))
10253            .unwrap();
10254        mailbox
10255            .handle(serde_json::json!({
10256                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10257                "ui": {"kind": "input", "question": "Level?"}
10258            }))
10259            .unwrap();
10260
10261        let first = bridge.take_pending().expect("r1 visible");
10262        assert_eq!(first.request_id, "r1");
10263        assert!(
10264            bridge.take_pending().is_none(),
10265            "only one request occupies the input slot"
10266        );
10267        bridge.respond("r1", serde_json::json!({"text": "7897"}));
10268        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10269
10270        let second = bridge.take_pending().expect("r2 visible");
10271        assert_eq!(second.request_id, "r2");
10272        bridge.cancel("r2");
10273        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10274
10275        // cancel_all drops anything still queued.
10276        mailbox
10277            .handle(serde_json::json!({
10278                "op": "open", "requestId": "r3",
10279                "ui": {"kind": "input", "question": "X?"}
10280            }))
10281            .unwrap();
10282        bridge.cancel_all();
10283        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10284
10285        // A detached mailbox rejects new prompts (headless contract).
10286        bridge.shutdown();
10287        assert!(mailbox
10288            .handle(serde_json::json!({
10289                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10290            }))
10291            .is_err());
10292    }
10293}