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    // ---- Session lifecycle (P1): SessionStart ----
6025    // The TUI + key worker are up and the session is ready to accept input.
6026    // Notify subscribed extensions now so they can initialize session-scoped
6027    // state. A veto here is **advisory** (the session is already up) — warn and
6028    // continue. No-op when no extension subscribes.
6029    if let Some(reason) = crate::session::dispatch_session_event_async(
6030        reload_context,
6031        rpi_plugin_sdk::EventTag::SessionStart,
6032    )
6033    .await
6034    {
6035        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6036    }
6037
6038    // ---- Initial prompts (run before reading from the channel) ----
6039    let mut prompts: Vec<String> = Vec::new();
6040    if let Some(init) = initial {
6041        prompts.push(init);
6042    }
6043    for m in extra_messages {
6044        prompts.push(m.clone());
6045    }
6046    let mut images = initial_images;
6047    for prompt in prompts {
6048        if !*running.lock().unwrap() {
6049            break;
6050        }
6051        add_user_message(&chat_container, &prompt);
6052        tui.request_render(false);
6053        run_prompt_streaming(
6054            &lane,
6055            &prompt,
6056            &tui,
6057            &state,
6058            drain_handle.is_some(),
6059            reload_context.js_extension_session.as_ref(),
6060            &js_dialog_bridge,
6061            args,
6062            std::mem::take(&mut images),
6063        )
6064        .await;
6065    }
6066
6067    // ---- Main loop: process submitted input + lifecycle messages ----
6068    loop {
6069        if !*running.lock().unwrap() {
6070            break;
6071        }
6072        match rx.recv().await {
6073            Some(TuiMessage::UserInput(prompt)) => {
6074                // Clear the editor so the next prompt starts fresh (the submit
6075                // handler runs on the blocking key thread and can't mutate the
6076                // editor state safely there; clearing here, on the async loop,
6077                // keeps it on one thread).
6078                editor.clear();
6079                let prompt_images = state.take_pending_images();
6080                if !prompt_images.is_empty() {
6081                    add_note_message(
6082                        &chat_container,
6083                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6084                    );
6085                }
6086                run_prompt_streaming(
6087                    &lane,
6088                    &prompt,
6089                    &tui,
6090                    &state,
6091                    drain_handle.is_some(),
6092                    reload_context.js_extension_session.as_ref(),
6093                    &js_dialog_bridge,
6094                    args,
6095                    prompt_images,
6096                )
6097                .await;
6098            }
6099            Some(TuiMessage::ExternalEditorResult(result)) => {
6100                match result {
6101                    Ok(text) => {
6102                        let cursor = text.chars().count();
6103                        editor.set_text(&text);
6104                        editor.set_cursor(0, cursor);
6105                        add_note_message(&chat_container, "Draft updated from external editor.");
6106                    }
6107                    Err(error) => add_error_message(&chat_container, &error),
6108                }
6109                tui.request_render(false);
6110            }
6111            Some(TuiMessage::OpenTree) => {
6112                if *state.status.lock().unwrap() != RunStatus::Idle {
6113                    add_note_message(
6114                        &chat_container,
6115                        "Wait for the current run to finish before opening the tree.",
6116                    );
6117                    tui.request_render(false);
6118                } else {
6119                    open_tree_selector(
6120                        &harness,
6121                        &state,
6122                        &editor_container,
6123                        &editor,
6124                        &tui,
6125                        &chat_container,
6126                        &tx,
6127                    )
6128                    .await;
6129                }
6130            }
6131            Some(TuiMessage::NavigateTree(entry_id)) => {
6132                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6133                    Ok(result) => match result.outcome {
6134                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6135                            chat_container.clear();
6136                            add_welcome_message(&chat_container);
6137                            render_session_history(
6138                                &harness,
6139                                &chat_container,
6140                                state.markdown_transformer(),
6141                                Some(state.extension_session.clone()),
6142                            )
6143                            .await;
6144                            add_note_message(
6145                                &chat_container,
6146                                "Moved to the selected session entry.",
6147                            );
6148                        }
6149                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6150                            add_error_message(&chat_container, &error.message);
6151                        }
6152                        _ => add_note_message(
6153                            &chat_container,
6154                            "The selected entry could not be opened.",
6155                        ),
6156                    },
6157                    Err(error) => add_error_message(
6158                        &chat_container,
6159                        &format!("Could not navigate session tree: {error}"),
6160                    ),
6161                }
6162                tui.request_render(false);
6163            }
6164            Some(TuiMessage::ClearChat) => {
6165                chat_container.clear();
6166                add_welcome_message(&chat_container);
6167                tui.request_render(false);
6168            }
6169            Some(TuiMessage::Compact) => {
6170                run_compact(&lane, &tui, &state).await;
6171            }
6172            Some(TuiMessage::Copy) => {
6173                copy_last_assistant(&state, &chat_container);
6174                tui.request_render(false);
6175            }
6176            Some(TuiMessage::Exit) => {
6177                *running.lock().unwrap() = false;
6178                break;
6179            }
6180            Some(TuiMessage::SwitchSession(id)) => {
6181                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6182                tui.request_render(false);
6183            }
6184            Some(TuiMessage::ImportSession(path)) => {
6185                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6186                tui.request_render(false);
6187            }
6188            Some(TuiMessage::ShareSession) => {
6189                share_session(&harness, &chat_container).await;
6190                tui.request_render(false);
6191            }
6192            Some(TuiMessage::SetSessionName(name)) => {
6193                let outcome = harness.session().set_name(Some(&name)).await;
6194                match outcome {
6195                    Ok(_) => add_note_message(
6196                        &chat_container,
6197                        &format!("Session renamed to \"{name}\"."),
6198                    ),
6199                    Err(e) => add_error_message(
6200                        &chat_container,
6201                        &format!("Could not rename session: {e}"),
6202                    ),
6203                }
6204                tui.request_render(false);
6205            }
6206            Some(TuiMessage::ExportSession) => {
6207                export_session(&harness, &chat_container, &cwd).await;
6208                tui.request_render(false);
6209            }
6210            Some(TuiMessage::ForkSession) => {
6211                fork_session(&harness, &cwd, &chat_container, &state).await;
6212                tui.request_render(false);
6213            }
6214            Some(TuiMessage::ReloadExtensions) => {
6215                // B5d: drive the shared reload routine on the async runtime,
6216                // then surface the outcome. `reload_context` was passed into
6217                // `interactive_tui` and is the same `Arc<ReloadContext>` the
6218                // `ReloadCommand` + the plugin mailbox both route through —
6219                // clone the `Arc` out so the borrow of `harness` (the main
6220                // loop's `&AgentHarness`) lives across the await.
6221                let reload_ctx = ctx.reload_context.clone();
6222                add_note_message(&chat_container, "Reloading extensions + resources…");
6223                tui.request_render(false);
6224                let outcome =
6225                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6226                // B5e: the reload swapped a fresh `ExtensionSession` into the
6227                // context's cell. Rebuild the markdown transformer from that
6228                // fresh snapshot and install it on the in-flight streaming
6229                // component (so a reloaded plugin's transformer takes effect on
6230                // the visible message immediately) + future components (they
6231                // read `state.markdown_transformer()` at construction). The old
6232                // closure no-ops once its snapshot's `active` flag flips false
6233                // (reload already did that before the swap).
6234                let fresh_transformer = build_markdown_transformer(
6235                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6236                );
6237                state.set_markdown_transformer_with_reinstall(fresh_transformer);
6238                if outcome.had_errors {
6239                    // Hard failure: the summary itself carries the error detail
6240                    // (e.g. "Settings reload failed: …"), so render it as an
6241                    // error without an extra stderr pointer.
6242                    add_error_message(&chat_container, &outcome.summary);
6243                } else if outcome.had_warnings {
6244                    // Reload succeeded but produced soft diagnostics (skill
6245                    // shadowing, name validation, …). The details are printed
6246                    // to stderr; keep this as an informational note so a
6247                    // successful reload isn't shown as a red ✗ error.
6248                    add_note_message(
6249                        &chat_container,
6250                        &format!(
6251                            "{} (with warnings — see stderr for details).",
6252                            outcome.summary
6253                        ),
6254                    );
6255                } else {
6256                    add_note_message(&chat_container, &outcome.summary);
6257                }
6258                tui.request_render(false);
6259            }
6260            None => break,
6261        }
6262    }
6263
6264    // ---- Shutdown ----
6265    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
6266    // bridge before joining the key worker and restoring the terminal.
6267    js_dialog_bridge.cancel_all();
6268    ask_user_bridge.shutdown();
6269    *running.lock().unwrap() = false;
6270    for handle in update_check_handles {
6271        handle.abort();
6272        let _ = handle.await;
6273    }
6274    // A hidden custom input listener can leave the key worker blocked on a
6275    // synchronous Node response. Stop the host first so transport shutdown
6276    // wakes that request before we join the worker.
6277    if let Some(js) = &reload_context.js_extension_session {
6278        js.shutdown();
6279    }
6280    // The input worker checks `running` at least every 50ms. Join it before
6281    // restoring cooked mode so no late event read races terminal cleanup.
6282    let _ = key_handle.await;
6283    tick_handle.abort();
6284    if let Some(handle) = drain_handle {
6285        handle.abort();
6286    }
6287    // Drop the reload bridge: clearing the mailbox closes the signal channel,
6288    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
6289    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
6290    reload_context.mailbox.clear();
6291    reload_bridge_handle.abort();
6292    tui.stop(Default::default());
6293    println!("\nGoodbye!");
6294    let _ = args;
6295
6296    0
6297}
6298
6299// ===========================================================================
6300// Run a single prompt (streaming or blocking)
6301// ===========================================================================
6302
6303/// Prepare the session's persistent Node host immediately before a real prompt
6304/// enters the agent loop. The first call starts the lazy host; later calls run
6305/// `before_agent_start` again on that host so each prompt sees current state.
6306/// Keeping startup here leaves an idle TUI free of a Node child while still
6307/// giving the lifecycle hook the fully installed UI bridge.
6308async fn ensure_js_runtime_before_prompt(
6309    js: Option<&crate::js_extensions::JsExtensionSession>,
6310    lane: &Arc<dyn AgentLane>,
6311    state: &Arc<TuiState>,
6312    dialog_bridge: &JsDialogBridge,
6313    args: &Args,
6314) -> bool {
6315    let Some(js) = js else {
6316        return true;
6317    };
6318    let cancellation = state.begin_js_preparation();
6319    let worker_cancellation = cancellation.clone();
6320    let js_for_start = js.clone();
6321    let mut worker = tokio::task::spawn_blocking(move || {
6322        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6323    });
6324    let result = tokio::select! {
6325        result = &mut worker => result,
6326        _ = cancellation.cancelled() => {
6327            dialog_bridge.cancel_open_requests();
6328            let js_for_cancel = js.clone();
6329            let _ = tokio::task::spawn_blocking(move || {
6330                js_for_cancel.cancel_prompt_preparation();
6331            }).await;
6332            worker.await
6333        }
6334    };
6335    let was_cancelled = cancellation.is_cancelled();
6336    if was_cancelled {
6337        dialog_bridge.cancel_open_requests();
6338        let js_for_cancel = js.clone();
6339        let _ = tokio::task::spawn_blocking(move || {
6340            js_for_cancel.cancel_prompt_preparation();
6341        })
6342        .await;
6343        state.finish_js_preparation();
6344        dialog_bridge.reopen();
6345        return false;
6346    }
6347    state.finish_js_preparation();
6348    match result {
6349        Ok(Ok(())) => {
6350            // The lifecycle hook can change the JS-only active tool set once
6351            // it sees the real TUI context. Merge that subset with the Rust
6352            // built-ins while applying the command-line tool policy.
6353            if let Some(js_active) = js.active_tools() {
6354                let js_names = js.tool_names();
6355                let mut active = lane.get_active_tools().await.unwrap_or_default();
6356                active.retain(|name| {
6357                    crate::session::tool_name_allowed(name, args)
6358                        && !js_names.iter().any(|js_name| js_name == name)
6359                });
6360                active.extend(js_active.into_iter().filter(|name| {
6361                    js_names.iter().any(|js_name| js_name == name)
6362                        && crate::session::tool_name_allowed(name, args)
6363                }));
6364                active = crate::session::filter_active_tool_names(active, args);
6365                let _ = lane.set_active_tools(active).await;
6366            }
6367        }
6368        Ok(Err(error)) => {
6369            if args.verbose {
6370                eprintln!("warning: could not start JS extension runtime: {error}");
6371            }
6372        }
6373        Err(error) => {
6374            if args.verbose {
6375                eprintln!("warning: JS extension runtime worker failed: {error}");
6376            }
6377        }
6378    }
6379    true
6380}
6381
6382fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6383    std::thread::spawn(move || {
6384        let file = match tempfile::Builder::new()
6385            .prefix("rpi-draft-")
6386            .suffix(".md")
6387            .tempfile()
6388        {
6389            Ok(file) => file,
6390            Err(error) => {
6391                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6392                    "Could not create editor file: {error}"
6393                ))));
6394                return;
6395            }
6396        };
6397        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6398            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6399                "Could not write editor file: {error}"
6400            ))));
6401            return;
6402        }
6403        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6404            .ok()
6405            .filter(|value| !value.trim().is_empty())
6406            .or_else(|| std::env::var("VISUAL").ok())
6407            .or_else(|| std::env::var("EDITOR").ok())
6408            .unwrap_or_else(|| {
6409                if cfg!(windows) {
6410                    "notepad".to_string()
6411                } else {
6412                    "nano".to_string()
6413                }
6414            });
6415        let status = std::process::Command::new(&editor)
6416            .arg(file.path())
6417            .status();
6418        let result = match status {
6419            Ok(status) if status.success() => std::fs::read_to_string(file.path())
6420                .map_err(|error| format!("Could not read editor file: {error}")),
6421            Ok(status) => Err(format!("External editor exited with {status}")),
6422            Err(error) => Err(format!(
6423                "Could not launch external editor `{editor}`: {error}"
6424            )),
6425        };
6426        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6427    });
6428}
6429
6430/// Apply the authoritative run result when it wins the race with the async
6431/// event drain, then detach the live component from further partial updates.
6432fn reconcile_streamed_assistant_completion(
6433    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6434    last_assistant_text: &Mutex<String>,
6435    final_message: Option<&AssistantMessage>,
6436) {
6437    let final_blocks = final_message.map(assistant_blocks);
6438    let final_text = final_message.map(assistant_text);
6439    let component = current_assistant.lock().unwrap().take();
6440
6441    if let Some(component) = component {
6442        if let Some(blocks) = final_blocks.as_deref() {
6443            component.update_blocks(blocks);
6444        }
6445        component.set_streaming(false);
6446    }
6447
6448    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6449        *last_assistant_text.lock().unwrap() = text;
6450    }
6451}
6452
6453/// Drive a single prompt through the lane. When `streaming` is true, the
6454/// `AgentEvent` drain task renders the response live and the completed outcome
6455/// reconciles its final snapshot. When false (no `event_rx`), this falls back
6456/// to the blocking await-final-text path.
6457async fn run_prompt_streaming(
6458    lane: &Arc<dyn AgentLane>,
6459    prompt: &str,
6460    tui: &Arc<TuiAltScreen>,
6461    state: &Arc<TuiState>,
6462    streaming: bool,
6463    js: Option<&crate::js_extensions::JsExtensionSession>,
6464    dialog_bridge: &JsDialogBridge,
6465    args: &Args,
6466    images: Vec<rpi_ai::types::ImageContent>,
6467) {
6468    // The persistent Node host is intentionally started at the first real
6469    // prompt. By this point the TUI key worker and all UI/runtime handlers are
6470    // live, so a `before_agent_start` hook may safely open a native dialog. A
6471    // session with no prompt never starts Node merely to render its welcome
6472    // screen; JS commands/tools still trigger the same lazy ensure path.
6473    // Preparation is part of the active turn. Mark it working before Node can
6474    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
6475    // semantics for initial argv prompts as well as editor submissions.
6476    state.set_status(RunStatus::Working);
6477    tui.request_render(false);
6478    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6479        state.set_status(RunStatus::Idle);
6480        tui.request_render(false);
6481        return;
6482    }
6483
6484    let outcome = lane.prompt_text(prompt, images).await;
6485
6486    if streaming {
6487        // Broadcast delivery is asynchronous: the harness result can resolve
6488        // before the drain task processes MessageEnd. Reconcile from the
6489        // authoritative outcome before detaching the component so the final
6490        // streamed tail cannot be left at an earlier partial snapshot.
6491        let final_message = match &outcome {
6492            Ok(result) => match &result.outcome {
6493                HarnessRunOutcome::Completed { final_message, .. }
6494                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6495                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6496                HarnessRunOutcome::Suspended { .. } => None,
6497            },
6498            Err(_) => None,
6499        };
6500        reconcile_streamed_assistant_completion(
6501            &state.current_assistant,
6502            &state.last_assistant_text,
6503            final_message,
6504        );
6505    }
6506
6507    state.set_status(RunStatus::Idle);
6508
6509    match outcome {
6510        Ok(result) => match &result.outcome {
6511            HarnessRunOutcome::Failed {
6512                error,
6513                final_message,
6514                ..
6515            } => {
6516                // Only add an error line if the stream did NOT already render
6517                // an assistant message for it (drain task leaves
6518                // current_assistant Some only on an abrupt end).
6519                // A final assistant error is emitted by the event drain only
6520                // in streaming mode. In regular mode there is no drain task,
6521                // so suppressing this branch merely hides 405/auth/network
6522                // diagnostics from the user.
6523                let already_rendered = streaming && final_message.is_some();
6524                if !already_rendered {
6525                    let msg = final_message
6526                        .as_ref()
6527                        .and_then(|m| m.error_message.clone())
6528                        .unwrap_or_else(|| format!("{error:?}"));
6529                    add_error_message(&state.chat_container, &msg);
6530                }
6531            }
6532            HarnessRunOutcome::Suspended { .. } => {
6533                add_error_message(
6534                    &state.chat_container,
6535                    "Run suspended (deferred) — resume is not supported in v1.",
6536                );
6537            }
6538            HarnessRunOutcome::Aborted { final_message, .. } => {
6539                // Aborted runs render their own partial/final message via the
6540                // stream; only add a note on the blocking fallback path.
6541                if !streaming {
6542                    add_error_message(&state.chat_container, "Request aborted.");
6543                    let _ = final_message; // (rendered by the stream in streaming mode)
6544                }
6545            }
6546            HarnessRunOutcome::Completed { final_message, .. } => {
6547                if !streaming {
6548                    let text = assistant_text(final_message);
6549                    if !text.is_empty() {
6550                        add_assistant_message_blocking(
6551                            &state.chat_container,
6552                            &text,
6553                            state.markdown_transformer(),
6554                        );
6555                        *state.last_assistant_text.lock().unwrap() = text;
6556                    }
6557                }
6558            }
6559        },
6560        Err(e) => {
6561            add_error_message(&state.chat_container, &e.to_string());
6562        }
6563    }
6564
6565    tui.request_render(false);
6566}
6567
6568/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
6569/// Reports the outcome as a transcript note; v1's compaction summarizes the
6570/// session in place, so no streaming display is wired (compaction emits no
6571/// `AgentEvent`s — only the harness bus `RunEnd`).
6572async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6573    state.set_status(RunStatus::Working);
6574    tui.request_render(false);
6575    match lane.compact(None).await {
6576        Ok(_) => {
6577            add_note_message(&state.chat_container, "Conversation compacted.");
6578        }
6579        Err(e) => {
6580            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6581        }
6582    }
6583    state.set_status(RunStatus::Idle);
6584    tui.request_render(false);
6585}
6586
6587/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
6588/// when no clipboard is available (or the `clipboard` feature is off), prints a
6589/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
6590fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6591    let text = state.last_assistant_text.lock().unwrap().clone();
6592    if text.is_empty() {
6593        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6594        return;
6595    }
6596    if copy_to_clipboard(&text) {
6597        add_note_message(chat, "Copied last reply to the clipboard.");
6598    } else {
6599        // Clipboard unavailable — print the text to the transcript so the user
6600        // can select/copy it manually (degrades gracefully in headless envs).
6601        let preview: String = text.chars().take(200).collect();
6602        add_note_message(
6603            chat,
6604            &format!(
6605                "Clipboard unavailable. Last reply: {preview}{}",
6606                if text.chars().count() > 200 {
6607                    "…"
6608                } else {
6609                    ""
6610                }
6611            ),
6612        );
6613    }
6614}
6615
6616/// Best-effort clipboard write. Enabled only with the `clipboard` feature
6617/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
6618#[cfg(feature = "clipboard")]
6619fn copy_to_clipboard(text: &str) -> bool {
6620    match arboard::Clipboard::new() {
6621        Ok(mut cb) => cb.set_text(text).is_ok(),
6622        Err(_) => false,
6623    }
6624}
6625
6626#[cfg(not(feature = "clipboard"))]
6627fn copy_to_clipboard(_text: &str) -> bool {
6628    false
6629}
6630
6631/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
6632/// `ImageContent` contract. The optional clipboard feature keeps headless
6633/// builds free of platform clipboard dependencies.
6634#[cfg(feature = "clipboard")]
6635fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6636    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6637    let image = match clipboard.get_image() {
6638        Ok(image) => image,
6639        Err(_) => return Ok(None),
6640    };
6641    let width =
6642        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6643    let height =
6644        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6645    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6646        return Err("clipboard image dimensions are outside the supported range".into());
6647    }
6648    let mut bytes = Vec::new();
6649    {
6650        let mut encoder = png::Encoder::new(&mut bytes, width, height);
6651        encoder.set_color(png::ColorType::Rgba);
6652        encoder.set_depth(png::BitDepth::Eight);
6653        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6654        writer
6655            .write_image_data(&image.bytes)
6656            .map_err(|e| e.to_string())?;
6657    }
6658    Ok(Some(rpi_ai::types::ImageContent {
6659        kind: rpi_ai::types::ImageContentType,
6660        data: base64::engine::general_purpose::STANDARD.encode(bytes),
6661        mime_type: "image/png".into(),
6662    }))
6663}
6664
6665fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6666    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6667        let mut options = ImageOptions::default();
6668        options.width = Some(48);
6669        options.alt_text = Some("Attached image".into());
6670        chat.add_child(Arc::new(Image::from_data(bytes, options)));
6671        chat.add_child(Arc::new(Spacer::new(1)));
6672    }
6673}
6674
6675#[cfg(not(feature = "clipboard"))]
6676fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6677    Ok(None)
6678}
6679
6680/// Blocking fallback (no `event_rx`): render the final assistant text as a
6681/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
6682/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
6683/// the identity path. The blocking path only fires when `event_rx` is absent,
6684/// so it shares the same transformer the streaming path installs on its
6685/// components.
6686fn add_assistant_message_blocking(
6687    container: &Arc<Container>,
6688    text: &str,
6689    transformer: Option<MarkdownTransformer>,
6690) {
6691    if text.is_empty() {
6692        return;
6693    }
6694    let msg = Arc::new(AssistantMessageComponent::new(
6695        AssistantMessageOptions::default(),
6696    ));
6697    if let Some(t) = &transformer {
6698        msg.set_markdown_transformer(Some(t.clone()));
6699    }
6700    msg.update_text(text);
6701    container.add_child(msg);
6702    container.add_child(Arc::new(Spacer::new(1)));
6703}
6704
6705// ===========================================================================
6706// AgentEvent drain task — the streaming core
6707// ===========================================================================
6708
6709/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
6710/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
6711/// lifetime of the TUI.
6712async fn drain_agent_events(
6713    mut rx: broadcast::Receiver<AgentEvent>,
6714    tui: Arc<TuiAltScreen>,
6715    state: Arc<TuiState>,
6716    chat: Arc<Container>,
6717) {
6718    loop {
6719        match rx.recv().await {
6720            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6721            Err(broadcast::error::RecvError::Lagged(_)) => {
6722                // We dropped some intermediate deltas; the next MessageUpdate/
6723                // MessageEnd carries a full partial snapshot so the UI re-syncs.
6724                continue;
6725            }
6726            Err(broadcast::error::RecvError::Closed) => break,
6727        }
6728    }
6729}
6730
6731/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
6732/// (`interactive-mode.ts:3068-3396`).
6733async fn handle_agent_event(
6734    event: AgentEvent,
6735    tui: &Arc<TuiAltScreen>,
6736    state: &Arc<TuiState>,
6737    chat: &Arc<Container>,
6738) {
6739    match event {
6740        AgentEvent::AgentStart => {
6741            state.set_status(RunStatus::Working);
6742            tui.request_render(false);
6743        }
6744
6745        AgentEvent::AgentEnd { .. } => {
6746            // Finalize any still-streaming assistant message.
6747            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6748                comp.set_streaming(false);
6749            }
6750            state.set_status(RunStatus::Idle);
6751            tui.request_render(false);
6752        }
6753
6754        AgentEvent::RetryScheduled {
6755            attempt,
6756            max_retries,
6757            delay_ms,
6758            ..
6759        } => {
6760            state.show_retry(attempt, max_retries, delay_ms);
6761            tui.request_render(false);
6762        }
6763
6764        AgentEvent::TurnStart => {
6765            // A new turn: reset the streaming-assistant guard so the next
6766            // MessageStart creates a fresh component.
6767            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6768                comp.set_streaming(false);
6769            }
6770        }
6771
6772        AgentEvent::TurnEnd {
6773            message,
6774            tool_results,
6775        } => {
6776            // Finalize the assistant message for this turn.
6777            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6778                if let AgentMessage::Assistant(a) = &message {
6779                    comp.update_blocks(&assistant_blocks(a));
6780                }
6781                comp.set_streaming(false);
6782            }
6783            // Any tool results whose components were never ended by a
6784            // ToolExecutionEnd get a static rendering here (best-effort). The
6785            // normal path removes the component via ToolExecutionEnd; this is
6786            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
6787            let tools = state.tool_components.lock().unwrap();
6788            for tr in &tool_results {
6789                if tools.contains_key(&tr.tool_call_id) {
6790                    // Will be removed below via ToolExecutionEnd in the normal
6791                    // path; leave as-is if still present.
6792                    let _ = tr;
6793                }
6794            }
6795            drop(tools);
6796            tui.request_render(false);
6797        }
6798
6799        AgentEvent::MessageStart { message } => match message {
6800            AgentMessage::Assistant(a) => {
6801                let comp = Arc::new(AssistantMessageComponent::new(
6802                    AssistantMessageOptions::default(),
6803                ));
6804                // B5e: install the live markdown transformer so the plugin's
6805                // `register_markdown_transformer` handlers apply from the very
6806                // first streamed delta. `set_streaming` before the transform
6807                // install is fine (transform fires on `update_blocks`, below).
6808                if let Some(t) = state.markdown_transformer() {
6809                    comp.set_markdown_transformer(Some(t));
6810                }
6811                comp.set_hide_thinking(state.hide_thinking());
6812                comp.set_streaming(true);
6813                // Render text AND thinking blocks in order (the old path fed
6814                // only the concatenated text, so thinking blocks never showed).
6815                comp.update_blocks(&assistant_blocks(&a));
6816                chat.add_child(comp.clone());
6817                // Spacer(1) separates this assistant turn from the next entry;
6818                // the component itself adds no leading spacer.
6819                chat.add_child(Arc::new(Spacer::new(1)));
6820                *state.current_assistant.lock().unwrap() = Some(comp);
6821                tui.request_render(false);
6822            }
6823            AgentMessage::Custom(custom) => {
6824                let payload = serde_json::json!({
6825                    "customType": custom.role,
6826                    "content": custom.content,
6827                    "details": custom.data,
6828                    "expanded": false,
6829                    "outputPad": 1,
6830                });
6831                if let Some(component) = extension_message_component(
6832                    &state.extension_session,
6833                    &custom.role,
6834                    &payload,
6835                    state.markdown_transformer(),
6836                ) {
6837                    chat.add_child(component);
6838                    chat.add_child(Arc::new(Spacer::new(1)));
6839                    tui.request_render(false);
6840                } else {
6841                    add_note_message(chat, &custom_message_fallback(&custom));
6842                    tui.request_render(false);
6843                }
6844            }
6845            // User / ToolResult / Custom starts are echoed at submit time or
6846            // via the tool-execution components; ignore user/tool dupes.
6847            _ => {}
6848        },
6849
6850        AgentEvent::MessageUpdate {
6851            message,
6852            assistant_message_event,
6853        } => {
6854            if let AgentMessage::Assistant(a) = &message {
6855                let text = assistant_text(a);
6856                let mut saw_bash_tool_call = false;
6857                // Scan content for finalized tool calls → proactively create
6858                // tool components (TS shows the tool as soon as the assistant
6859                // emits the ToolCall; ToolExecutionStart coalesces if it
6860                // already exists).
6861                for c in &a.content {
6862                    if let Content::ToolCall(tc) = c {
6863                        // Streaming providers may expose a placeholder tool
6864                        // call before its name has arrived. It is not a real
6865                        // tool panel and must not leave an empty first row.
6866                        if tc.name.trim().is_empty() {
6867                            continue;
6868                        }
6869                        if tc.name == "bash" {
6870                            // Bash has a dedicated component. Create it here as
6871                            // well as on ToolExecutionStart because the tool
6872                            // call can become visible in a MessageUpdate first.
6873                            // Keeping it in the bash map lets Start coalesce
6874                            // with this panel instead of appending a second one.
6875                            let command = tc
6876                                .arguments
6877                                .get("command")
6878                                .and_then(|v| v.as_str())
6879                                .unwrap_or("");
6880                            // Streaming tool-call arguments may still be `{}`
6881                            // here. Do not create a running bash panel until
6882                            // the lifecycle start event provides the command;
6883                            // otherwise the spinner renders first and the
6884                            // actual `$ command` header appears one frame
6885                            // later.
6886                            if command.trim().is_empty() {
6887                                continue;
6888                            }
6889                            saw_bash_tool_call = true;
6890                            let mut bash = state.bash_components.lock().unwrap();
6891                            if let Some(existing) = bash.get(&tc.id) {
6892                                // Tool-call arguments arrive incrementally.
6893                                // Refresh the running panel from every full
6894                                // assistant snapshot instead of leaving its
6895                                // header on the first partial command.
6896                                existing.set_command(command);
6897                            } else {
6898                                let comp = Arc::new(BashExecutionComponent::new(command));
6899                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6900                                chat.add_child(comp.clone());
6901                                bash.insert(tc.id.clone(), comp);
6902                            }
6903                        } else {
6904                            let mut tools = state.tool_components.lock().unwrap();
6905                            let display_args = if is_ask_user_tool(&tc.name) {
6906                                ask_user_args_display(&tc.arguments)
6907                            } else {
6908                                tc.arguments.to_string()
6909                            };
6910                            if let Some(existing) = tools.get(&tc.id) {
6911                                // Regular tool arguments stream incrementally
6912                                // too, so refresh their live header as soon as
6913                                // a more complete snapshot arrives.
6914                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
6915                                    existing.set_args(&display_args);
6916                                }
6917                            } else {
6918                                let comp = Arc::new(ToolExecutionComponent::new(
6919                                    &tc.name,
6920                                    &display_args,
6921                                ));
6922                                if is_ask_user_tool(&tc.name) {
6923                                    comp.set_display_title("ASK USER");
6924                                }
6925                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6926                                comp.set_running();
6927                                chat.add_child(comp.clone());
6928                                tools.insert(tc.id.clone(), comp);
6929                            }
6930                        }
6931                    }
6932                }
6933                // MessageUpdate can expose the finalized bash call before
6934                // ToolExecutionStart arrives. Hide the global `Working…`
6935                // loader immediately when creating that bash panel; otherwise
6936                // it briefly appears alongside the panel's `Running…` spinner.
6937                if saw_bash_tool_call {
6938                    state.sync_working_loader_with_bash();
6939                }
6940                let _ = assistant_message_event; // snapshot already applied via `a`
6941                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6942                    // Stream the full block list (text + thinking) each update
6943                    // so thinking blocks render live as they arrive.
6944                    comp.update_blocks(&assistant_blocks(a));
6945                }
6946                *state.last_assistant_text.lock().unwrap() = text;
6947                tui.request_render(false);
6948            }
6949        }
6950
6951        AgentEvent::MessageEnd { message } => {
6952            if let AgentMessage::Assistant(a) = &message {
6953                let text = assistant_text(a);
6954                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6955                    comp.update_blocks(&assistant_blocks(a));
6956                    comp.set_streaming(false);
6957                }
6958                // Cache the finalized text for `/copy`.
6959                if !text.is_empty() {
6960                    *state.last_assistant_text.lock().unwrap() = text;
6961                }
6962                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
6963                // the previous turn's input established a cacheable prefix; a
6964                // large input this turn that read nothing from cache means the
6965                // prefix was re-billed. No cost display — v1 has no per-run
6966                // cost tracking here.
6967                let usage = &a.usage;
6968                let prev_input = *state.last_input_tokens.lock().unwrap();
6969                if prev_input > 0
6970                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6971                    && usage.cache_read == 0
6972                {
6973                    add_note_message(
6974                        &state.chat_container,
6975                        &format!(
6976                            "Cache miss: {} tokens re-billed",
6977                            format_tokens(usage.input)
6978                        ),
6979                    );
6980                }
6981                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6982                    add_note_message(chat, &text);
6983                }
6984                // Error assistant messages carry the provider diagnostic in
6985                // `error_message`, not in text content. The assistant
6986                // component is empty for these messages, so surface the
6987                // diagnostic as a visible error row in the transcript.
6988                if let Some(error) = assistant_error_text(a) {
6989                    add_error_message(chat, &error);
6990                }
6991                *state.last_input_tokens.lock().unwrap() = usage.input;
6992            }
6993            tui.request_render(false);
6994        }
6995
6996        AgentEvent::ToolExecutionStart {
6997            tool_call_id,
6998            tool_name,
6999            args,
7000        } => {
7001            // Ignore placeholder lifecycle events emitted before the
7002            // provider has supplied a tool name.
7003            if tool_name.trim().is_empty() {
7004                return;
7005            }
7006            if tool_name == "bash" {
7007                // Bash streams into a dedicated BashExecutionComponent (command
7008                // header + live preview + exit/truncation status) rather than a
7009                // generic ToolExecutionComponent. The command comes from the
7010                // `command` field of the bash tool args.
7011                let command = args
7012                    .get("command")
7013                    .and_then(|v| v.as_str())
7014                    .unwrap_or("")
7015                    .to_string();
7016                // Bash arguments can still be `{}` when the lifecycle event
7017                // races the streamed tool-call argument finalization. Defer
7018                // the panel until a later start event carries the command.
7019                if command.trim().is_empty() {
7020                    return;
7021                }
7022                let mut bash_map = state.bash_components.lock().unwrap();
7023                if let Some(existing) = bash_map.get(&tool_call_id) {
7024                    // A ToolExecutionUpdate already created the panel (fast
7025                    // command — Update can arrive before Start); backfill the
7026                    // command header instead of adding a SECOND panel, which
7027                    // used to stack an empty "$ " box above the real one.
7028                    existing.set_command(&command);
7029                } else {
7030                    let comp = Arc::new(BashExecutionComponent::new(command));
7031                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7032                    chat.add_child(comp.clone());
7033                    bash_map.insert(tool_call_id.clone(), comp);
7034                }
7035            } else {
7036                let _comp = {
7037                    let mut tools = state.tool_components.lock().unwrap();
7038                    if let Some(existing) = tools.get(&tool_call_id) {
7039                        if is_ask_user_tool(&tool_name) {
7040                            existing.set_display_title("ASK USER");
7041                            existing.set_args(&ask_user_args_display(&args));
7042                        } else {
7043                            existing.set_args(&args.to_string());
7044                        }
7045                        existing.clone()
7046                    } else {
7047                        let display_args = if is_ask_user_tool(&tool_name) {
7048                            ask_user_args_display(&args)
7049                        } else {
7050                            args.to_string()
7051                        };
7052                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7053                        if is_ask_user_tool(&tool_name) {
7054                            comp.set_display_title("ASK USER");
7055                        }
7056                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7057                        // A `read` of a SKILL.md renders as native Pi's
7058                        // `[skill] <name>` invocation box (custom-message
7059                        // background, collapsed to one line, Ctrl+O expands the
7060                        // skill markdown) instead of a generic READ tool panel.
7061                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
7062                            comp.set_skill_name(skill);
7063                        }
7064                        comp.set_running();
7065                        chat.add_child(comp.clone());
7066                        tools.insert(tool_call_id.clone(), comp.clone());
7067                        comp
7068                    }
7069                };
7070            }
7071            state.sync_working_loader_with_bash();
7072            tui.request_render(false);
7073        }
7074
7075        AgentEvent::ToolExecutionUpdate {
7076            tool_call_id,
7077            tool_name,
7078            args,
7079            partial_result,
7080        } => {
7081            if tool_name.trim().is_empty() {
7082                return;
7083            }
7084            let partial_text = if is_ask_user_tool(&tool_name) {
7085                ask_user_progress_text(&args, &partial_result)
7086            } else {
7087                tool_result_text(&partial_result)
7088            };
7089            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7090            if tool_name == "bash" {
7091                // Append the streamed chunk to the bash component's preview.
7092                // RAW text (no single-line collapsing) — the old
7093                // `summarize_tool_result` folded every newline into a `⏎`
7094                // glyph, cramming e.g. `ls -la`'s listing onto one line.
7095                let chunk = partial_text;
7096                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7097                    // Lifecycle updates can contain a more complete args object
7098                    // than the snapshot that created this running panel.
7099                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7100                        bash.set_command(command);
7101                    }
7102                    if has_partial_payload {
7103                        bash.append_output(&chunk);
7104                    }
7105                } else if has_partial_payload {
7106                    // ToolExecutionStart is emitted before a tool can run.
7107                    // Ignore an out-of-order partial until that event gives us
7108                    // the real command, rather than showing a spinner above an
7109                    // empty `$ ` header. Normal updates are handled by the
7110                    // component created in ToolExecutionStart.
7111                }
7112            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7113                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7114                    comp.set_skill_name(skill);
7115                }
7116                if is_ask_user_tool(&tool_name) {
7117                    comp.set_display_title("ASK USER");
7118                    comp.set_args(&ask_user_args_display(&args));
7119                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7120                    comp.set_args(&args.to_string());
7121                }
7122                // Raw multi-line text — read/ls-style tools must show their
7123                // full content, not the single-line ⏎-folded summary.
7124                if has_partial_payload {
7125                    comp.set_result(&partial_text, false);
7126                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7127                }
7128            } else if has_partial_payload {
7129                // No component yet — create a running one so the partial shows.
7130                // Empty callbacks are common before ToolExecutionStart; wait
7131                // for Start so the first panel has the real arguments instead
7132                // of an empty `TOOLS` box.
7133                let display_args = if is_ask_user_tool(&tool_name) {
7134                    ask_user_args_display(&args)
7135                } else {
7136                    args.to_string()
7137                };
7138                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7139                if is_ask_user_tool(&tool_name) {
7140                    comp.set_display_title("ASK USER");
7141                }
7142                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7143                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7144                    comp.set_skill_name(skill);
7145                }
7146                comp.set_running();
7147                comp.set_result(&partial_text, false);
7148                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7149                chat.add_child(comp.clone());
7150                state
7151                    .tool_components
7152                    .lock()
7153                    .unwrap()
7154                    .insert(tool_call_id.clone(), comp.clone());
7155            }
7156            state.sync_working_loader_with_bash();
7157            tui.request_render(false);
7158        }
7159
7160        AgentEvent::ToolExecutionEnd {
7161            tool_call_id,
7162            tool_name,
7163            result,
7164            is_error,
7165        } => {
7166            if tool_name.trim().is_empty() {
7167                return;
7168            }
7169            if tool_name == "bash" {
7170                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7171                if let Some(bash) = bash {
7172                    finalize_bash(&bash, &result, is_error);
7173                } else {
7174                    // Bash ended without a Start/Update — render a finalized
7175                    // component directly from the result text.
7176                    let command = result
7177                        .details
7178                        .get("command")
7179                        .and_then(|v| v.as_str())
7180                        .unwrap_or("")
7181                        .to_string();
7182                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7183                        state.sync_working_loader_with_bash();
7184                        // Edge case: empty bash result. Still render immediately
7185                        // to clear the panel state for the user.
7186                        tui.render_now(false);
7187                        return;
7188                    }
7189                    let comp = Arc::new(BashExecutionComponent::new(command));
7190                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7191                    comp.append_output(&tool_result_text(&result));
7192                    finalize_bash(&comp, &result, is_error);
7193                    chat.add_child(comp);
7194                }
7195            } else {
7196                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7197                if let Some(comp) = comp {
7198                    let result_text = if is_ask_user_tool(&tool_name) {
7199                        ask_user_result_text(&result)
7200                    } else {
7201                        tool_result_text(&result)
7202                    };
7203                    comp.set_result(&result_text, is_error);
7204                    if !is_ask_user_tool(&tool_name) {
7205                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7206                    }
7207                } else {
7208                    // Tool ended without a Start/Update (e.g. a very fast tool):
7209                    // render a finalized component directly.
7210                    let comp = Arc::new(ToolExecutionComponent::new(
7211                        &tool_name,
7212                        &if is_ask_user_tool(&tool_name) {
7213                            ask_user_args_display(&serde_json::Value::Null)
7214                        } else {
7215                            "".to_string()
7216                        },
7217                    ));
7218                    if is_ask_user_tool(&tool_name) {
7219                        comp.set_display_title("ASK USER");
7220                    }
7221                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7222                    let result_text = if is_ask_user_tool(&tool_name) {
7223                        ask_user_result_text(&result)
7224                    } else {
7225                        tool_result_text(&result)
7226                    };
7227                    comp.set_result(&result_text, is_error);
7228                    if !is_ask_user_tool(&tool_name) {
7229                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7230                    }
7231                    chat.add_child(comp.clone());
7232                }
7233            }
7234            state.sync_working_loader_with_bash();
7235            // Bash tool completion is user-visible: immediate render ensures the
7236            // result is displayed even when the scheduler thread is busy or the
7237            // throttle window has not elapsed. This single immediate frame does
7238            // not regress the streaming-output throttle that keeps CPU/GPU use
7239            // low (bash completions are discrete events, not per-token bursts).
7240            tui.render_now(false);
7241        }
7242    }
7243}
7244
7245/// Return the diagnostic carried by a failed or aborted provider request.
7246/// Providers may omit `error_message`; keep a stable fallback so a terminal
7247/// request failure can never render as an empty transcript turn.
7248fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7249    let fallback = match message.stop_reason {
7250        rpi_ai::StopReason::Error => "Provider request failed.",
7251        rpi_ai::StopReason::Aborted => "Request aborted.",
7252        _ => return None,
7253    };
7254    Some(
7255        message
7256            .error_message
7257            .as_deref()
7258            .filter(|text| !text.trim().is_empty())
7259            .unwrap_or(fallback)
7260            .to_string(),
7261    )
7262}
7263
7264/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
7265/// tool result and mark the component complete. Mirrors the TS bash finalize
7266/// path; only the fields `BashExecutionComponent` needs are read.
7267fn finalize_bash(
7268    comp: &Arc<BashExecutionComponent>,
7269    result: &rpi_agent::AgentToolResult,
7270    is_error: bool,
7271) {
7272    // The exit code isn't in details directly (TS carries it elsewhere); use
7273    // `is_error` as the error signal and 0/1 as a best-effort exit code.
7274    let exit_code = if is_error { Some(1) } else { Some(0) };
7275    let truncated = result
7276        .details
7277        .get("truncation")
7278        .and_then(|t| t.get("truncated"))
7279        .and_then(|v| v.as_bool())
7280        .unwrap_or(false);
7281    let full_output_path = result
7282        .details
7283        .get("full_output_path")
7284        .and_then(|v| v.as_str())
7285        .map(|s| s.to_string());
7286    let truncation = BashTruncation {
7287        truncated,
7288        full_output_path,
7289    };
7290    // Some tool backends attach the authoritative command to result details.
7291    // Backfill it before completing so this is a fallback, never the first
7292    // opportunity for the UI to show the whole command.
7293    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7294        comp.set_command(command);
7295    }
7296    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
7297    comp.set_complete(exit_code, cancelled, truncation);
7298}
7299
7300/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
7301/// display-diff string, render it with colors and attach to the component so
7302/// the changes show in the transcript. `write` has no diff (details: Null) and
7303/// stays a plain summary.
7304fn apply_edit_diff(
7305    comp: &Arc<ToolExecutionComponent>,
7306    tool_name: &str,
7307    details: &serde_json::Value,
7308    tui: &Arc<TuiAltScreen>,
7309) {
7310    if tool_name != "edit" {
7311        return;
7312    }
7313    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7314        return;
7315    };
7316    if diff_text.is_empty() {
7317        return;
7318    }
7319    let width = tui.width();
7320    let lines = render_diff(diff_text, width);
7321    comp.set_diff(lines);
7322}
7323
7324/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
7325/// `None`. The name is the `SKILL.md` parent directory's basename (matching
7326/// native Pi's skill-file convention). Ordinary markdown/document reads
7327/// return `None` and remain regular `READ` tool panels.
7328fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7329    if tool_name != "read" {
7330        return None;
7331    }
7332    let path = args.get("path").and_then(|value| value.as_str())?;
7333    let normalized = path.replace('\\', "/");
7334    let file_name = normalized.rsplit('/').next()?;
7335    if !file_name.eq_ignore_ascii_case("SKILL.md") {
7336        return None;
7337    }
7338    normalized
7339        .trim_end_matches('/')
7340        .rsplit('/')
7341        .nth(1)
7342        .filter(|name| !name.is_empty())
7343        .map(str::to_string)
7344}
7345
7346/// Render an `AgentToolResult` as a single-line summary for the
7347/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
7348fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7349    use rpi_agent::TextContentOrImage;
7350    let mut parts: Vec<String> = Vec::new();
7351    for c in &result.content {
7352        if let TextContentOrImage::Text(t) = c {
7353            parts.push(t.text.clone());
7354        }
7355    }
7356    let joined = parts.join("\n");
7357    // Keep the tool line compact: collapse to a single line, trim length.
7358    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7359    if one_line.chars().count() > 200 {
7360        let truncated: String = one_line.chars().take(200).collect();
7361        format!("{truncated}…")
7362    } else {
7363        one_line
7364    }
7365}
7366
7367/// The raw multi-line text of a tool result (no single-line collapsing). The
7368/// bash panel needs the original line structure — the old path fed it through
7369/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
7370/// crammed e.g. `ls -la`'s whole listing onto one line.
7371fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7372    use rpi_agent::TextContentOrImage;
7373    let mut parts: Vec<String> = Vec::new();
7374    for c in &result.content {
7375        if let TextContentOrImage::Text(t) = c {
7376            parts.push(t.text.clone());
7377        }
7378    }
7379    parts.join("\n")
7380}
7381
7382/// Empty progress callbacks are valid (notably before a tool's start event),
7383/// but they do not contain anything useful to render. Defer those callbacks so
7384/// the first tool panel is created from `ToolExecutionStart` with real args.
7385fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7386    !text.trim().is_empty() || !result.details.is_null()
7387}
7388
7389/// Tool names rendered through the dedicated `ASK USER` panel instead of a
7390/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
7391fn is_ask_user_tool(tool_name: &str) -> bool {
7392    matches!(tool_name, "ask_user" | "ask_user_question")
7393}
7394
7395/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
7396/// transport JSON must never leak into the transcript.
7397fn ask_user_args_display(args: &serde_json::Value) -> String {
7398    if args.is_null() {
7399        return String::new();
7400    }
7401    if let Some(summary) = args
7402        .get("summary")
7403        .and_then(serde_json::Value::as_str)
7404        .map(str::trim)
7405        .filter(|text| !text.is_empty())
7406    {
7407        return format!("Confirmation requested: {summary}");
7408    }
7409
7410    let mut lines: Vec<String> = Vec::new();
7411    let questions: Vec<&serde_json::Value> = args
7412        .get("questions")
7413        .and_then(serde_json::Value::as_array)
7414        .filter(|items| !items.is_empty())
7415        .map(|items| items.iter().collect())
7416        .unwrap_or_else(|| vec![args]);
7417
7418    for question in questions {
7419        let prompt = question
7420            .get("question")
7421            .and_then(serde_json::Value::as_str)
7422            .or_else(|| {
7423                args.get("question")
7424                    .and_then(serde_json::Value::as_str)
7425            })
7426            .map(str::trim)
7427            .unwrap_or_default();
7428        if prompt.is_empty() {
7429            continue;
7430        }
7431        if let Some(header) = question
7432            .get("header")
7433            .and_then(serde_json::Value::as_str)
7434            .map(str::trim)
7435            .filter(|text| !text.is_empty())
7436        {
7437            lines.push(format!("{header}: {prompt}"));
7438        } else {
7439            lines.push(prompt.to_string());
7440        }
7441        let context = question
7442            .get("context")
7443            .and_then(serde_json::Value::as_str)
7444            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7445            .map(str::trim)
7446            .filter(|text| !text.is_empty());
7447        if let Some(context) = context {
7448            lines.push(format!("  {context}"));
7449        }
7450        let options = question
7451            .get("options")
7452            .and_then(serde_json::Value::as_array)
7453            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7454        if let Some(options) = options {
7455            let labels: Vec<String> = options
7456                .iter()
7457                .filter_map(|option| {
7458                    option
7459                        .get("title")
7460                        .and_then(serde_json::Value::as_str)
7461                        .or_else(|| option.as_str())
7462                        .map(str::trim)
7463                        .filter(|label| !label.is_empty())
7464                        .map(str::to_string)
7465                })
7466                .collect();
7467            if !labels.is_empty() {
7468                lines.push(format!("Choices: {}", labels.join(", ")));
7469            }
7470        }
7471        if let Some(suggest) = question
7472            .get("suggest")
7473            .and_then(serde_json::Value::as_str)
7474            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7475            .map(str::trim)
7476            .filter(|text| !text.is_empty())
7477        {
7478            lines.push(format!("Suggestions: {suggest}"));
7479        }
7480    }
7481
7482    lines.join("\n")
7483}
7484
7485/// Progress text while an ask_user tool is pending. Prefer the plugin's own
7486/// readable `content`, otherwise show the question so the panel is not blank.
7487fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7488    let text = tool_result_text(result);
7489    if !text.trim().is_empty() {
7490        return text;
7491    }
7492    let args_text = ask_user_args_display(args);
7493    if args_text.trim().is_empty() {
7494        "Waiting for your answer…".to_string()
7495    } else {
7496        args_text
7497    }
7498}
7499
7500/// Final result text for an ask_user tool. The plugin returns a human-readable
7501/// answer summary in `content`; never surface the transport JSON.
7502fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7503    let text = tool_result_text(result);
7504    if text.trim().is_empty() {
7505        "Answer recorded.".to_string()
7506    } else {
7507        text
7508    }
7509}
7510
7511// ===========================================================================
7512// Selectors — editor-container swap (TS showSelector pattern)
7513// ===========================================================================
7514
7515/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
7516/// hiding the editor while the selector is open. Records the selector in
7517/// `state.active_selector` so the key loop routes to it.
7518fn open_selector(
7519    state: &Arc<TuiState>,
7520    editor_container: &Arc<Container>,
7521    editor: &Arc<Editor>,
7522    tui: &Arc<TuiAltScreen>,
7523    list: Arc<SelectList>,
7524    kind: SelectorKind,
7525) {
7526    open_selector_with_view(
7527        state,
7528        editor_container,
7529        editor,
7530        tui,
7531        list.clone(),
7532        list,
7533        kind,
7534    );
7535}
7536
7537/// Open a selector with an optional framed view. Native extension selectors
7538/// wrap the list with a title and hint while built-in selectors keep the list
7539/// as the complete view.
7540fn open_selector_with_view<C: Component + 'static>(
7541    state: &Arc<TuiState>,
7542    editor_container: &Arc<Container>,
7543    editor: &Arc<Editor>,
7544    tui: &Arc<TuiAltScreen>,
7545    list: Arc<SelectList>,
7546    view: Arc<C>,
7547    kind: SelectorKind,
7548) {
7549    // Unfocus the editor so its cursor marker doesn't render behind the list.
7550    editor.set_focused(false);
7551    // A selector replaces the editor slot. Drop stale slash/@file
7552    // suggestions so they cannot reappear after the selector closes.
7553    state.autocomplete_container.clear();
7554    // Swap: clear the container and add the selector view.
7555    editor_container.clear();
7556    editor_container.add_child(view.clone());
7557    *state.active_selector.lock().unwrap() = Some((list, kind));
7558    let focused: Arc<dyn Component> = view;
7559    tui.set_focus(Some(focused));
7560    tui.request_render(false);
7561}
7562
7563/// Restore the editor into the `editor_container` and clear the active
7564/// selector. Called by selector `on_cancel` and the Esc handler.
7565fn close_selector(
7566    state: &Arc<TuiState>,
7567    editor_container: &Arc<Container>,
7568    editor: &Arc<Editor>,
7569    tui: &Arc<TuiAltScreen>,
7570) {
7571    editor_container.clear();
7572    editor_container.add_child(editor.clone());
7573    state.autocomplete_container.clear();
7574    editor.set_focused(true);
7575    *state.active_selector.lock().unwrap() = None;
7576    *state.active_extension_cancel.lock().unwrap() = None;
7577    tui.set_focus(Some(editor.clone()));
7578    tui.request_render(false);
7579}
7580
7581/// Build + open the `/model` selector. Items are the resolved catalog (display
7582/// label = model name; description = id), with the current model marked.
7583/// Selecting applies the model **live** via `lane.set_model` (takes effect on
7584/// the next user message — the in-flight run's config is already snapshotted),
7585/// updates the footer, and notes the next-prompt effect.
7586fn open_model_selector(
7587    state: &Arc<TuiState>,
7588    editor_container: &Arc<Container>,
7589    editor: &Arc<Editor>,
7590    tui: &Arc<TuiAltScreen>,
7591    catalog: &[rpi_ai::Model],
7592    lane: &Arc<dyn AgentLane>,
7593    lane_model_id: &str,
7594    chat: &Arc<Container>,
7595) {
7596    let items = model_selector_items(catalog, lane_model_id);
7597    if items.is_empty() {
7598        add_note_message(
7599            chat,
7600            "No models in the catalog. Use --model at startup to select one.",
7601        );
7602        tui.request_render(false);
7603        return;
7604    }
7605    let list = Arc::new(SelectList::new(items, 10));
7606
7607    // Capture the catalog + lane so the on_select closure can resolve the
7608    // chosen Model and apply it. `on_select` fires on the blocking key thread,
7609    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
7610    let catalog_arc = catalog.to_vec();
7611    let state_sel = state.clone();
7612    let ec_sel = editor_container.clone();
7613    let editor_sel = editor.clone();
7614    let tui_sel = tui.clone();
7615    let chat_sel = chat.clone();
7616    let lane_sel = lane.clone();
7617    list.on_select(Arc::new(move |item| {
7618        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7619            add_note_message(
7620                &chat_sel,
7621                &format!("Model {} not found in catalog.", item.label),
7622            );
7623            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7624            return;
7625        };
7626        state_sel.set_current_model(&model);
7627        let lane = lane_sel.clone();
7628        tokio::spawn(async move {
7629            let _ = lane.set_model(model).await;
7630        });
7631        add_note_message(
7632            &chat_sel,
7633            &format!(
7634                "Model set to {} — applies to the next message.",
7635                short_model_name(&item.value)
7636            ),
7637        );
7638        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7639    }));
7640    let state_cancel = state.clone();
7641    let ec_cancel = editor_container.clone();
7642    let editor_cancel = editor.clone();
7643    let tui_cancel = tui.clone();
7644    list.on_cancel(Arc::new(move || {
7645        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7646    }));
7647
7648    open_selector(
7649        state,
7650        editor_container,
7651        editor,
7652        tui,
7653        list,
7654        SelectorKind::Model,
7655    );
7656}
7657
7658/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
7659/// Returns `None` only when the catalog is empty or the current id isn't
7660/// found (in which case the first entry is returned — a no-op if it IS the
7661/// current). Used by the Ctrl+M model-cycle hotkey.
7662fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
7663    if catalog.is_empty() {
7664        return None;
7665    }
7666    let idx = catalog
7667        .iter()
7668        .position(|m| m.id.eq_ignore_ascii_case(current_id));
7669    match idx {
7670        Some(i) => {
7671            let next = (i + 1) % catalog.len();
7672            Some(catalog[next].clone())
7673        }
7674        None => Some(catalog[0].clone()),
7675    }
7676}
7677
7678/// Build + open the `/session` selector. Lists JSONL session files under the
7679/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
7680/// fallback). Selecting reports "restore not
7681/// implemented in v1" (existing constraint) but shows the list for
7682/// discoverability.
7683fn open_session_selector(
7684    state: &Arc<TuiState>,
7685    editor_container: &Arc<Container>,
7686    editor: &Arc<Editor>,
7687    tui: &Arc<TuiAltScreen>,
7688    cwd: &std::path::Path,
7689    tx: &mpsc::UnboundedSender<TuiMessage>,
7690) {
7691    let dir = crate::session::default_session_dir(cwd);
7692    let mut items: Vec<SelectItem> = Vec::new();
7693    if let Ok(entries) = std::fs::read_dir(&dir) {
7694        for entry in entries.flatten() {
7695            let path = entry.path();
7696            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
7697                continue;
7698            }
7699            let stem = path
7700                .file_stem()
7701                .and_then(|s| s.to_str())
7702                .unwrap_or("(unnamed)")
7703                .to_string();
7704            let display = path
7705                .file_name()
7706                .and_then(|s| s.to_str())
7707                .unwrap_or(&stem)
7708                .to_string();
7709            items.push(SelectItem::new(&stem, &display));
7710        }
7711    }
7712    if items.is_empty() {
7713        add_note_message(
7714            &state.chat_container,
7715            "No saved sessions found. Sessions are created automatically in interactive mode.",
7716        );
7717        tui.request_render(false);
7718        return;
7719    }
7720    let list = Arc::new(SelectList::new(items, 10));
7721
7722    let state_sel = state.clone();
7723    let ec_sel = editor_container.clone();
7724    let editor_sel = editor.clone();
7725    let tui_sel = tui.clone();
7726    let tx_sel = tx.clone();
7727    list.on_select(Arc::new(move |item| {
7728        // Close the selector first, then ask the async loop to hot-switch:
7729        // opening the session file + swapping the harness backing is async
7730        // (repo list/open) and must not run on the blocking key thread.
7731        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7732        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
7733    }));
7734    let state_cancel = state.clone();
7735    let ec_cancel = editor_container.clone();
7736    let editor_cancel = editor.clone();
7737    let tui_cancel = tui.clone();
7738    list.on_cancel(Arc::new(move || {
7739        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7740    }));
7741
7742    open_selector(
7743        state,
7744        editor_container,
7745        editor,
7746        tui,
7747        list,
7748        SelectorKind::Session,
7749    );
7750}
7751
7752fn custom_entry_display_text(
7753    custom_type: &str,
7754    data: Option<&serde_json::Value>,
7755) -> Option<String> {
7756    let data = data?;
7757    let text = data
7758        .get("summary")
7759        .or_else(|| data.get("text"))
7760        .or_else(|| data.get("output"))
7761        .and_then(|value| value.as_str())
7762        .filter(|value| !value.trim().is_empty())?;
7763    let label = match custom_type {
7764        "compactionSummary" => "Compaction summary",
7765        "branchSummary" => "Branch summary",
7766        "bashExecution" => "Command output",
7767        other => other,
7768    };
7769    Some(format!("{label}: {text}"))
7770}
7771
7772/// Open a selector for the current session's persisted entry tree. Selecting a
7773/// message moves the main lane leaf to that entry, then the caller reloads the
7774/// visible branch from durable storage.
7775async fn open_tree_selector(
7776    harness: &AgentHarness,
7777    state: &Arc<TuiState>,
7778    editor_container: &Arc<Container>,
7779    editor: &Arc<Editor>,
7780    tui: &Arc<TuiAltScreen>,
7781    chat: &Arc<Container>,
7782    tx: &mpsc::UnboundedSender<TuiMessage>,
7783) {
7784    let entries = match harness
7785        .session()
7786        .view("main")
7787        .find_entries(&EntryQuery {
7788            order: Some(EntryOrder::OldestFirst),
7789            ..Default::default()
7790        })
7791        .await
7792    {
7793        Ok(entries) => entries,
7794        Err(error) => {
7795            add_error_message(chat, &format!("Could not read session tree: {error}"));
7796            tui.request_render(false);
7797            return;
7798        }
7799    };
7800    let current = harness.session().get_leaf_id().await.ok().flatten();
7801    let items: Vec<SelectItem> = entries
7802        .iter()
7803        .map(|entry| {
7804            let marker = if current.as_deref() == Some(entry.id()) {
7805                " (current)"
7806            } else {
7807                ""
7808            };
7809            SelectItem::new(
7810                entry.id(),
7811                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7812            )
7813            .with_description(&entry.id()[..entry.id().len().min(12)])
7814        })
7815        .collect();
7816    if items.is_empty() {
7817        add_note_message(chat, "The current session has no entries to navigate.");
7818        tui.request_render(false);
7819        return;
7820    }
7821    let list = Arc::new(SelectList::new(items, 12));
7822    let state_sel = state.clone();
7823    let ec_sel = editor_container.clone();
7824    let editor_sel = editor.clone();
7825    let tui_sel = tui.clone();
7826    let tx_sel = tx.clone();
7827    list.on_select(Arc::new(move |item| {
7828        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7829        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7830    }));
7831    let state_cancel = state.clone();
7832    let ec_cancel = editor_container.clone();
7833    let editor_cancel = editor.clone();
7834    let tui_cancel = tui.clone();
7835    list.on_cancel(Arc::new(move || {
7836        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7837    }));
7838    open_selector(
7839        state,
7840        editor_container,
7841        editor,
7842        tui,
7843        list,
7844        SelectorKind::Tree,
7845    );
7846}
7847
7848/// Build + open the `/theme` selector. Built-in presets and enabled package
7849/// themes are shown; selecting applies the theme live and re-renders.
7850fn open_theme_selector(
7851    state: &Arc<TuiState>,
7852    editor_container: &Arc<Container>,
7853    editor: &Arc<Editor>,
7854    tui: &Arc<TuiAltScreen>,
7855    cwd: &std::path::Path,
7856    package_resources: &Arc<crate::packages::PackageResources>,
7857) {
7858    let mut items = vec![
7859        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7860        SelectItem::new("light", "Light").with_description("Light background"),
7861        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7862    ];
7863    if state.themes_enabled {
7864        for path in package_resources.theme_files() {
7865            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7866                items.push(SelectItem::new(name, name).with_description("Package theme"));
7867            }
7868        }
7869    }
7870    let list = Arc::new(SelectList::new(items, 10));
7871
7872    let state_sel = state.clone();
7873    let ec_sel = editor_container.clone();
7874    let editor_sel = editor.clone();
7875    let tui_sel = tui.clone();
7876    let chat_sel = state.chat_container.clone();
7877    let cwd_sel = cwd.to_path_buf();
7878    let package_resources_sel = package_resources.clone();
7879    list.on_select(Arc::new(move |item| {
7880        let preset = match item.value.as_str() {
7881            "light" => Some(ThemePreset::Light),
7882            "monochrome" => Some(ThemePreset::Monochrome),
7883            "dark" => Some(ThemePreset::Dark),
7884            name => {
7885                if state_sel.themes_enabled {
7886                    if let Ok(custom) = crate::packages::load_theme_with_resources(
7887                        &cwd_sel,
7888                        name,
7889                        &package_resources_sel,
7890                    ) {
7891                        rpi_tui::global_theme_manager().set(custom.clone());
7892                        state_sel.theme_manager.set(custom);
7893                    }
7894                }
7895                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7896                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7897                tui_sel.render_now(true);
7898                return;
7899            }
7900        };
7901        let Some(preset) = preset else { return };
7902        apply_theme_preset(preset);
7903        state_sel.theme_manager.apply_preset(preset);
7904        // A quick accent note so the user sees the change registered even if
7905        // the terminal's own colors mask the preset difference.
7906        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7907        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7908        tui_sel.render_now(true);
7909    }));
7910    let state_cancel = state.clone();
7911    let ec_cancel = editor_container.clone();
7912    let editor_cancel = editor.clone();
7913    let tui_cancel = tui.clone();
7914    list.on_cancel(Arc::new(move || {
7915        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7916    }));
7917
7918    open_selector(
7919        state,
7920        editor_container,
7921        editor,
7922        tui,
7923        list,
7924        SelectorKind::Theme,
7925    );
7926}
7927
7928// ===========================================================================
7929// Feasible selectors — /thinking, /tools, /images
7930// ===========================================================================
7931
7932/// One-line descriptions for each thinking level, ported from
7933/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
7934fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7935    use rpi_ai::types::ThinkingLevel::*;
7936    match level {
7937        Off => "Off — No reasoning",
7938        Minimal => "Minimal — Brief reasoning (~1k tokens)",
7939        Low => "Low — Light reasoning (~1k tokens)",
7940        Medium => "Medium — Moderate reasoning (~80% of max)",
7941        High => "High — Extensive reasoning (~95% of max)",
7942        Xhigh => "Xhigh — Near-maximal reasoning",
7943        Max => "Max — Maximum reasoning",
7944    }
7945}
7946
7947/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
7948/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
7949fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7950    use rpi_ai::types::ThinkingLevel::*;
7951    match level {
7952        Off => "off",
7953        Minimal => "minimal",
7954        Low => "low",
7955        Medium => "medium",
7956        High => "high",
7957        Xhigh => "xhigh",
7958        Max => "max",
7959    }
7960}
7961
7962/// Parse a thinking-level name back to the enum (case-insensitive). Returns
7963/// `None` for an unknown name; used by the `/thinking` selector callback.
7964fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7965    use rpi_ai::types::ThinkingLevel::*;
7966    match name.to_ascii_lowercase().as_str() {
7967        "off" => Some(Off),
7968        "minimal" => Some(Minimal),
7969        "low" => Some(Low),
7970        "medium" => Some(Medium),
7971        "high" => Some(High),
7972        "xhigh" => Some(Xhigh),
7973        "max" => Some(Max),
7974        _ => None,
7975    }
7976}
7977
7978/// Build + open the `/thinking` selector. Items are the levels the current
7979/// model supports (`Model::supported_thinking_levels`), each with a
7980/// description; the current level (read beforehand via `lane.get_thinking_level`)
7981/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
7982///
7983/// `on_select` fires on the blocking key thread, so it can't await
7984/// `lane.get_thinking_level()` to know the current level — the opener resolves
7985/// it first (best-effort) and preselects; the toggle on_select just applies
7986/// whatever was picked.
7987fn open_thinking_selector(
7988    state: &Arc<TuiState>,
7989    editor_container: &Arc<Container>,
7990    editor: &Arc<Editor>,
7991    tui: &Arc<TuiAltScreen>,
7992    lane: &Arc<dyn AgentLane>,
7993    catalog: &[rpi_ai::Model],
7994    lane_model_id: &str,
7995    chat: &Arc<Container>,
7996) {
7997    // Find the current model in the catalog to read its supported levels. If
7998    // absent, fall back to all levels so the selector still opens.
7999    let model = catalog
8000        .iter()
8001        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8002    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8003        .map(|m| m.supported_thinking_levels())
8004        .unwrap_or_else(|| {
8005            use rpi_ai::types::ThinkingLevel::*;
8006            vec![Off, Minimal, Low, Medium, High]
8007        });
8008    let mut items: Vec<SelectItem> = Vec::new();
8009    for lvl in &levels {
8010        let name = thinking_level_name(*lvl);
8011        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8012    }
8013    if items.is_empty() {
8014        add_note_message(chat, "This model has no supported thinking levels.");
8015        tui.request_render(false);
8016        return;
8017    }
8018    let list = Arc::new(SelectList::new(items, 10));
8019
8020    let state_sel = state.clone();
8021    let ec_sel = editor_container.clone();
8022    let editor_sel = editor.clone();
8023    let tui_sel = tui.clone();
8024    let chat_sel = chat.clone();
8025    let lane_sel = lane.clone();
8026    list.on_select(Arc::new(move |item| {
8027        let Some(level) = thinking_level_from_name(&item.value) else {
8028            add_note_message(
8029                &chat_sel,
8030                &format!("Unknown thinking level: {}.", item.label),
8031            );
8032            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8033            return;
8034        };
8035        let lane = lane_sel.clone();
8036        let footer_sel = state_sel.footer.clone();
8037        tokio::spawn(async move {
8038            let _ = lane.set_thinking_level(level).await;
8039        });
8040        // Reflect the chosen level in the footer's model suffix (pi parity:
8041        // `model • thinking off` / `model • medium`). The shown text for the
8042        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
8043        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8044        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8045        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8046    }));
8047    let state_cancel = state.clone();
8048    let ec_cancel = editor_container.clone();
8049    let editor_cancel = editor.clone();
8050    let tui_cancel = tui.clone();
8051    list.on_cancel(Arc::new(move || {
8052        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8053    }));
8054
8055    open_selector(
8056        state,
8057        editor_container,
8058        editor,
8059        tui,
8060        list,
8061        SelectorKind::Thinking,
8062    );
8063}
8064
8065/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
8066/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
8067/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
8068/// — the blocking key thread can't await) and selecting a tool **toggles** it
8069/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
8070fn open_tools_selector(
8071    state: &Arc<TuiState>,
8072    editor_container: &Arc<Container>,
8073    editor: &Arc<Editor>,
8074    tui: &Arc<TuiAltScreen>,
8075    lane: &Arc<dyn AgentLane>,
8076    chat: &Arc<Container>,
8077) {
8078    // Best-effort read of the current active set. The opener runs on the async
8079    // runtime (it's called from the main loop's channel dispatch or the submit
8080    // closure that lives on the blocking thread — but `handle.block_on` is safe
8081    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
8082    let mut active = match tokio::runtime::Handle::try_current() {
8083        Ok(h) => h
8084            .block_on(async { lane.get_active_tools().await })
8085            .unwrap_or_default(),
8086        Err(_) => Vec::new(),
8087    };
8088    // An empty active set is the harness sentinel for "all registered tools"
8089    // (the selector only exposes built-ins). Expand it before rendering and
8090    // toggling so the first `/tools` visit does not show every tool as off or
8091    // accidentally reduce the active set to the one item selected.
8092    if active.is_empty() {
8093        active = crate::session::BUILTIN_TOOL_NAMES
8094            .iter()
8095            .map(|name| (*name).to_string())
8096            .collect();
8097    }
8098    let mut items: Vec<SelectItem> = Vec::new();
8099    for name in crate::session::BUILTIN_TOOL_NAMES {
8100        let on = active.iter().any(|a| a == name);
8101        let label = if on {
8102            format!("{name} (on)")
8103        } else {
8104            (*name).to_string()
8105        };
8106        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8107    }
8108    let list = Arc::new(SelectList::new(items, 10));
8109
8110    // Capture the active set so on_select can toggle without re-reading.
8111    let active_captured = active.clone();
8112    let state_sel = state.clone();
8113    let ec_sel = editor_container.clone();
8114    let editor_sel = editor.clone();
8115    let tui_sel = tui.clone();
8116    let chat_sel = chat.clone();
8117    let lane_sel = lane.clone();
8118    list.on_select(Arc::new(move |item| {
8119        let mut next = active_captured.clone();
8120        if let Some(pos) = next.iter().position(|a| a == &item.value) {
8121            next.remove(pos);
8122        } else {
8123            next.push(item.value.clone());
8124        }
8125        let on = next.iter().any(|a| a == &item.value);
8126        let lane = lane_sel.clone();
8127        let next_clone = next.clone();
8128        tokio::spawn(async move {
8129            let _ = lane.set_active_tools(next_clone).await;
8130        });
8131        let list_str = if next.is_empty() {
8132            "(none)".to_string()
8133        } else {
8134            next.join(", ")
8135        };
8136        add_note_message(
8137            &chat_sel,
8138            &format!(
8139                "{} {} — active tools: {}",
8140                item.value,
8141                if on { "enabled" } else { "disabled" },
8142                list_str
8143            ),
8144        );
8145        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8146    }));
8147    let state_cancel = state.clone();
8148    let ec_cancel = editor_container.clone();
8149    let editor_cancel = editor.clone();
8150    let tui_cancel = tui.clone();
8151    list.on_cancel(Arc::new(move || {
8152        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8153    }));
8154
8155    open_selector(
8156        state,
8157        editor_container,
8158        editor,
8159        tui,
8160        list,
8161        SelectorKind::Tools,
8162    );
8163}
8164
8165/// Build + open the `/images` selector (Yes/No). Stores the choice in
8166/// `state.show_images` and notes it. Image wiring is minimal this pass — the
8167/// flag is consulted where images would be shown and echoed back here.
8168fn open_images_selector(
8169    state: &Arc<TuiState>,
8170    editor_container: &Arc<Container>,
8171    editor: &Arc<Editor>,
8172    tui: &Arc<TuiAltScreen>,
8173    chat: &Arc<Container>,
8174) {
8175    let current = *state.show_images.lock().unwrap();
8176    let items = vec![
8177        SelectItem::new("yes", "Yes").with_description(if current {
8178            "Inline images (current)"
8179        } else {
8180            "Inline images"
8181        }),
8182        SelectItem::new("no", "No").with_description(if current {
8183            "Placeholder only"
8184        } else {
8185            "Placeholder only (current)"
8186        }),
8187    ];
8188    let list = Arc::new(SelectList::new(items, 5));
8189
8190    let state_sel = state.clone();
8191    let ec_sel = editor_container.clone();
8192    let editor_sel = editor.clone();
8193    let tui_sel = tui.clone();
8194    let chat_sel = chat.clone();
8195    list.on_select(Arc::new(move |item| {
8196        let on = item.value == "yes";
8197        *state_sel.show_images.lock().unwrap() = on;
8198        add_note_message(
8199            &chat_sel,
8200            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8201        );
8202        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8203    }));
8204    let state_cancel = state.clone();
8205    let ec_cancel = editor_container.clone();
8206    let editor_cancel = editor.clone();
8207    let tui_cancel = tui.clone();
8208    list.on_cancel(Arc::new(move || {
8209        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8210    }));
8211
8212    open_selector(
8213        state,
8214        editor_container,
8215        editor,
8216        tui,
8217        list,
8218        SelectorKind::Images,
8219    );
8220}
8221
8222// ===========================================================================
8223// Autocomplete
8224// ===========================================================================
8225
8226/// Refresh the autocomplete suggestion list from the current editor text +
8227/// cursor. Renders the suggestions into `autocomplete_container` (above the
8228/// editor) or clears it when there are none.
8229fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8230    let text = editor.get_text();
8231    let cursor = editor_cursor_offset(editor, &text);
8232    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8233    render_autocomplete(state, suggestions);
8234}
8235
8236/// Convert the editor's logical `(row, byte-column)` caret into the absolute
8237/// byte offset expected by autocomplete providers.
8238fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8239    let (row, col) = editor.cursor_position();
8240    let mut offset = 0;
8241    for (index, line) in text.split('\n').enumerate() {
8242        if index == row {
8243            return (offset + col.min(line.len())).min(text.len());
8244        }
8245        offset = offset.saturating_add(line.len() + 1);
8246    }
8247    text.len()
8248}
8249
8250/// Restore an editor caret from an absolute byte offset after autocomplete
8251/// replaces a span in a multi-line draft.
8252fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8253    let offset = offset.min(text.len());
8254    let mut consumed = 0;
8255    for (row, line) in text.split('\n').enumerate() {
8256        let end = consumed + line.len();
8257        if offset <= end {
8258            editor.set_cursor(row, offset - consumed);
8259            return;
8260        }
8261        consumed = end + 1;
8262    }
8263    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8264    editor.set_cursor(
8265        last_row,
8266        text.rsplit('\n').next().map(str::len).unwrap_or(0),
8267    );
8268}
8269
8270/// Render (or clear) the autocomplete suggestion list into the container.
8271fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8272    state.autocomplete_container.clear();
8273    let Some(sugg) = suggestions else {
8274        return;
8275    };
8276    if sugg.items.is_empty() {
8277        return;
8278    }
8279    // Build a compact list: top item marked with `→`, rest with `  `.
8280    // Cap the list so the dock doesn't swallow the transcript.
8281    let accent = state.theme_manager.get().colors.accent;
8282    let muted = state.theme_manager.get().colors.muted;
8283    for (i, item) in sugg
8284        .items
8285        .iter()
8286        .take(state.autocomplete_max_visible)
8287        .enumerate()
8288    {
8289        let prefix = if i == 0 { "→ " } else { "  " };
8290        let label = item.display_text();
8291        let line = if i == 0 {
8292            format!(
8293                "{prefix}{} {}",
8294                accent.fg(label),
8295                muted.fg(item.description.as_deref().unwrap_or(""))
8296            )
8297        } else {
8298            format!(
8299                "{prefix}{} {}",
8300                muted.fg(label),
8301                muted.fg(item.description.as_deref().unwrap_or(""))
8302            )
8303        };
8304        state
8305            .autocomplete_container
8306            .add_child(Arc::new(Text::new(line, 1, 0)));
8307    }
8308}
8309
8310/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
8311/// suggestion text, reposition the caret, and clear the suggestion list.
8312/// Returns `true` if a suggestion was accepted.
8313fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8314    let text = editor.get_text();
8315    let cursor = editor_cursor_offset(editor, &text);
8316    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8317        return false;
8318    };
8319    let Some(top) = sugg.items.first() else {
8320        return false;
8321    };
8322    // Replace the [start, end) span with the suggestion text. `start`/`end`
8323    // are byte offsets emitted by the providers on char boundaries, so the
8324    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
8325    let start = sugg.start.min(text.len());
8326    let end = sugg.end.min(text.len());
8327    let mut replaced = String::with_capacity(text.len() + top.text.len());
8328    replaced.push_str(&text[..start]);
8329    replaced.push_str(&top.text);
8330    // Keep the text AFTER the replaced span (mid-line completion: replacing
8331    // `[start, end)` must not drop the rest of the line).
8332    replaced.push_str(&text[end..]);
8333    if top.insert_space && !replaced.ends_with('/') {
8334        replaced.push(' ');
8335    }
8336    // New caret position: after the inserted text (byte offset; the editor
8337    // snaps `set_cursor` to a char boundary as a safety net).
8338    let new_cursor = replaced.len().min(
8339        start
8340            + top.text.len()
8341            + if top.insert_space && !top.text.ends_with('/') {
8342                1
8343            } else {
8344                0
8345            },
8346    );
8347    editor.set_text(&replaced);
8348    set_editor_cursor_offset(editor, &replaced, new_cursor);
8349    state.autocomplete_container.clear();
8350    true
8351}
8352
8353// ===========================================================================
8354// Transcript message helpers
8355// ===========================================================================
8356
8357/// Add the welcome header to the chat container.
8358fn add_welcome_message(container: &Arc<Container>) {
8359    add_welcome_message_with_capabilities(container, &[], &[]);
8360}
8361
8362/// Add the startup welcome header and a compact snapshot of active tools and
8363/// discovered skills. The snapshot reflects the harness configuration used by
8364/// the first turn, including tools contributed by extensions.
8365fn add_welcome_message_with_capabilities(
8366    container: &Arc<Container>,
8367    active_tools: &[String],
8368    skills: &[String],
8369) {
8370    let c = current_theme().colors;
8371    // Accent logotype + a dim tagline, separated from the rest by a thin
8372    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
8373    // to the body text, so the header didn't read as a header.
8374    let title = format!(
8375        "{} {}",
8376        c.accent.fg(&tui_bold("rpi")),
8377        c.muted.fg("interactive TUI")
8378    );
8379    container.add_child(Arc::new(Text::new(title, 1, 0)));
8380    container.add_child(Arc::new(Spacer::new(1)));
8381    container.add_child(Arc::new(Text::new(
8382        c.dim.fg("Type your message and press Enter to send."),
8383        1,
8384        0,
8385    )));
8386    let hint = c
8387        .dim
8388        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8389    container.add_child(Arc::new(Text::new(hint, 1, 0)));
8390    container.add_child(Arc::new(Spacer::new(1)));
8391    container.add_child(Arc::new(Text::new(
8392        welcome_capability_line("Tools", active_tools),
8393        1,
8394        0,
8395    )));
8396    container.add_child(Arc::new(Text::new(
8397        welcome_capability_line("Skills", skills),
8398        1,
8399        0,
8400    )));
8401    container.add_child(Arc::new(DynamicBorder::new()));
8402}
8403
8404/// Append update notices inside the live transcript. Fullscreen mode clears
8405/// pre-TUI stdout/stderr, so update state must be represented by components.
8406fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8407    let colors = current_theme().colors;
8408    let group = Arc::new(Container::new());
8409
8410    for warning in &report.warnings {
8411        let body = format!(
8412            "{}\n{} {}{}",
8413            colors.error.fg(&warning.message),
8414            colors.muted.fg("Run"),
8415            colors.accent.fg(&warning.command),
8416            colors.muted.fg(" to retry.")
8417        );
8418        add_update_panel(&group, "Update Failed", &body, colors.error);
8419    }
8420
8421    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8422        let body = format!(
8423            "{} {}{}",
8424            colors
8425                .muted
8426                .fg(&format!("New version {} is available. Run", notice.latest)),
8427            colors.accent.fg(&notice.command),
8428            colors.muted.fg(".")
8429        );
8430        add_update_panel(&group, "Update Available", &body, colors.warning);
8431    }
8432
8433    let package_notices = report
8434        .notices
8435        .iter()
8436        .filter(|notice| notice.name != "rpi")
8437        .collect::<Vec<_>>();
8438    if !package_notices.is_empty() {
8439        let command = package_notices[0].command.as_str();
8440        let mut lines = vec![format!(
8441            "{} {}{}",
8442            colors.muted.fg("Package updates are available. Run"),
8443            colors.accent.fg(command),
8444            colors.muted.fg(".")
8445        )];
8446        lines.push(colors.muted.fg("Packages:"));
8447        lines.extend(
8448            package_notices
8449                .into_iter()
8450                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8451        );
8452        add_update_panel(
8453            &group,
8454            "Package Updates Available",
8455            &lines.join("\n"),
8456            colors.warning,
8457        );
8458    }
8459
8460    // Other transcript producers append concurrently. Add the fully built
8461    // group in one operation so card borders and content cannot interleave
8462    // with user, assistant, tool, or extension messages.
8463    if group.child_count() > 0 {
8464        container.add_child(group);
8465    }
8466}
8467
8468fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8469    container.add_child(Arc::new(Spacer::new(1)));
8470    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8471    container.add_child(Arc::new(Text::new(
8472        format!("{}\n{body}", color.fg(&tui_bold(title))),
8473        1,
8474        0,
8475    )));
8476    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8477}
8478
8479fn welcome_capability_line(label: &str, names: &[String]) -> String {
8480    let c = current_theme().colors;
8481    let value = if names.is_empty() {
8482        "none".to_string()
8483    } else {
8484        names.join(" · ")
8485    };
8486    format!(
8487        "{} {}",
8488        c.accent.fg(&format!("{label} ({})", names.len())),
8489        c.muted.fg(&value)
8490    )
8491}
8492
8493/// Add the `/help` command listing to the chat container.
8494fn add_help_message(container: &Arc<Container>) {
8495    let c = current_theme().colors;
8496    // Section header + a thin themed rule, then a two-column command table:
8497    // `cmd` in accent, `— desc` in muted. The old single-space layout made
8498    // the description column wander depending on command length.
8499    container.add_child(Arc::new(Text::new(
8500        c.md_heading.fg(&tui_bold("📚 Available Commands")),
8501        1,
8502        0,
8503    )));
8504    container.add_child(Arc::new(Spacer::new(1)));
8505
8506    let cmds: &[(&str, &str)] = &[
8507        ("/help, /?", "Show this help message"),
8508        ("/clear, /new", "Clear the conversation"),
8509        ("/exit, /quit, /q", "Exit the application"),
8510        ("/version, /v", "Show version information"),
8511        ("/changelog", "Show recent release changes"),
8512        ("/model, /m", "Choose a model (live switch)"),
8513        ("/thinking, /think", "Set reasoning depth (selector)"),
8514        ("/tools", "Toggle built-in tools on/off"),
8515        ("/images", "Toggle inline image rendering"),
8516        ("/session", "List saved sessions"),
8517        ("/theme", "Choose a theme (selector)"),
8518        ("/compact", "Compact the conversation"),
8519        ("/copy", "Copy last reply to clipboard"),
8520        ("/hotkeys", "Show keyboard shortcuts"),
8521        ("/armin", "🐾 Easter egg"),
8522        ("/earendil", "Earendil announcement"),
8523    ];
8524    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8525    for (cmd, desc) in cmds {
8526        let row = format!(
8527            "  {:<cmd_w$}  {}  {}",
8528            c.accent.fg(cmd),
8529            c.dim.fg("—"),
8530            c.muted.fg(desc)
8531        );
8532        container.add_child(Arc::new(Text::new(row, 1, 0)));
8533    }
8534    container.add_child(Arc::new(Spacer::new(1)));
8535}
8536
8537/// Add the `/version` block to the chat container.
8538fn add_version_message(container: &Arc<Container>) {
8539    let c = current_theme().colors;
8540    container.add_child(Arc::new(Text::new(
8541        c.md_heading.fg(&tui_bold("📦 Version Information")),
8542        1,
8543        0,
8544    )));
8545    container.add_child(Arc::new(Spacer::new(1)));
8546    // Use the crate version (kept in sync via `version.workspace = true`)
8547    // instead of the stale hardcoded "v0.1.2".
8548    container.add_child(Arc::new(Text::new(
8549        format!(
8550            "  {} {}",
8551            c.muted.fg("rpi-cli"),
8552            c.text.fg(&format!("v{}", crate::VERSION))
8553        ),
8554        1,
8555        0,
8556    )));
8557    container.add_child(Arc::new(Text::new(
8558        format!(
8559            "  {}",
8560            c.dim.fg("Rust implementation of pi coding agent TUI")
8561        ),
8562        1,
8563        0,
8564    )));
8565    container.add_child(Arc::new(Spacer::new(1)));
8566}
8567
8568/// Add a compact `/changelog` block to the chat container. Keep this local to
8569/// the binary so the command remains useful in installed builds without a
8570/// source checkout or a network request.
8571fn add_changelog_message(container: &Arc<Container>) {
8572    let c = current_theme().colors;
8573    container.add_child(Arc::new(Text::new(
8574        c.md_heading.fg(&tui_bold("Recent Changes")),
8575        1,
8576        0,
8577    )));
8578    container.add_child(Arc::new(Spacer::new(1)));
8579    let entries = [
8580        (
8581            "Native parity phase 1",
8582            "models, images, trust, export, and JSON events",
8583        ),
8584        (
8585            "TUI controls",
8586            "external editor, thinking levels, and tool output toggles",
8587        ),
8588        (
8589            "Provider auth",
8590            "OpenAI-compatible API key aliases and gateway headers",
8591        ),
8592    ];
8593    for (release, summary) in entries {
8594        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
8595        container.add_child(Arc::new(Text::new(row, 1, 0)));
8596    }
8597    container.add_child(Arc::new(Text::new(
8598        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8599        1,
8600        0,
8601    )));
8602    container.add_child(Arc::new(Spacer::new(1)));
8603}
8604
8605/// Add the `/hotkeys` block to the chat container.
8606fn add_hotkeys_message(container: &Arc<Container>) {
8607    let c = current_theme().colors;
8608    container.add_child(Arc::new(Text::new(
8609        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
8610        1,
8611        0,
8612    )));
8613    container.add_child(Arc::new(Spacer::new(1)));
8614    let keys: &[(&str, &str)] = &[
8615        ("Enter", "Send message"),
8616        ("Shift+Enter", "New line"),
8617        ("Tab", "Accept autocomplete suggestion"),
8618        ("Ctrl+A / Ctrl+E", "Line start / end"),
8619        (
8620            "Ctrl+K / Ctrl+U",
8621            "Kill to end / start of line (Ctrl+Y yanks)",
8622        ),
8623        ("Ctrl+- / Ctrl+R", "Undo / redo"),
8624        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8625        ("Alt+Backspace", "Kill previous word"),
8626        ("Ctrl+C", "Abort a run, or exit when idle"),
8627        ("Esc", "Abort a running prompt"),
8628        ("Ctrl+L", "Open model selector"),
8629        ("Ctrl+M", "Cycle to the next model (live)"),
8630        ("Ctrl+O", "Expand/collapse all tool output"),
8631        ("Ctrl+T", "Show/hide reasoning blocks"),
8632        ("PageUp/Down", "Scroll transcript by one page"),
8633        ("Home / End", "Jump to transcript start / latest output"),
8634    ];
8635    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8636    for (key, desc) in keys {
8637        let row = format!(
8638            "  {:<key_w$}  {}  {}",
8639            c.accent.fg(key),
8640            c.dim.fg("—"),
8641            c.muted.fg(desc)
8642        );
8643        container.add_child(Arc::new(Text::new(row, 1, 0)));
8644    }
8645    container.add_child(Arc::new(Spacer::new(1)));
8646}
8647
8648/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
8649/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
8650/// plain `> text` echo. A trailing Spacer(1) separates it from the next
8651// transcript entry (every entry contributes one trailing spacer so
8652// consecutive turns are separated by exactly one blank line).
8653fn add_user_message(container: &Arc<Container>, text: &str) {
8654    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
8655    container.add_child(Arc::new(Spacer::new(1)));
8656}
8657
8658/// Add an error message to the chat container.
8659fn add_error_message(container: &Arc<Container>, text: &str) {
8660    let c = current_theme().colors;
8661    let text = sanitize_error_message(text);
8662    container.add_child(Arc::new(Text::new(
8663        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
8664        1,
8665        0,
8666    )));
8667    container.add_child(Arc::new(Spacer::new(1)));
8668}
8669
8670/// Keep provider diagnostics printable in the transcript. HTTP error bodies
8671/// may contain carriage returns, terminal escapes, or an unexpectedly large
8672/// JSON payload; letting those bytes reach the renderer can corrupt the input
8673/// row or make the whole frame exceed terminal limits.
8674fn sanitize_error_message(text: &str) -> String {
8675    const MAX_ERROR_CHARS: usize = 16 * 1024;
8676    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
8677    let mut count = 0;
8678    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
8679    let mut escape_mode = 0u8;
8680    for ch in text.chars() {
8681        if escape_mode != 0 {
8682            match escape_mode {
8683                1 if ch == '[' => escape_mode = 2,
8684                1 if ch == ']' => escape_mode = 3,
8685                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
8686                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
8687                3 if ch == '\x07' => escape_mode = 0,
8688                _ => {}
8689            }
8690            continue;
8691        }
8692        if ch == '\x1b' {
8693            escape_mode = 1;
8694            continue;
8695        }
8696        if count >= MAX_ERROR_CHARS {
8697            result.push_str("…");
8698            break;
8699        }
8700        match ch {
8701            '\n' | '\t' => {
8702                result.push(ch);
8703                count += 1;
8704            }
8705            '\r' => {}
8706            c if c.is_control() => {}
8707            c => {
8708                result.push(c);
8709                count += 1;
8710            }
8711        }
8712    }
8713    if result.trim().is_empty() {
8714        "Provider request failed.".to_string()
8715    } else {
8716        result
8717    }
8718}
8719
8720/// Add a neutral note (e.g. unsupported-command message) to the chat container.
8721fn add_note_message(container: &Arc<Container>, text: &str) {
8722    let c = current_theme().colors;
8723    container.add_child(Arc::new(Text::new(
8724        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
8725        1,
8726        0,
8727    )));
8728    container.add_child(Arc::new(Spacer::new(1)));
8729}
8730
8731/// Render the `/context` panel: a transcript message listing the discovered
8732/// context files, skills, and prompt templates loaded for this session
8733/// (Part A resource discovery). Reads the harness resources snapshot captured
8734/// at TUI startup (the blocking submit handler can't `await get_resources()`.
8735///
8736/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
8737/// via `/reload`); here it's a transcript note rather than an overlay since the
8738/// resource set is session-static between `/reload`s (deferred).
8739fn show_context_panel(
8740    chat: &Arc<Container>,
8741    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
8742) {
8743    let skills = resources.skills.as_deref().unwrap_or(&[]);
8744    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
8745    let mut lines: Vec<String> = Vec::new();
8746    lines.push("📂 Discovered resources for this session:".into());
8747
8748    if skills.is_empty() {
8749        lines.push(
8750            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
8751        );
8752    } else {
8753        lines.push(format!("  Skills ({}):", skills.len()));
8754        for s in skills {
8755            let marker = if s.disable_model_invocation == Some(true) {
8756                " [hidden]"
8757            } else {
8758                ""
8759            };
8760            let desc: String = s.description.chars().take(72).collect();
8761            lines.push(format!("    • {}{marker} — {desc}", s.name));
8762        }
8763    }
8764
8765    if templates.is_empty() {
8766        lines.push(
8767            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
8768        );
8769    } else {
8770        lines.push(format!("  Prompt templates ({}):", templates.len()));
8771        for t in templates {
8772            let desc = t
8773                .description
8774                .as_deref()
8775                .unwrap_or("(no description)")
8776                .chars()
8777                .take(72)
8778                .collect::<String>();
8779            lines.push(format!("    • /{} — {desc}", t.name));
8780        }
8781    }
8782    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
8783    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
8784    lines.push(
8785        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
8786            .into(),
8787    );
8788    let body = lines.join("\n");
8789    container_note_block(chat, &body);
8790}
8791
8792/// Append a multi-line neutral note (header line + body) to the chat container.
8793fn container_note_block(container: &Arc<Container>, body: &str) {
8794    for line in body.lines() {
8795        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
8796    }
8797    container.add_child(Arc::new(Spacer::new(1)));
8798}
8799
8800// ===========================================================================
8801// TUI support + entry detection
8802// ===========================================================================
8803
8804/// Check if the terminal supports TUI mode.
8805pub fn is_tui_supported() -> bool {
8806    std::io::stdout().is_terminal()
8807}
8808
8809// Keep the `Color` import used (theme accent rendering in autocomplete).
8810#[allow(unused_imports)]
8811use rpi_tui::Color as _Color;
8812
8813#[cfg(test)]
8814mod tests {
8815    use super::*;
8816    use rpi_tui::Component;
8817
8818    #[test]
8819    fn verbose_overrides_quiet_startup_listing() {
8820        assert!(should_show_startup_listing(false, false));
8821        assert!(should_show_startup_listing(true, false));
8822        assert!(should_show_startup_listing(true, true));
8823        assert!(!should_show_startup_listing(false, true));
8824    }
8825
8826    #[test]
8827    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
8828        let tmp = tempfile::tempdir().unwrap();
8829        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
8830        let local_root = tmp.path().join("local-package");
8831        std::fs::create_dir_all(git_root.join(".git")).unwrap();
8832        std::fs::create_dir_all(&local_root).unwrap();
8833        std::fs::write(
8834            git_root.join("package.json"),
8835            r#"{"name":"git-demo","version":"1.0.0"}"#,
8836        )
8837        .unwrap();
8838        std::fs::write(
8839            local_root.join("package.json"),
8840            r#"{"name":"local-demo","version":"1.0.0"}"#,
8841        )
8842        .unwrap();
8843        let resources = crate::packages::discover(
8844            tmp.path(),
8845            &[
8846                "git:github.com/example/repo".to_string(),
8847                local_root.to_string_lossy().into_owned(),
8848            ],
8849        );
8850        assert_eq!(resources.packages.len(), 2);
8851
8852        let filtered = git_only_update_resources(resources);
8853
8854        assert_eq!(filtered.packages.len(), 1);
8855        assert!(matches!(
8856            filtered.packages[0].source,
8857            crate::packages::PackageSource::Git
8858        ));
8859    }
8860
8861    #[test]
8862    fn trust_command_uses_the_session_cwd_after_process_chdir() {
8863        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
8864        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
8865        const TEST_NAME: &str =
8866            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
8867
8868        if std::env::var_os(CHILD_ENV).is_some() {
8869            let session_cwd = std::path::PathBuf::from(
8870                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
8871            );
8872            let changed_cwd = std::env::current_dir().unwrap();
8873
8874            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
8875
8876            assert_eq!(
8877                crate::config::project_trust_decision(&session_cwd).unwrap(),
8878                Some(true)
8879            );
8880            assert_eq!(
8881                crate::config::project_trust_decision(&changed_cwd).unwrap(),
8882                None
8883            );
8884            return;
8885        }
8886
8887        let tmp = tempfile::tempdir().unwrap();
8888        let session_cwd = tmp.path().join("session-project");
8889        let changed_cwd = tmp.path().join("extension-cwd");
8890        let agent_dir = tmp.path().join("agent");
8891        std::fs::create_dir_all(&session_cwd).unwrap();
8892        std::fs::create_dir_all(&changed_cwd).unwrap();
8893        std::fs::create_dir_all(&agent_dir).unwrap();
8894
8895        let status = std::process::Command::new(std::env::current_exe().unwrap())
8896            .arg("--exact")
8897            .arg(TEST_NAME)
8898            .arg("--nocapture")
8899            .env(CHILD_ENV, "1")
8900            .env(SESSION_CWD_ENV, &session_cwd)
8901            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8902            .current_dir(&changed_cwd)
8903            .status()
8904            .unwrap();
8905
8906        assert!(status.success(), "child test process failed: {status}");
8907    }
8908
8909    #[test]
8910    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8911        let global = crate::settings::Settings {
8912            editor_padding_x: Some(3),
8913            autocomplete_max_visible: Some(6),
8914            hide_thinking_block: Some(false),
8915            quiet_startup: Some(true),
8916            show_terminal_progress: Some(true),
8917            ..Default::default()
8918        };
8919        let rpi_project = crate::settings::Settings {
8920            editor_padding_x: Some(0),
8921            hide_thinking_block: Some(true),
8922            quiet_startup: Some(false),
8923            show_terminal_progress: Some(false),
8924            ..Default::default()
8925        };
8926        let pi_project = crate::settings::Settings {
8927            editor_padding_x: Some(9),
8928            autocomplete_max_visible: Some(12),
8929            quiet_startup: Some(true),
8930            ..Default::default()
8931        };
8932
8933        assert_eq!(
8934            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8935            TuiStartupSettings {
8936                editor_padding_x: 0,
8937                autocomplete_max_visible: 12,
8938                hide_thinking: true,
8939                quiet_startup: false,
8940                show_terminal_progress: false,
8941            }
8942        );
8943    }
8944
8945    #[test]
8946    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8947        let global = crate::settings::Settings {
8948            quiet_startup: Some(false),
8949            ..Default::default()
8950        };
8951        let rpi_project = crate::settings::Settings::default();
8952        let pi_project = crate::settings::Settings {
8953            quiet_startup: Some(true),
8954            ..Default::default()
8955        };
8956
8957        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8958
8959        assert!(resolved.quiet_startup);
8960    }
8961
8962    #[test]
8963    fn tui_startup_settings_use_global_values_without_project_fields() {
8964        let global = crate::settings::Settings {
8965            editor_padding_x: Some(7),
8966            autocomplete_max_visible: Some(8),
8967            hide_thinking_block: Some(true),
8968            quiet_startup: Some(true),
8969            show_terminal_progress: Some(false),
8970            ..Default::default()
8971        };
8972
8973        assert_eq!(
8974            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8975            TuiStartupSettings {
8976                editor_padding_x: 7,
8977                autocomplete_max_visible: 8,
8978                hide_thinking: true,
8979                quiet_startup: true,
8980                show_terminal_progress: false,
8981            }
8982        );
8983    }
8984
8985    #[test]
8986    fn tui_startup_settings_ignore_untrusted_project_values() {
8987        let global = crate::settings::Settings {
8988            quiet_startup: Some(false),
8989            show_terminal_progress: Some(true),
8990            ..Default::default()
8991        };
8992        let project = crate::settings::Settings {
8993            quiet_startup: Some(true),
8994            show_terminal_progress: Some(false),
8995            ..Default::default()
8996        };
8997
8998        let resolved = resolve_tui_startup_settings(&global, &[project], false);
8999
9000        assert!(!resolved.quiet_startup);
9001        assert!(resolved.show_terminal_progress);
9002    }
9003
9004    #[test]
9005    fn transcript_page_uses_viewport_with_overlap() {
9006        assert_eq!(transcript_page_size(24), 20);
9007        assert_eq!(transcript_page_size(4), 1);
9008        assert_eq!(transcript_page_size(0), 1);
9009    }
9010
9011    #[test]
9012    fn key_repeat_is_dispatched_but_release_is_not() {
9013        assert!(should_dispatch_key(KeyEventKind::Press));
9014        assert!(should_dispatch_key(KeyEventKind::Repeat));
9015        assert!(!should_dispatch_key(KeyEventKind::Release));
9016    }
9017
9018    #[test]
9019    fn key_event_encoding_matches_pi_keybinding_protocol() {
9020        let key = |code, modifiers| KeyEvent::new(code, modifiers);
9021        assert_eq!(
9022            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9023            "\r"
9024        );
9025        assert_eq!(
9026            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9027            "\x1b[13;2u"
9028        );
9029        assert_eq!(
9030            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9031            "\x1b[9;2u"
9032        );
9033        assert_eq!(
9034            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9035            "\x1b[Z"
9036        );
9037        assert_eq!(
9038            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9039            "\x1b[Z"
9040        );
9041        assert_eq!(
9042            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9043            "\x03"
9044        );
9045        assert_eq!(
9046            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9047            "\x0f"
9048        );
9049        assert_eq!(
9050            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9051            "!"
9052        );
9053        assert_eq!(
9054            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9055            "1"
9056        );
9057    }
9058
9059    #[test]
9060    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9061        let bridge = JsDialogBridge::default();
9062        let (sender, receiver) = std_mpsc::channel();
9063        bridge.pending.lock().unwrap().push_back(JsDialogPending {
9064            request: JsDialogRequest {
9065                id: "dialog-1".into(),
9066                method: "input".into(),
9067                title: String::new(),
9068                message: String::new(),
9069                options: Vec::new(),
9070                placeholder: None,
9071                prefill: None,
9072            },
9073            result: sender,
9074        });
9075
9076        // Model the cancellation arriving after the queue entry has been
9077        // removed but before the TUI has installed the native widget.
9078        bridge.cancel("dialog-1");
9079        assert!(bridge.take_pending().is_none());
9080        assert_eq!(
9081            receiver.recv().unwrap(),
9082            serde_json::json!({ "cancelled": true })
9083        );
9084        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9085    }
9086
9087    #[test]
9088    fn test_layout_renders_welcome_message() {
9089        let chat = Arc::new(Container::new());
9090        add_welcome_message(&chat);
9091
9092        let scroll = Arc::new(ScrollView::new(
9093            chat.clone(),
9094            ScrollViewOptions {
9095                follow: FollowMode::End,
9096                primary: true,
9097                ..Default::default()
9098            },
9099        ));
9100
9101        let editor = Arc::new(Editor::new(
9102            EditorOptions {
9103                padding_x: 1,
9104                ..Default::default()
9105            },
9106            EditorStyle::default(),
9107            Arc::new(rpi_tui::Keybindings::new()),
9108        ));
9109        let dock = Arc::new(Container::new());
9110        dock.add_child(editor);
9111
9112        let footer = Arc::new(FooterComponent::new());
9113
9114        let root = VStack::from_children(vec![
9115            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9116            StackChild::Entry(StackEntry::new(dock)),
9117            StackChild::Entry(StackEntry::new(footer)),
9118        ]);
9119
9120        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9121
9122        let all: String = frame.lines.join("\n");
9123        assert!(
9124            all.contains("rpi"),
9125            "Welcome message not found. Rendered: {}",
9126            all
9127        );
9128        assert!(
9129            all.contains("Type your message"),
9130            "Help text not found. Rendered: {}",
9131            all
9132        );
9133    }
9134
9135    #[test]
9136    fn test_chat_container_has_welcome_content() {
9137        let chat = Arc::new(Container::new());
9138        add_welcome_message_with_capabilities(
9139            &chat,
9140            &["read".into(), "bash".into(), "web_fetch".into()],
9141            &["rust-review".into(), "release".into()],
9142        );
9143
9144        let lines = chat.render(80);
9145        let all: String = lines.join("\n");
9146        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
9147        // joined by an ANSI reset — strip ANSI before checking the substring.
9148        let plain = strip_ansi(&all);
9149        assert!(
9150            plain.contains("rpi"),
9151            "Welcome message not in chat container: {:?}",
9152            lines
9153        );
9154        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9155        assert!(
9156            plain.contains("read · bash · web_fetch"),
9157            "Tool names missing: {plain}"
9158        );
9159        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9160        assert!(
9161            plain.contains("rust-review · release"),
9162            "Skill names missing: {plain}"
9163        );
9164    }
9165
9166    #[test]
9167    fn update_notices_render_inside_the_transcript() {
9168        let chat = Arc::new(Container::new());
9169        let report = crate::updates::UpdateReport {
9170            notices: vec![
9171                crate::updates::UpdateNotice {
9172                    name: "rpi".into(),
9173                    current: "0.1.10".into(),
9174                    latest: "0.1.11".into(),
9175                    command: "rpi update".into(),
9176                },
9177                crate::updates::UpdateNotice {
9178                    name: "rpi-search".into(),
9179                    current: "0.1.0".into(),
9180                    latest: "0.1.1".into(),
9181                    command: "rpi pi-package update".into(),
9182                },
9183            ],
9184            warnings: vec![crate::updates::UpdateWarning {
9185                message: "The previously scheduled rpi update failed: access denied".into(),
9186                command: "rpi update".into(),
9187            }],
9188        };
9189
9190        add_update_notices(&chat, &report);
9191
9192        assert_eq!(chat.child_count(), 1);
9193        let plain = strip_ansi(&chat.render(80).join("\n"));
9194        assert!(plain.contains("Update Failed"), "{plain}");
9195        assert!(
9196            plain.contains("rpi update failed: access denied"),
9197            "{plain}"
9198        );
9199        assert!(plain.contains("Update Available"), "{plain}");
9200        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9201        assert!(plain.contains("rpi update"), "{plain}");
9202        assert!(plain.contains("Package Updates Available"), "{plain}");
9203        assert!(plain.contains("rpi pi-package update"), "{plain}");
9204        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9205    }
9206
9207    #[test]
9208    fn empty_update_report_does_not_add_transcript_content() {
9209        let chat = Arc::new(Container::new());
9210
9211        add_update_notices(&chat, &crate::updates::UpdateReport::default());
9212
9213        assert_eq!(chat.child_count(), 0);
9214    }
9215
9216    #[test]
9217    fn skill_reads_are_detected_by_path() {
9218        let name = skill_tool_name(
9219            "read",
9220            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9221        );
9222        assert_eq!(name.as_deref(), Some("release"));
9223
9224        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9225        assert!(name.is_none());
9226
9227        // Only `read` (not other tools) triggers the skill box.
9228        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9229    }
9230
9231    #[test]
9232    fn welcome_capabilities_show_empty_state() {
9233        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9234        assert_eq!(plain, "Skills (0) none");
9235    }
9236
9237    #[test]
9238    fn empty_tool_progress_is_deferred_until_start() {
9239        let empty = rpi_agent::AgentToolResult::default();
9240        assert!(!tool_update_has_payload("", &empty));
9241
9242        let text = rpi_agent::AgentToolResult::text("partial output");
9243        assert!(tool_update_has_payload("partial output", &text));
9244
9245        let details = rpi_agent::AgentToolResult {
9246            details: serde_json::json!({"path": "src/lib.rs"}),
9247            ..Default::default()
9248        };
9249        assert!(tool_update_has_payload("", &details));
9250    }
9251
9252    #[test]
9253    fn completed_stream_reconciles_the_final_tail_before_detaching() {
9254        let component = Arc::new(AssistantMessageComponent::new(
9255            AssistantMessageOptions::default(),
9256        ));
9257        component.set_streaming(true);
9258        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9259
9260        let current = Mutex::new(Some(component.clone()));
9261        let cached = Mutex::new("partial response".to_string());
9262        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9263        final_message.content = vec![Content::text(
9264            "partial response with the previously missing final tail",
9265        )];
9266        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9267
9268        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
9269
9270        assert!(current.lock().unwrap().is_none());
9271        assert_eq!(
9272            cached.lock().unwrap().as_str(),
9273            "partial response with the previously missing final tail"
9274        );
9275        let rendered = strip_ansi(&component.render(100).join("\n"));
9276        assert!(
9277            rendered.contains("previously missing final tail"),
9278            "{rendered}"
9279        );
9280    }
9281
9282    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
9283    /// replaces the editor text, the NEXT rendered frame must show the
9284    /// completed text (" /model " with the caret after it), not the old
9285    /// prefix. Mirrors the real dock layout (autocomplete_container above the
9286    /// bordered editor) and drives the same accept path the Tab handler uses.
9287    #[test]
9288    fn tab_accept_suggestion_reflects_in_next_render() {
9289        use rpi_tui::render_layout_frame;
9290
9291        let editor = Arc::new(Editor::new(
9292            EditorOptions {
9293                padding_x: 1,
9294                ..Default::default()
9295            },
9296            EditorStyle::default(),
9297            Arc::new(rpi_tui::Keybindings::new()),
9298        ));
9299        editor.set_focused(true);
9300        let editor_container = Arc::new(Container::new());
9301        editor_container.add_child(editor.clone());
9302        let autocomplete_container = Arc::new(Container::new());
9303        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9304        let dock = Arc::new(VStack::from_children(vec![
9305            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9306            StackChild::Entry(
9307                StackEntry::new(editor_container.clone())
9308                    .shrink(0)
9309                    .min_size(3),
9310            ),
9311            StackChild::Entry(StackEntry::new(footer)),
9312        ]));
9313
9314        // Simulate the user typing "/mo" (the popup shows suggestions).
9315        let mut manager = AutocompleteManager::new();
9316        let mut combined = CombinedAutocompleteProvider::new();
9317        combined.add_provider(Arc::new(
9318            SlashCommandAutocompleteProvider::with_default_commands(),
9319        ));
9320        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9321        manager.set_provider(Arc::new(combined));
9322        // Simulate typing "/mo" via the real insert path (advances the caret
9323        // by char length, like `handle_key` does).
9324        editor.insert("/mo");
9325        assert_eq!(editor.cursor_position(), (0, 3));
9326
9327        let frame_before = render_layout_frame(dock.clone(), 80, 10);
9328        assert!(
9329            frame_before.lines.iter().any(|l| l.contains("/mo")),
9330            "precondition: editor shows the typed prefix. Frame rows:\n{}",
9331            frame_before
9332                .lines
9333                .iter()
9334                .map(|l| format!("  [{l}]"))
9335                .collect::<Vec<_>>()
9336                .join("\n")
9337        );
9338
9339        // Tab: accept the top suggestion (the same code path as the key loop).
9340        let text = editor.get_text();
9341        let cursor = editor_cursor_offset(&editor, &text);
9342        let sugg = manager
9343            .get_suggestions(&text, cursor)
9344            .expect("slash suggestions for /mo");
9345        let top = sugg.items.first().expect("at least one suggestion");
9346        let start = sugg.start.min(text.len());
9347        let end = sugg.end.min(text.len());
9348        let mut replaced = String::new();
9349        replaced.push_str(&text[..start]);
9350        replaced.push_str(&top.text);
9351        replaced.push_str(&text[end..]);
9352        if top.insert_space && !replaced.ends_with('/') {
9353            replaced.push(' ');
9354        }
9355        let new_cursor = start + top.text.len();
9356        editor.set_text(&replaced);
9357        set_editor_cursor_offset(&editor, &replaced, new_cursor);
9358        autocomplete_container.clear();
9359        assert_eq!(editor.get_text(), "/model");
9360
9361        // The next render MUST display the completed text.
9362        let frame_after = render_layout_frame(dock, 80, 10);
9363        let all: String = frame_after.lines.join("\n");
9364        assert!(
9365            all.contains("/model"),
9366            "completed text missing from next render. Got:\n{all}"
9367        );
9368        // The caret must sit AFTER the completed command (the snap_boundary
9369        // regression put it one char early: "/mode|l" with the final char
9370        // dangling past the caret).
9371        let editor_line = frame_after
9372            .lines
9373            .iter()
9374            .find(|l| l.contains("/model"))
9375            .expect("editor row with completed text");
9376        assert!(
9377            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9378            "caret must follow the full completed text. Got: {editor_line:?}"
9379        );
9380    }
9381
9382    #[test]
9383    fn multiline_autocomplete_preserves_row_and_column() {
9384        let editor = Arc::new(Editor::simple());
9385        editor.set_text("first\n/mo");
9386        editor.set_cursor(1, 3);
9387
9388        let text = editor.get_text();
9389        assert_eq!(editor_cursor_offset(&editor, &text), 9);
9390
9391        let replaced = "first\n/model";
9392        editor.set_text(replaced);
9393        set_editor_cursor_offset(&editor, replaced, 12);
9394        assert_eq!(editor.cursor_position(), (1, 6));
9395    }
9396
9397    #[test]
9398    fn test_slash_command_dispatch() {
9399        // The registry is the single source of truth for dispatch: `find(token)`
9400        // returns the command (by name or alias) whose `name()` is the canonical
9401        // form, or `None` for an unknown token. This replaces the old enum-based
9402        // `handle_slash_command` assertions with equivalent registry lookups.
9403        let registry = build_builtin_registry();
9404
9405        // Helper: a token resolves to the command with this canonical name.
9406        let resolves_to = |token: &str, canonical: &str| {
9407            let found = registry.find(token).expect("{token} should resolve");
9408            assert_eq!(
9409                found.name(),
9410                canonical,
9411                "{token} resolved to {} (expected {canonical})",
9412                found.name()
9413            );
9414        };
9415
9416        resolves_to("/help", "/help");
9417        resolves_to("/?", "/help"); // alias → canonical
9418        resolves_to("/clear", "/clear");
9419        resolves_to("/new", "/clear"); // alias
9420        resolves_to("/q", "/exit"); // alias
9421        resolves_to("/quit", "/exit"); // alias
9422        resolves_to("/version", "/version");
9423        resolves_to("/v", "/version"); // alias
9424        resolves_to("/changelog", "/changelog");
9425        resolves_to("/hotkeys", "/hotkeys");
9426        resolves_to("/model", "/model");
9427        resolves_to("/m", "/model"); // alias
9428        resolves_to("/theme", "/theme");
9429        resolves_to("/session", "/session");
9430        resolves_to("/resume", "/session"); // alias
9431        resolves_to("/compact", "/compact");
9432        resolves_to("/copy", "/copy");
9433        resolves_to("/thinking", "/thinking");
9434        resolves_to("/think", "/thinking"); // alias
9435        resolves_to("/tools", "/tools");
9436        resolves_to("/images", "/images");
9437        resolves_to("/armin", "/armin");
9438        resolves_to("/earendil", "/earendil");
9439        resolves_to("/context", "/context");
9440        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
9441        resolves_to("/settings", "/settings");
9442        resolves_to("/name", "/name");
9443        resolves_to("/export", "/export");
9444
9445        // Unknown token → not found.
9446        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9447    }
9448
9449    #[test]
9450
9451    fn test_registry_visible_entries_cover_dispatch() {
9452        // The autocomplete list is derived from the registry, so every visible
9453        // command the dispatcher recognizes must appear in it — by construction,
9454        // but this guards against a future command being registered with
9455        // `visible()` / a non-empty description that the builder drops.
9456        let registry = build_builtin_registry();
9457        let names: Vec<String> = registry
9458            .visible_entries()
9459            .iter()
9460            .map(|c| c.name.clone())
9461            .collect();
9462        for recognized in [
9463            "/help",
9464            "/clear",
9465            "/new",
9466            "/exit",
9467            "/quit",
9468            "/version",
9469            "/changelog",
9470            "/model",
9471            "/session",
9472            "/theme",
9473            "/compact",
9474            "/copy",
9475            "/hotkeys",
9476            "/tools",
9477            "/images",
9478            "/thinking",
9479            "/armin",
9480            "/earendil",
9481        ] {
9482            assert!(
9483                names.contains(&recognized.to_string()),
9484                "{recognized} missing from autocomplete list"
9485            );
9486        }
9487        // Hidden commands stay off the list.
9488        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9489            assert!(
9490                !names.contains(&hidden.to_string()),
9491                "{hidden} should be hidden from autocomplete"
9492            );
9493        }
9494    }
9495
9496    #[test]
9497    fn test_agent_event_mapping_creates_assistant_and_tool() {
9498        // Synthetic AgentEvent sequence → UI mutations, exercised against the
9499        // real drain handler with a no-op TUI stand-in.
9500        use rpi_ai::types::{
9501            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9502            ToolCall, ToolCallType, Usage,
9503        };
9504
9505        let state = Arc::new(TuiState {
9506            current_assistant: std::sync::Mutex::new(None),
9507            tool_components: std::sync::Mutex::new(HashMap::new()),
9508            bash_components: std::sync::Mutex::new(HashMap::new()),
9509            themes_enabled: true,
9510            hide_thinking: std::sync::Mutex::new(false),
9511            tool_outputs_expanded: std::sync::Mutex::new(false),
9512            show_terminal_progress: true,
9513            status: std::sync::Mutex::new(RunStatus::Idle),
9514            js_preparation_cancel: std::sync::Mutex::new(None),
9515            footer: Arc::new(FooterComponent::new()),
9516            status_container: Arc::new(Container::new()),
9517            chat_container: Arc::new(Container::new()),
9518            loader: Arc::new(Loader::new()),
9519            last_assistant_text: std::sync::Mutex::new(String::new()),
9520            active_selector: std::sync::Mutex::new(None),
9521            active_extension_editor: std::sync::Mutex::new(None),
9522            active_extension_input: std::sync::Mutex::new(None),
9523            active_extension_cancel: std::sync::Mutex::new(None),
9524            autocomplete: AutocompleteManager::new(),
9525            autocomplete_container: Arc::new(Container::new()),
9526            autocomplete_max_visible: 5,
9527            pending_images: std::sync::Mutex::new(Vec::new()),
9528            theme_manager: Arc::new(ThemeManager::new()),
9529            tui: None,
9530            current_model_id: std::sync::Mutex::new(String::new()),
9531            show_images: std::sync::Mutex::new(true),
9532            history: std::sync::Mutex::new(Vec::new()),
9533            history_index: std::sync::Mutex::new(-1),
9534            history_draft: std::sync::Mutex::new(None),
9535            last_input_tokens: std::sync::Mutex::new(0),
9536            scoped_edit: std::sync::Mutex::new(None),
9537            markdown_transformer: std::sync::Mutex::new(None),
9538            extension_session: Arc::new(std::sync::Mutex::new(
9539                rpi_extensions::ExtensionSession::none(),
9540            )),
9541        });
9542
9543        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
9544        // terminal; instead, exercise the *mutation* half directly against a
9545        // captured chat container via a synthetic message-start event's data.
9546        let assistant = AssistantMessage {
9547            role: rpi_ai::types::AssistantRole,
9548            content: vec![
9549                Content::Thinking(ThinkingContent {
9550                    kind: ThinkingContentType,
9551                    thinking: "Reasoning about the reply.".into(),
9552                    thinking_signature: None,
9553                    redacted: false,
9554                }),
9555                Content::Text(TextContent {
9556                    kind: TextContentType,
9557                    text: "Hello.".into(),
9558                    text_signature: None,
9559                }),
9560                Content::ToolCall(ToolCall {
9561                    kind: ToolCallType,
9562                    id: "tc1".into(),
9563                    name: "bash".into(),
9564                    arguments: serde_json::json!({"command": "echo hi"}),
9565                    thought_signature: None,
9566                    namespace: None,
9567                }),
9568            ],
9569            api: rpi_ai::Api::AnthropicMessages,
9570            provider: "anthropic".into(),
9571            model: "claude-sonnet-5".into(),
9572            response_model: None,
9573            response_id: None,
9574            usage: Usage::zero(),
9575            stop_reason: StopReason::Stop,
9576            deferred: None,
9577            error_message: None,
9578            raw_stop_reason: None,
9579            end_turn: None,
9580            timestamp: 0,
9581        };
9582
9583        // Manually apply the MessageStart assistant branch logic (mirrors the
9584        // drain handler, without needing a TuiAltScreen).
9585        let comp = Arc::new(AssistantMessageComponent::new(
9586            AssistantMessageOptions::default(),
9587        ));
9588        comp.set_streaming(true);
9589        comp.update_blocks(&assistant_blocks(&assistant));
9590        let chat = Arc::new(Container::new());
9591        chat.add_child(comp.clone());
9592        *state.current_assistant.lock().unwrap() = Some(comp);
9593
9594        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
9595        for c in &assistant.content {
9596            if let Content::ToolCall(tc) = c {
9597                let mut tools = state.tool_components.lock().unwrap();
9598                if !tools.contains_key(&tc.id) {
9599                    let tc_comp = Arc::new(ToolExecutionComponent::new(
9600                        &tc.name,
9601                        &tc.arguments.to_string(),
9602                    ));
9603                    tc_comp.set_running();
9604                    chat.add_child(tc_comp.clone());
9605                    tools.insert(tc.id.clone(), tc_comp);
9606                }
9607            }
9608        }
9609
9610        // Assert: the assistant component rendered the text + the thinking
9611        // block (the update_blocks path keeps thinking visible), and a tool
9612        // component was registered.
9613        let rendered = chat.render(80);
9614        let joined: String = rendered.join("\n");
9615        assert!(
9616            joined.contains("Hello."),
9617            "assistant text not rendered: {joined}"
9618        );
9619        assert!(
9620            joined.contains("Reasoning about the reply."),
9621            "thinking block not rendered: {joined}"
9622        );
9623        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9624        assert!(state.current_assistant.lock().unwrap().is_some());
9625
9626        // Manually apply ToolExecutionEnd (mirrors drain).
9627        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
9628        ended.set_result("hi", false);
9629        assert!(state.tool_components.lock().unwrap().is_empty());
9630
9631        // A running bash panel owns the visible spinner. The global loader is
9632        // hidden until the last concurrent bash tool completes, then restored
9633        // while the agent remains in the Working state.
9634        assert!(state.try_start_working());
9635        assert!(
9636            !state.try_start_working(),
9637            "a second submit must be rejected"
9638        );
9639        state.set_status(RunStatus::Idle);
9640        state.set_status(RunStatus::Working);
9641        assert_eq!(state.status_container.child_count(), 1);
9642        assert!(state.footer.get_status().is_empty());
9643        state.show_retry(3, 10, 8_000);
9644        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
9645        assert!(retry_status.contains("Retrying (3/10)"));
9646        state.set_status(RunStatus::Working);
9647        {
9648            let mut bash = state.bash_components.lock().unwrap();
9649            bash.insert(
9650                "bash-1".into(),
9651                Arc::new(BashExecutionComponent::new("one")),
9652            );
9653            bash.insert(
9654                "bash-2".into(),
9655                Arc::new(BashExecutionComponent::new("two")),
9656            );
9657        }
9658        state.sync_working_loader_with_bash();
9659        assert_eq!(state.status_container.child_count(), 0);
9660        state.bash_components.lock().unwrap().remove("bash-1");
9661        state.sync_working_loader_with_bash();
9662        assert_eq!(state.status_container.child_count(), 0);
9663        state.bash_components.lock().unwrap().remove("bash-2");
9664        state.sync_working_loader_with_bash();
9665        assert_eq!(state.status_container.child_count(), 1);
9666
9667        state.set_status(RunStatus::Aborting);
9668        assert_eq!(state.status_container.child_count(), 0);
9669        assert!(!state.loader.is_running());
9670    }
9671
9672    #[test]
9673    fn fresh_launch_does_not_restore_old_history() {
9674        let fresh = Args::default();
9675        assert!(!launch_restores_history(&fresh));
9676
9677        let continued = Args {
9678            continue_session: true,
9679            ..Args::default()
9680        };
9681        assert!(launch_restores_history(&continued));
9682
9683        let selected = Args {
9684            session: Some("session-id".into()),
9685            ..Args::default()
9686        };
9687        assert!(launch_restores_history(&selected));
9688    }
9689
9690    #[test]
9691    fn test_short_model_name() {
9692        assert_eq!(
9693            short_model_name("anthropic:claude-sonnet-5"),
9694            "claude-sonnet-5"
9695        );
9696        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
9697    }
9698
9699    #[test]
9700    fn model_selector_items_are_deduplicated_and_provider_qualified() {
9701        use rpi_ai::{Api, Model};
9702
9703        let mut gateway = Model::new(
9704            "gpt-5.6-sol",
9705            "GPT 5.6 Sol",
9706            Api::OpenaiCompletions,
9707            "routeryo-copy",
9708            "https://gateway.example.com",
9709        );
9710        let duplicate = gateway.clone();
9711        let anthropic = Model::new(
9712            "claude-sonnet-5",
9713            "Claude Sonnet 5",
9714            Api::AnthropicMessages,
9715            "anthropic",
9716            "https://api.anthropic.com",
9717        );
9718        gateway.headers = Some(std::collections::BTreeMap::from([(
9719            "authorization".into(),
9720            "Bearer test".into(),
9721        )]));
9722
9723        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
9724        assert_eq!(items.len(), 2);
9725        assert_eq!(items[0].value, "gpt-5.6-sol");
9726        assert_eq!(items[0].label, "GPT 5.6 Sol");
9727        assert_eq!(
9728            items[0].description.as_deref(),
9729            Some("routeryo-copy/gpt-5.6-sol (current)")
9730        );
9731        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
9732    }
9733
9734    #[test]
9735    fn model_selector_match_accepts_bare_and_qualified_ids() {
9736        use rpi_ai::{Api, Model};
9737
9738        let gateway = Model::new(
9739            "gpt-5.6-sol",
9740            "GPT 5.6 Sol",
9741            Api::OpenaiCompletions,
9742            "routeryo-copy",
9743            "https://gateway.example.com",
9744        );
9745        let anthropic = Model::new(
9746            "claude-sonnet-5",
9747            "Claude Sonnet 5",
9748            Api::AnthropicMessages,
9749            "anthropic",
9750            "https://api.anthropic.com",
9751        );
9752        let catalog = [gateway, anthropic];
9753        assert_eq!(
9754            find_model_selector_match(&catalog, "gpt-5.6-sol")
9755                .unwrap()
9756                .provider,
9757            "routeryo-copy"
9758        );
9759        assert_eq!(
9760            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
9761                .unwrap()
9762                .id,
9763            "gpt-5.6-sol"
9764        );
9765        assert_eq!(
9766            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
9767                .unwrap()
9768                .id,
9769            "claude-sonnet-5"
9770        );
9771        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
9772    }
9773
9774    #[test]
9775    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
9776        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
9777
9778        let failed = AssistantMessage {
9779            role: AssistantRole,
9780            content: Vec::new(),
9781            api: rpi_ai::Api::AnthropicMessages,
9782            provider: "anthropic".into(),
9783            model: "claude-sonnet-5".into(),
9784            response_model: None,
9785            response_id: None,
9786            usage: Usage::zero(),
9787            stop_reason: StopReason::Error,
9788            deferred: None,
9789            error_message: Some("upstream returned 401".into()),
9790            raw_stop_reason: None,
9791            end_turn: None,
9792            timestamp: 0,
9793        };
9794        assert_eq!(
9795            assistant_error_text(&failed).as_deref(),
9796            Some("upstream returned 401")
9797        );
9798
9799        let mut no_detail = failed;
9800        no_detail.error_message = Some("  ".into());
9801        assert_eq!(
9802            assistant_error_text(&no_detail).as_deref(),
9803            Some("Provider request failed.")
9804        );
9805
9806        let mut aborted = no_detail;
9807        aborted.stop_reason = StopReason::Aborted;
9808        aborted.error_message = Some("abort error: Request aborted".into());
9809        assert_eq!(
9810            assistant_error_text(&aborted).as_deref(),
9811            Some("abort error: Request aborted")
9812        );
9813
9814        aborted.error_message = None;
9815        assert_eq!(
9816            assistant_error_text(&aborted).as_deref(),
9817            Some("Request aborted.")
9818        );
9819    }
9820
9821    #[test]
9822    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
9823        assert_eq!(
9824            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
9825            "405\nMethod Not Allowed"
9826        );
9827        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
9828        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
9829        let long = "x".repeat(20_000);
9830        let cleaned = sanitize_error_message(&long);
9831        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
9832        assert!(cleaned.ends_with('…'));
9833    }
9834
9835    #[test]
9836    fn test_cycle_next_model_wraps_around() {
9837        use rpi_ai::{Api, Model};
9838        let mk = |id: &str| {
9839            Model::new(
9840                id,
9841                id,
9842                Api::AnthropicMessages,
9843                "anthropic",
9844                "https://api.anthropic.com",
9845            )
9846        };
9847        let catalog = [mk("a"), mk("b"), mk("c")];
9848        // Next after "a" is "b"; after "c" wraps to "a".
9849        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
9850        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
9851        // An unknown current id falls back to the first model.
9852        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
9853        // Empty catalog yields None.
9854        let empty: Vec<Model> = vec![];
9855        assert!(cycle_next_model(&empty, "a").is_none());
9856    }
9857
9858    #[test]
9859    fn test_autocomplete_slash_suggestions_render() {
9860        // The autocomplete container should render at least one suggestion
9861        // line when the editor holds a `/` prefix, and clear when it doesn't.
9862        let state = Arc::new(TuiState {
9863            current_assistant: std::sync::Mutex::new(None),
9864            tool_components: std::sync::Mutex::new(HashMap::new()),
9865            bash_components: std::sync::Mutex::new(HashMap::new()),
9866            themes_enabled: true,
9867            hide_thinking: std::sync::Mutex::new(false),
9868            tool_outputs_expanded: std::sync::Mutex::new(false),
9869            show_terminal_progress: true,
9870            status: std::sync::Mutex::new(RunStatus::Idle),
9871            js_preparation_cancel: std::sync::Mutex::new(None),
9872            footer: Arc::new(FooterComponent::new()),
9873            status_container: Arc::new(Container::new()),
9874            chat_container: Arc::new(Container::new()),
9875            loader: Arc::new(Loader::new()),
9876            last_assistant_text: std::sync::Mutex::new(String::new()),
9877            active_selector: std::sync::Mutex::new(None),
9878            active_extension_editor: std::sync::Mutex::new(None),
9879            active_extension_input: std::sync::Mutex::new(None),
9880            active_extension_cancel: std::sync::Mutex::new(None),
9881            autocomplete: AutocompleteManager::new(),
9882            autocomplete_container: Arc::new(Container::new()),
9883            autocomplete_max_visible: 5,
9884            pending_images: std::sync::Mutex::new(Vec::new()),
9885            theme_manager: Arc::new(ThemeManager::new()),
9886            tui: None,
9887            current_model_id: std::sync::Mutex::new(String::new()),
9888            show_images: std::sync::Mutex::new(true),
9889            history: std::sync::Mutex::new(Vec::new()),
9890            history_index: std::sync::Mutex::new(-1),
9891            history_draft: std::sync::Mutex::new(None),
9892            last_input_tokens: std::sync::Mutex::new(0),
9893            scoped_edit: std::sync::Mutex::new(None),
9894            markdown_transformer: std::sync::Mutex::new(None),
9895            extension_session: Arc::new(std::sync::Mutex::new(
9896                rpi_extensions::ExtensionSession::none(),
9897            )),
9898        });
9899        {
9900            let mut combined = CombinedAutocompleteProvider::new();
9901            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9902                build_builtin_registry().visible_entries(),
9903            )));
9904            state.autocomplete.set_provider(Arc::new(combined));
9905        }
9906
9907        let editor = Arc::new(Editor::simple());
9908        editor.set_text("/he");
9909        editor.set_cursor(0, 3);
9910        refresh_autocomplete(&state, &editor);
9911        let lines = state.autocomplete_container.render(80);
9912        let joined: String = lines.join("\n");
9913        assert!(
9914            joined.contains("/help"),
9915            "slash suggestions not rendered: {joined}"
9916        );
9917
9918        // Clear: no suggestions for plain text.
9919        editor.set_text("hello");
9920        editor.set_cursor(0, 5);
9921        refresh_autocomplete(&state, &editor);
9922        assert!(state.autocomplete_container.render(80).is_empty());
9923    }
9924
9925    #[test]
9926    fn test_select_list_swap_restores_editor() {
9927        // The editor-container swap: opening a selector replaces the editor
9928        // child; closing restores it. Verify the container child count + the
9929        // active_selector flag round-trip.
9930        let state = Arc::new(TuiState {
9931            current_assistant: std::sync::Mutex::new(None),
9932            tool_components: std::sync::Mutex::new(HashMap::new()),
9933            bash_components: std::sync::Mutex::new(HashMap::new()),
9934            themes_enabled: true,
9935            hide_thinking: std::sync::Mutex::new(false),
9936            tool_outputs_expanded: std::sync::Mutex::new(false),
9937            show_terminal_progress: true,
9938            status: std::sync::Mutex::new(RunStatus::Idle),
9939            js_preparation_cancel: std::sync::Mutex::new(None),
9940            footer: Arc::new(FooterComponent::new()),
9941            status_container: Arc::new(Container::new()),
9942            chat_container: Arc::new(Container::new()),
9943            loader: Arc::new(Loader::new()),
9944            last_assistant_text: std::sync::Mutex::new(String::new()),
9945            active_selector: std::sync::Mutex::new(None),
9946            active_extension_editor: std::sync::Mutex::new(None),
9947            active_extension_input: std::sync::Mutex::new(None),
9948            active_extension_cancel: std::sync::Mutex::new(None),
9949            autocomplete: AutocompleteManager::new(),
9950            autocomplete_container: Arc::new(Container::new()),
9951            autocomplete_max_visible: 5,
9952            pending_images: std::sync::Mutex::new(Vec::new()),
9953            theme_manager: Arc::new(ThemeManager::new()),
9954            tui: None,
9955            current_model_id: std::sync::Mutex::new(String::new()),
9956            show_images: std::sync::Mutex::new(true),
9957            history: std::sync::Mutex::new(Vec::new()),
9958            history_index: std::sync::Mutex::new(-1),
9959            history_draft: std::sync::Mutex::new(None),
9960            last_input_tokens: std::sync::Mutex::new(0),
9961            scoped_edit: std::sync::Mutex::new(None),
9962            markdown_transformer: std::sync::Mutex::new(None),
9963            extension_session: Arc::new(std::sync::Mutex::new(
9964                rpi_extensions::ExtensionSession::none(),
9965            )),
9966        });
9967        let editor_container = Arc::new(Container::new());
9968        let editor = Arc::new(Editor::simple());
9969        editor_container.add_child(editor.clone());
9970        assert!(!state.selector_open());
9971
9972        let tui_terminal = Box::new(ProcessTerminal::new());
9973        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9974        let list = Arc::new(SelectList::new(
9975            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9976            5,
9977        ));
9978        open_selector(
9979            &state,
9980            &editor_container,
9981            &editor,
9982            &tui,
9983            list,
9984            SelectorKind::Theme,
9985        );
9986        assert!(state.selector_open());
9987        // list only (editor swapped out).
9988        assert_eq!(editor_container.child_count(), 1);
9989
9990        close_selector(&state, &editor_container, &editor, &tui);
9991        assert!(!state.selector_open());
9992        // editor restored.
9993        assert_eq!(editor_container.child_count(), 1);
9994    }
9995
9996    #[test]
9997    fn test_message_history_browse_restores_draft() {
9998        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
9999        // messages, browse older → newer → back past the newest restores the
10000        // draft the user was typing.
10001        let state = Arc::new(TuiState {
10002            current_assistant: std::sync::Mutex::new(None),
10003            tool_components: std::sync::Mutex::new(HashMap::new()),
10004            bash_components: std::sync::Mutex::new(HashMap::new()),
10005            themes_enabled: true,
10006            hide_thinking: std::sync::Mutex::new(false),
10007            tool_outputs_expanded: std::sync::Mutex::new(false),
10008            show_terminal_progress: true,
10009            status: std::sync::Mutex::new(RunStatus::Idle),
10010            js_preparation_cancel: std::sync::Mutex::new(None),
10011            footer: Arc::new(FooterComponent::new()),
10012            status_container: Arc::new(Container::new()),
10013            chat_container: Arc::new(Container::new()),
10014            loader: Arc::new(Loader::new()),
10015            last_assistant_text: std::sync::Mutex::new(String::new()),
10016            active_selector: std::sync::Mutex::new(None),
10017            active_extension_editor: std::sync::Mutex::new(None),
10018            active_extension_input: std::sync::Mutex::new(None),
10019            active_extension_cancel: std::sync::Mutex::new(None),
10020            autocomplete: AutocompleteManager::new(),
10021            autocomplete_container: Arc::new(Container::new()),
10022            autocomplete_max_visible: 5,
10023            pending_images: std::sync::Mutex::new(Vec::new()),
10024            theme_manager: Arc::new(ThemeManager::new()),
10025            tui: None,
10026            current_model_id: std::sync::Mutex::new(String::new()),
10027            show_images: std::sync::Mutex::new(true),
10028            history: std::sync::Mutex::new(Vec::new()),
10029            history_index: std::sync::Mutex::new(-1),
10030            history_draft: std::sync::Mutex::new(None),
10031            last_input_tokens: std::sync::Mutex::new(0),
10032            scoped_edit: std::sync::Mutex::new(None),
10033            markdown_transformer: std::sync::Mutex::new(None),
10034            extension_session: Arc::new(std::sync::Mutex::new(
10035                rpi_extensions::ExtensionSession::none(),
10036            )),
10037        });
10038        let editor = Arc::new(Editor::simple());
10039
10040        push_history(&state, "first message");
10041        push_history(&state, "second message");
10042        // Consecutive duplicate is skipped.
10043        push_history(&state, "second message");
10044        push_history(&state, "   "); // empty → skipped
10045        assert_eq!(state.history.lock().unwrap().len(), 2);
10046        assert_eq!(state.history.lock().unwrap()[0], "second message");
10047
10048        // User starts typing a fresh prompt.
10049        editor.set_text("half-typed");
10050        editor.set_cursor(0, 11);
10051
10052        // ↑ → most recent.
10053        navigate_history(&state, &editor, -1);
10054        assert_eq!(editor.get_text(), "second message");
10055        assert_eq!(*state.history_index.lock().unwrap(), 0);
10056        // ↑ → older.
10057        navigate_history(&state, &editor, -1);
10058        assert_eq!(editor.get_text(), "first message");
10059        assert_eq!(*state.history_index.lock().unwrap(), 1);
10060        // ↑ past the oldest → stays (no wrap).
10061        navigate_history(&state, &editor, -1);
10062        assert_eq!(editor.get_text(), "first message");
10063        // ↓ → newer.
10064        navigate_history(&state, &editor, 1);
10065        assert_eq!(editor.get_text(), "second message");
10066        // ↓ past the newest → restores the draft.
10067        navigate_history(&state, &editor, 1);
10068        assert_eq!(editor.get_text(), "half-typed");
10069        assert_eq!(*state.history_index.lock().unwrap(), -1);
10070    }
10071
10072    #[test]
10073    fn test_accept_top_suggestion_replaces_prefix() {
10074        // `/he` + Tab → `/help ` (slash command provider inserts a space).
10075        let state = Arc::new(TuiState {
10076            current_assistant: std::sync::Mutex::new(None),
10077            tool_components: std::sync::Mutex::new(HashMap::new()),
10078            bash_components: std::sync::Mutex::new(HashMap::new()),
10079            themes_enabled: true,
10080            hide_thinking: std::sync::Mutex::new(false),
10081            tool_outputs_expanded: std::sync::Mutex::new(false),
10082            show_terminal_progress: true,
10083            status: std::sync::Mutex::new(RunStatus::Idle),
10084            js_preparation_cancel: std::sync::Mutex::new(None),
10085            footer: Arc::new(FooterComponent::new()),
10086            status_container: Arc::new(Container::new()),
10087            chat_container: Arc::new(Container::new()),
10088            loader: Arc::new(Loader::new()),
10089            last_assistant_text: std::sync::Mutex::new(String::new()),
10090            active_selector: std::sync::Mutex::new(None),
10091            active_extension_editor: std::sync::Mutex::new(None),
10092            active_extension_input: std::sync::Mutex::new(None),
10093            active_extension_cancel: std::sync::Mutex::new(None),
10094            autocomplete: AutocompleteManager::new(),
10095            autocomplete_container: Arc::new(Container::new()),
10096            autocomplete_max_visible: 5,
10097            pending_images: std::sync::Mutex::new(Vec::new()),
10098            theme_manager: Arc::new(ThemeManager::new()),
10099            tui: None,
10100            current_model_id: std::sync::Mutex::new(String::new()),
10101            show_images: std::sync::Mutex::new(true),
10102            history: std::sync::Mutex::new(Vec::new()),
10103            history_index: std::sync::Mutex::new(-1),
10104            history_draft: std::sync::Mutex::new(None),
10105            last_input_tokens: std::sync::Mutex::new(0),
10106            scoped_edit: std::sync::Mutex::new(None),
10107            markdown_transformer: std::sync::Mutex::new(None),
10108            extension_session: Arc::new(std::sync::Mutex::new(
10109                rpi_extensions::ExtensionSession::none(),
10110            )),
10111        });
10112        {
10113            let mut combined = CombinedAutocompleteProvider::new();
10114            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10115                build_builtin_registry().visible_entries(),
10116            )));
10117            state.autocomplete.set_provider(Arc::new(combined));
10118        }
10119        let editor = Arc::new(Editor::simple());
10120        editor.set_text("/he");
10121        editor.set_cursor(0, 3);
10122        refresh_autocomplete(&state, &editor);
10123        let accepted = accept_top_suggestion(&state, &editor);
10124        assert!(accepted, "should accept the top suggestion");
10125        let text = editor.get_text();
10126        assert!(
10127            text.starts_with("/help"),
10128            "editor text should start with /help, got {text}"
10129        );
10130    }
10131
10132    #[test]
10133    fn configured_key_parser_supports_native_notation() {
10134        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10135        assert_eq!(combo.code, KeyCode::Char('g'));
10136        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10137        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10138        assert_eq!(combo.code, KeyCode::BackTab);
10139    }
10140
10141    #[test]
10142    fn double_escape_trigger_has_half_second_window() {
10143        let now = std::time::Instant::now();
10144        assert!(!double_escape_trigger(None, now));
10145        assert!(double_escape_trigger(
10146            Some(now - std::time::Duration::from_millis(500)),
10147            now
10148        ));
10149        assert!(!double_escape_trigger(
10150            Some(now - std::time::Duration::from_millis(501)),
10151            now
10152        ));
10153    }
10154
10155    // ---- ask_user TUI bridge ----
10156
10157    #[test]
10158    fn ask_user_tool_names_are_recognized() {
10159        assert!(is_ask_user_tool("ask_user"));
10160        assert!(is_ask_user_tool("ask_user_question"));
10161        assert!(!is_ask_user_tool("bash"));
10162    }
10163
10164    #[test]
10165    fn ask_user_args_display_never_leaks_transport_json() {
10166        let text = ask_user_args_display(&serde_json::json!({
10167            "question": "你的代理端口是多少?",
10168            "context": "本机",
10169            "options": ["7890", {"title": "7897", "description": "clash"}],
10170            "suggest": "1080"
10171        }));
10172        assert!(text.contains("你的代理端口是多少?"));
10173        assert!(text.contains("本机"));
10174        assert!(text.contains("Choices: 7890, 7897"));
10175        assert!(text.contains("Suggestions: 1080"));
10176        assert!(!text.trim_start().starts_with('{'));
10177
10178        let multi = ask_user_args_display(&serde_json::json!({
10179            "questions": [
10180                {"id": "a", "question": "Q1"},
10181                {"id": "b", "header": "H", "question": "Q2"}
10182            ]
10183        }));
10184        assert!(multi.contains("Q1"));
10185        assert!(multi.contains("H: Q2"));
10186
10187        assert_eq!(
10188            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10189            "Confirmation requested: Deploy?"
10190        );
10191        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10192    }
10193
10194    #[test]
10195    fn ask_user_result_and_progress_prefer_readable_content() {
10196        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10197        assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10198            .contains("端口?"));
10199        let empty = rpi_agent::AgentToolResult::default();
10200        assert!(
10201            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10202                .contains("端口?")
10203        );
10204        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10205        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10206        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10207    }
10208
10209    #[test]
10210    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10211        let flat = rpi_extensions::UiDialogRequest {
10212            request_id: "r1".into(),
10213            tool_call_id: Some("c1".into()),
10214            ui: serde_json::json!({
10215                "kind": "selector",
10216                "id": "proxy_port",
10217                "header": "Proxy",
10218                "question": "端口?",
10219                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10220                "allowMultiple": true,
10221                "allowFreeform": false,
10222                "suggest": "1080"
10223            }),
10224            raw: serde_json::json!({}),
10225        };
10226        let prompt = parse_ask_user_prompt(&flat);
10227        assert_eq!(prompt.question_id, "proxy_port");
10228        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10229        assert_eq!(prompt.options.len(), 2);
10230        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10231        assert!(prompt.allow_multiple);
10232        assert!(!prompt.allow_freeform);
10233        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10234
10235        let alias = rpi_extensions::UiDialogRequest {
10236            ui: serde_json::json!({
10237                "questions": [{"id": "q", "question": "Q?", "options": []}]
10238            }),
10239            ..flat.clone()
10240        };
10241        let prompt = parse_ask_user_prompt(&alias);
10242        assert_eq!(prompt.question_id, "q");
10243        assert!(prompt.allow_freeform, "no options defaults to freeform");
10244
10245        let confirm = rpi_extensions::UiDialogRequest {
10246            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10247            ..flat.clone()
10248        };
10249        let prompt = parse_ask_user_prompt(&confirm);
10250        assert_eq!(prompt.kind, "confirm");
10251        assert_eq!(prompt.options.len(), 2);
10252        assert_eq!(prompt.options[0].0, "Yes");
10253    }
10254
10255    #[test]
10256    fn ask_user_bridge_answers_and_cancels_by_request_id() {
10257        let mailbox = rpi_extensions::UiDialogMailbox::new();
10258        mailbox.attach();
10259        let bridge = AskUserBridge::new(mailbox.clone());
10260
10261        // Two concurrent requests must not cross answers.
10262        mailbox
10263            .handle(serde_json::json!({
10264                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10265                "ui": {"kind": "input", "question": "Port?"}
10266            }))
10267            .unwrap();
10268        mailbox
10269            .handle(serde_json::json!({
10270                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10271                "ui": {"kind": "input", "question": "Level?"}
10272            }))
10273            .unwrap();
10274
10275        let first = bridge.take_pending().expect("r1 visible");
10276        assert_eq!(first.request_id, "r1");
10277        assert!(
10278            bridge.take_pending().is_none(),
10279            "only one request occupies the input slot"
10280        );
10281        bridge.respond("r1", serde_json::json!({"text": "7897"}));
10282        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10283
10284        let second = bridge.take_pending().expect("r2 visible");
10285        assert_eq!(second.request_id, "r2");
10286        bridge.cancel("r2");
10287        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10288
10289        // cancel_all drops anything still queued.
10290        mailbox
10291            .handle(serde_json::json!({
10292                "op": "open", "requestId": "r3",
10293                "ui": {"kind": "input", "question": "X?"}
10294            }))
10295            .unwrap();
10296        bridge.cancel_all();
10297        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10298
10299        // A detached mailbox rejects new prompts (headless contract).
10300        bridge.shutdown();
10301        assert!(mailbox
10302            .handle(serde_json::json!({
10303                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10304            }))
10305            .is_err());
10306    }
10307}