Skip to main content

rpi_cli/
interactive_tui.rs

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