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