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/// Drive a single prompt through the lane. When `streaming` is true, the
5879/// `AgentEvent` drain task renders the response live and this function only
5880/// awaits completion (to surface hard errors). When false (no `event_rx`),
5881/// it falls back to the blocking await-final-text path.
5882async fn run_prompt_streaming(
5883    lane: &Arc<dyn AgentLane>,
5884    prompt: &str,
5885    tui: &Arc<TuiAltScreen>,
5886    state: &Arc<TuiState>,
5887    streaming: bool,
5888    js: Option<&crate::js_extensions::JsExtensionSession>,
5889    dialog_bridge: &JsDialogBridge,
5890    args: &Args,
5891    images: Vec<rpi_ai::types::ImageContent>,
5892) {
5893    // The persistent Node host is intentionally started at the first real
5894    // prompt. By this point the TUI key worker and all UI/runtime handlers are
5895    // live, so a `before_agent_start` hook may safely open a native dialog. A
5896    // session with no prompt never starts Node merely to render its welcome
5897    // screen; JS commands/tools still trigger the same lazy ensure path.
5898    // Preparation is part of the active turn. Mark it working before Node can
5899    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
5900    // semantics for initial argv prompts as well as editor submissions.
5901    state.set_status(RunStatus::Working);
5902    tui.request_render(false);
5903    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
5904        state.set_status(RunStatus::Idle);
5905        tui.request_render(false);
5906        return;
5907    }
5908
5909    let outcome = lane.prompt_text(prompt, images).await;
5910
5911    // The drain task finalized the assistant message via MessageEnd/AgentEnd,
5912    // but guard against runs that ended without a terminal event (e.g. a hard
5913    // provider rejection before any streaming) by clearing streaming state.
5914    {
5915        let mut cur = state.current_assistant.lock().unwrap();
5916        if let Some(comp) = cur.take() {
5917            comp.set_streaming(false);
5918        }
5919    }
5920
5921    state.set_status(RunStatus::Idle);
5922
5923    match outcome {
5924        Ok(result) => match &result.outcome {
5925            HarnessRunOutcome::Failed {
5926                error,
5927                final_message,
5928                ..
5929            } => {
5930                // Only add an error line if the stream did NOT already render
5931                // an assistant message for it (drain task leaves
5932                // current_assistant Some only on an abrupt end).
5933                let already_rendered = final_message.is_some();
5934                if !already_rendered {
5935                    let msg = final_message
5936                        .as_ref()
5937                        .and_then(|m| m.error_message.clone())
5938                        .unwrap_or_else(|| format!("{error:?}"));
5939                    add_error_message(&state.chat_container, &msg);
5940                }
5941            }
5942            HarnessRunOutcome::Suspended { .. } => {
5943                add_error_message(
5944                    &state.chat_container,
5945                    "Run suspended (deferred) — resume is not supported in v1.",
5946                );
5947            }
5948            HarnessRunOutcome::Aborted { final_message, .. } => {
5949                // Aborted runs render their own partial/final message via the
5950                // stream; only add a note on the blocking fallback path.
5951                if !streaming {
5952                    add_error_message(&state.chat_container, "Request aborted.");
5953                    let _ = final_message; // (rendered by the stream in streaming mode)
5954                }
5955            }
5956            HarnessRunOutcome::Completed { final_message, .. } => {
5957                if !streaming {
5958                    let text = assistant_text(final_message);
5959                    if !text.is_empty() {
5960                        add_assistant_message_blocking(
5961                            &state.chat_container,
5962                            &text,
5963                            state.markdown_transformer(),
5964                        );
5965                        *state.last_assistant_text.lock().unwrap() = text;
5966                    }
5967                }
5968            }
5969        },
5970        Err(e) => {
5971            add_error_message(&state.chat_container, &e.to_string());
5972        }
5973    }
5974
5975    tui.request_render(false);
5976}
5977
5978/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
5979/// Reports the outcome as a transcript note; v1's compaction summarizes the
5980/// session in place, so no streaming display is wired (compaction emits no
5981/// `AgentEvent`s — only the harness bus `RunEnd`).
5982async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
5983    state.set_status(RunStatus::Working);
5984    tui.request_render(false);
5985    match lane.compact(None).await {
5986        Ok(_) => {
5987            add_note_message(&state.chat_container, "Conversation compacted.");
5988        }
5989        Err(e) => {
5990            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
5991        }
5992    }
5993    state.set_status(RunStatus::Idle);
5994    tui.request_render(false);
5995}
5996
5997/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
5998/// when no clipboard is available (or the `clipboard` feature is off), prints a
5999/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
6000fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6001    let text = state.last_assistant_text.lock().unwrap().clone();
6002    if text.is_empty() {
6003        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6004        return;
6005    }
6006    if copy_to_clipboard(&text) {
6007        add_note_message(chat, "Copied last reply to the clipboard.");
6008    } else {
6009        // Clipboard unavailable — print the text to the transcript so the user
6010        // can select/copy it manually (degrades gracefully in headless envs).
6011        let preview: String = text.chars().take(200).collect();
6012        add_note_message(
6013            chat,
6014            &format!(
6015                "Clipboard unavailable. Last reply: {preview}{}",
6016                if text.chars().count() > 200 {
6017                    "…"
6018                } else {
6019                    ""
6020                }
6021            ),
6022        );
6023    }
6024}
6025
6026/// Best-effort clipboard write. Enabled only with the `clipboard` feature
6027/// (`arboard`); otherwise returns `false` so the caller degrades to a hint.
6028#[cfg(feature = "clipboard")]
6029fn copy_to_clipboard(text: &str) -> bool {
6030    match arboard::Clipboard::new() {
6031        Ok(mut cb) => cb.set_text(text).is_ok(),
6032        Err(_) => false,
6033    }
6034}
6035
6036#[cfg(not(feature = "clipboard"))]
6037fn copy_to_clipboard(_text: &str) -> bool {
6038    false
6039}
6040
6041/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
6042/// `ImageContent` contract. The optional clipboard feature keeps headless
6043/// builds free of platform clipboard dependencies.
6044#[cfg(feature = "clipboard")]
6045fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6046    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6047    let image = match clipboard.get_image() {
6048        Ok(image) => image,
6049        Err(_) => return Ok(None),
6050    };
6051    let width =
6052        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
6053    let height =
6054        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
6055    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
6056        return Err("clipboard image dimensions are outside the supported range".into());
6057    }
6058    let mut bytes = Vec::new();
6059    {
6060        let mut encoder = png::Encoder::new(&mut bytes, width, height);
6061        encoder.set_color(png::ColorType::Rgba);
6062        encoder.set_depth(png::BitDepth::Eight);
6063        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
6064        writer
6065            .write_image_data(&image.bytes)
6066            .map_err(|e| e.to_string())?;
6067    }
6068    Ok(Some(rpi_ai::types::ImageContent {
6069        kind: rpi_ai::types::ImageContentType,
6070        data: base64::engine::general_purpose::STANDARD.encode(bytes),
6071        mime_type: "image/png".into(),
6072    }))
6073}
6074
6075fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
6076    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
6077        let mut options = ImageOptions::default();
6078        options.width = Some(48);
6079        options.alt_text = Some("Attached image".into());
6080        chat.add_child(Arc::new(Image::from_data(bytes, options)));
6081        chat.add_child(Arc::new(Spacer::new(1)));
6082    }
6083}
6084
6085#[cfg(not(feature = "clipboard"))]
6086fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6087    Ok(None)
6088}
6089
6090/// Blocking fallback (no `event_rx`): render the final assistant text as a
6091/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
6092/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
6093/// the identity path. The blocking path only fires when `event_rx` is absent,
6094/// so it shares the same transformer the streaming path installs on its
6095/// components.
6096fn add_assistant_message_blocking(
6097    container: &Arc<Container>,
6098    text: &str,
6099    transformer: Option<MarkdownTransformer>,
6100) {
6101    if text.is_empty() {
6102        return;
6103    }
6104    let msg = Arc::new(AssistantMessageComponent::new(
6105        AssistantMessageOptions::default(),
6106    ));
6107    if let Some(t) = &transformer {
6108        msg.set_markdown_transformer(Some(t.clone()));
6109    }
6110    msg.update_text(text);
6111    container.add_child(msg);
6112    container.add_child(Arc::new(Spacer::new(1)));
6113}
6114
6115// ===========================================================================
6116// AgentEvent drain task — the streaming core
6117// ===========================================================================
6118
6119/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
6120/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
6121/// lifetime of the TUI.
6122async fn drain_agent_events(
6123    mut rx: broadcast::Receiver<AgentEvent>,
6124    tui: Arc<TuiAltScreen>,
6125    state: Arc<TuiState>,
6126    chat: Arc<Container>,
6127) {
6128    loop {
6129        match rx.recv().await {
6130            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
6131            Err(broadcast::error::RecvError::Lagged(_)) => {
6132                // We dropped some intermediate deltas; the next MessageUpdate/
6133                // MessageEnd carries a full partial snapshot so the UI re-syncs.
6134                continue;
6135            }
6136            Err(broadcast::error::RecvError::Closed) => break,
6137        }
6138    }
6139}
6140
6141/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
6142/// (`interactive-mode.ts:3068-3396`).
6143async fn handle_agent_event(
6144    event: AgentEvent,
6145    tui: &Arc<TuiAltScreen>,
6146    state: &Arc<TuiState>,
6147    chat: &Arc<Container>,
6148) {
6149    match event {
6150        AgentEvent::AgentStart => {
6151            state.set_status(RunStatus::Working);
6152            tui.request_render(false);
6153        }
6154
6155        AgentEvent::AgentEnd { .. } => {
6156            // Finalize any still-streaming assistant message.
6157            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6158                comp.set_streaming(false);
6159            }
6160            state.set_status(RunStatus::Idle);
6161            tui.request_render(false);
6162        }
6163
6164        AgentEvent::RetryScheduled {
6165            attempt,
6166            max_retries,
6167            delay_ms,
6168            ..
6169        } => {
6170            state.show_retry(attempt, max_retries, delay_ms);
6171            tui.request_render(false);
6172        }
6173
6174        AgentEvent::TurnStart => {
6175            // A new turn: reset the streaming-assistant guard so the next
6176            // MessageStart creates a fresh component.
6177            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6178                comp.set_streaming(false);
6179            }
6180        }
6181
6182        AgentEvent::TurnEnd {
6183            message,
6184            tool_results,
6185        } => {
6186            // Finalize the assistant message for this turn.
6187            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6188                if let AgentMessage::Assistant(a) = &message {
6189                    comp.update_blocks(&assistant_blocks(a));
6190                }
6191                comp.set_streaming(false);
6192            }
6193            // Any tool results whose components were never ended by a
6194            // ToolExecutionEnd get a static rendering here (best-effort). The
6195            // normal path removes the component via ToolExecutionEnd; this is
6196            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
6197            let tools = state.tool_components.lock().unwrap();
6198            for tr in &tool_results {
6199                if tools.contains_key(&tr.tool_call_id) {
6200                    // Will be removed below via ToolExecutionEnd in the normal
6201                    // path; leave as-is if still present.
6202                    let _ = tr;
6203                }
6204            }
6205            drop(tools);
6206            tui.request_render(false);
6207        }
6208
6209        AgentEvent::MessageStart { message } => match message {
6210            AgentMessage::Assistant(a) => {
6211                let comp = Arc::new(AssistantMessageComponent::new(
6212                    AssistantMessageOptions::default(),
6213                ));
6214                // B5e: install the live markdown transformer so the plugin's
6215                // `register_markdown_transformer` handlers apply from the very
6216                // first streamed delta. `set_streaming` before the transform
6217                // install is fine (transform fires on `update_blocks`, below).
6218                if let Some(t) = state.markdown_transformer() {
6219                    comp.set_markdown_transformer(Some(t));
6220                }
6221                comp.set_hide_thinking(state.hide_thinking());
6222                comp.set_streaming(true);
6223                // Render text AND thinking blocks in order (the old path fed
6224                // only the concatenated text, so thinking blocks never showed).
6225                comp.update_blocks(&assistant_blocks(&a));
6226                chat.add_child(comp.clone());
6227                // Spacer(1) separates this assistant turn from the next entry;
6228                // the component itself adds no leading spacer.
6229                chat.add_child(Arc::new(Spacer::new(1)));
6230                *state.current_assistant.lock().unwrap() = Some(comp);
6231                tui.request_render(false);
6232            }
6233            AgentMessage::Custom(custom) => {
6234                let payload = serde_json::json!({
6235                    "customType": custom.role,
6236                    "content": custom.content,
6237                    "details": custom.data,
6238                    "expanded": false,
6239                    "outputPad": 1,
6240                });
6241                if let Some(component) = extension_message_component(
6242                    &state.extension_session,
6243                    &custom.role,
6244                    &payload,
6245                    state.markdown_transformer(),
6246                ) {
6247                    chat.add_child(component);
6248                    chat.add_child(Arc::new(Spacer::new(1)));
6249                    tui.request_render(false);
6250                } else {
6251                    add_note_message(chat, &custom_message_fallback(&custom));
6252                    tui.request_render(false);
6253                }
6254            }
6255            // User / ToolResult / Custom starts are echoed at submit time or
6256            // via the tool-execution components; ignore user/tool dupes.
6257            _ => {}
6258        },
6259
6260        AgentEvent::MessageUpdate {
6261            message,
6262            assistant_message_event,
6263        } => {
6264            if let AgentMessage::Assistant(a) = &message {
6265                let text = assistant_text(a);
6266                let mut saw_bash_tool_call = false;
6267                // Scan content for finalized tool calls → proactively create
6268                // tool components (TS shows the tool as soon as the assistant
6269                // emits the ToolCall; ToolExecutionStart coalesces if it
6270                // already exists).
6271                for c in &a.content {
6272                    if let Content::ToolCall(tc) = c {
6273                        // Streaming providers may expose a placeholder tool
6274                        // call before its name has arrived. It is not a real
6275                        // tool panel and must not leave an empty first row.
6276                        if tc.name.trim().is_empty() {
6277                            continue;
6278                        }
6279                        if tc.name == "bash" {
6280                            // Bash has a dedicated component. Create it here as
6281                            // well as on ToolExecutionStart because the tool
6282                            // call can become visible in a MessageUpdate first.
6283                            // Keeping it in the bash map lets Start coalesce
6284                            // with this panel instead of appending a second one.
6285                            let command = tc
6286                                .arguments
6287                                .get("command")
6288                                .and_then(|v| v.as_str())
6289                                .unwrap_or("");
6290                            // Streaming tool-call arguments may still be `{}`
6291                            // here. Do not create a running bash panel until
6292                            // the lifecycle start event provides the command;
6293                            // otherwise the spinner renders first and the
6294                            // actual `$ command` header appears one frame
6295                            // later.
6296                            if command.trim().is_empty() {
6297                                continue;
6298                            }
6299                            saw_bash_tool_call = true;
6300                            let mut bash = state.bash_components.lock().unwrap();
6301                            if !bash.contains_key(&tc.id) {
6302                                let comp = Arc::new(BashExecutionComponent::new(command));
6303                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6304                                chat.add_child(comp.clone());
6305                                bash.insert(tc.id.clone(), comp);
6306                            }
6307                        } else {
6308                            let mut tools = state.tool_components.lock().unwrap();
6309                            if !tools.contains_key(&tc.id) {
6310                                let comp = Arc::new(ToolExecutionComponent::new(
6311                                    &tc.name,
6312                                    &tc.arguments.to_string(),
6313                                ));
6314                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6315                                comp.set_running();
6316                                chat.add_child(comp.clone());
6317                                tools.insert(tc.id.clone(), comp);
6318                            }
6319                        }
6320                    }
6321                }
6322                // MessageUpdate can expose the finalized bash call before
6323                // ToolExecutionStart arrives. Hide the global `Working…`
6324                // loader immediately when creating that bash panel; otherwise
6325                // it briefly appears alongside the panel's `Running…` spinner.
6326                if saw_bash_tool_call {
6327                    state.sync_working_loader_with_bash();
6328                }
6329                let _ = assistant_message_event; // snapshot already applied via `a`
6330                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
6331                    // Stream the full block list (text + thinking) each update
6332                    // so thinking blocks render live as they arrive.
6333                    comp.update_blocks(&assistant_blocks(a));
6334                }
6335                *state.last_assistant_text.lock().unwrap() = text;
6336                tui.request_render(false);
6337            }
6338        }
6339
6340        AgentEvent::MessageEnd { message } => {
6341            if let AgentMessage::Assistant(a) = &message {
6342                let text = assistant_text(a);
6343                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
6344                    comp.update_blocks(&assistant_blocks(a));
6345                    comp.set_streaming(false);
6346                }
6347                // Cache the finalized text for `/copy`.
6348                if !text.is_empty() {
6349                    *state.last_assistant_text.lock().unwrap() = text;
6350                }
6351                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
6352                // the previous turn's input established a cacheable prefix; a
6353                // large input this turn that read nothing from cache means the
6354                // prefix was re-billed. No cost display — v1 has no per-run
6355                // cost tracking here.
6356                let usage = &a.usage;
6357                let prev_input = *state.last_input_tokens.lock().unwrap();
6358                if prev_input > 0
6359                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
6360                    && usage.cache_read == 0
6361                {
6362                    add_note_message(
6363                        &state.chat_container,
6364                        &format!(
6365                            "Cache miss: {} tokens re-billed",
6366                            format_tokens(usage.input)
6367                        ),
6368                    );
6369                }
6370                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
6371                    add_note_message(chat, &text);
6372                }
6373                // Error assistant messages carry the provider diagnostic in
6374                // `error_message`, not in text content. The assistant
6375                // component is empty for these messages, so surface the
6376                // diagnostic as a visible error row in the transcript.
6377                if let Some(error) = assistant_error_text(a) {
6378                    add_error_message(chat, &error);
6379                }
6380                *state.last_input_tokens.lock().unwrap() = usage.input;
6381            }
6382            tui.request_render(false);
6383        }
6384
6385        AgentEvent::ToolExecutionStart {
6386            tool_call_id,
6387            tool_name,
6388            args,
6389        } => {
6390            // Ignore placeholder lifecycle events emitted before the
6391            // provider has supplied a tool name.
6392            if tool_name.trim().is_empty() {
6393                return;
6394            }
6395            if tool_name == "bash" {
6396                // Bash streams into a dedicated BashExecutionComponent (command
6397                // header + live preview + exit/truncation status) rather than a
6398                // generic ToolExecutionComponent. The command comes from the
6399                // `command` field of the bash tool args.
6400                let command = args
6401                    .get("command")
6402                    .and_then(|v| v.as_str())
6403                    .unwrap_or("")
6404                    .to_string();
6405                // Bash arguments can still be `{}` when the lifecycle event
6406                // races the streamed tool-call argument finalization. Defer
6407                // the panel until a later start event carries the command.
6408                if command.trim().is_empty() {
6409                    return;
6410                }
6411                let mut bash_map = state.bash_components.lock().unwrap();
6412                if let Some(existing) = bash_map.get(&tool_call_id) {
6413                    // A ToolExecutionUpdate already created the panel (fast
6414                    // command — Update can arrive before Start); backfill the
6415                    // command header instead of adding a SECOND panel, which
6416                    // used to stack an empty "$ " box above the real one.
6417                    existing.set_command(&command);
6418                } else {
6419                    let comp = Arc::new(BashExecutionComponent::new(command));
6420                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6421                    chat.add_child(comp.clone());
6422                    bash_map.insert(tool_call_id.clone(), comp);
6423                }
6424            } else {
6425                let _comp = {
6426                    let mut tools = state.tool_components.lock().unwrap();
6427                    if let Some(existing) = tools.get(&tool_call_id) {
6428                        existing.set_args(&args.to_string());
6429                        existing.clone()
6430                    } else {
6431                        let comp =
6432                            Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6433                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6434                        // A `read` of a SKILL.md renders as native Pi's
6435                        // `[skill] <name>` invocation box (custom-message
6436                        // background, collapsed to one line, Ctrl+O expands the
6437                        // skill markdown) instead of a generic READ tool panel.
6438                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
6439                            comp.set_skill_name(skill);
6440                        }
6441                        comp.set_running();
6442                        chat.add_child(comp.clone());
6443                        tools.insert(tool_call_id.clone(), comp.clone());
6444                        comp
6445                    }
6446                };
6447            }
6448            state.sync_working_loader_with_bash();
6449            tui.request_render(false);
6450        }
6451
6452        AgentEvent::ToolExecutionUpdate {
6453            tool_call_id,
6454            tool_name,
6455            args,
6456            partial_result,
6457        } => {
6458            if tool_name.trim().is_empty() {
6459                return;
6460            }
6461            let partial_text = tool_result_text(&partial_result);
6462            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
6463            if tool_name == "bash" {
6464                // Append the streamed chunk to the bash component's preview.
6465                // RAW text (no single-line collapsing) — the old
6466                // `summarize_tool_result` folded every newline into a `⏎`
6467                // glyph, cramming e.g. `ls -la`'s listing onto one line.
6468                let chunk = partial_text;
6469                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
6470                    if has_partial_payload {
6471                        bash.append_output(&chunk);
6472                    }
6473                } else if has_partial_payload {
6474                    // ToolExecutionStart is emitted before a tool can run.
6475                    // Ignore an out-of-order partial until that event gives us
6476                    // the real command, rather than showing a spinner above an
6477                    // empty `$ ` header. Normal updates are handled by the
6478                    // component created in ToolExecutionStart.
6479                }
6480            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
6481                if let Some(skill) = skill_tool_name(&tool_name, &args) {
6482                    comp.set_skill_name(skill);
6483                }
6484                // Raw multi-line text — read/ls-style tools must show their
6485                // full content, not the single-line ⏎-folded summary.
6486                if has_partial_payload {
6487                    comp.set_result(&partial_text, false);
6488                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
6489                }
6490            } else if has_partial_payload {
6491                // No component yet — create a running one so the partial shows.
6492                // Empty callbacks are common before ToolExecutionStart; wait
6493                // for Start so the first panel has the real arguments instead
6494                // of an empty `TOOLS` box.
6495                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &args.to_string()));
6496                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6497                if let Some(skill) = skill_tool_name(&tool_name, &args) {
6498                    comp.set_skill_name(skill);
6499                }
6500                comp.set_running();
6501                comp.set_result(&partial_text, false);
6502                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
6503                chat.add_child(comp.clone());
6504                state
6505                    .tool_components
6506                    .lock()
6507                    .unwrap()
6508                    .insert(tool_call_id.clone(), comp.clone());
6509            }
6510            state.sync_working_loader_with_bash();
6511            tui.request_render(false);
6512        }
6513
6514        AgentEvent::ToolExecutionEnd {
6515            tool_call_id,
6516            tool_name,
6517            result,
6518            is_error,
6519        } => {
6520            if tool_name.trim().is_empty() {
6521                return;
6522            }
6523            if tool_name == "bash" {
6524                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
6525                if let Some(bash) = bash {
6526                    finalize_bash(&bash, &result, is_error);
6527                } else {
6528                    // Bash ended without a Start/Update — render a finalized
6529                    // component directly from the result text.
6530                    let command = result
6531                        .details
6532                        .get("command")
6533                        .and_then(|v| v.as_str())
6534                        .unwrap_or("")
6535                        .to_string();
6536                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
6537                        state.sync_working_loader_with_bash();
6538                        tui.request_render(false);
6539                        return;
6540                    }
6541                    let comp = Arc::new(BashExecutionComponent::new(command));
6542                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6543                    comp.append_output(&tool_result_text(&result));
6544                    finalize_bash(&comp, &result, is_error);
6545                    chat.add_child(comp);
6546                }
6547            } else {
6548                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
6549                if let Some(comp) = comp {
6550                    comp.set_result(&tool_result_text(&result), is_error);
6551                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6552                } else {
6553                    // Tool ended without a Start/Update (e.g. a very fast tool):
6554                    // render a finalized component directly.
6555                    let comp = Arc::new(ToolExecutionComponent::new(&tool_name, ""));
6556                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
6557                    comp.set_result(&tool_result_text(&result), is_error);
6558                    apply_edit_diff(&comp, &tool_name, &result.details, &tui);
6559                    chat.add_child(comp.clone());
6560                }
6561            }
6562            state.sync_working_loader_with_bash();
6563            tui.request_render(false);
6564        }
6565    }
6566}
6567
6568/// Return the diagnostic carried by a failed or aborted provider request.
6569/// Providers may omit `error_message`; keep a stable fallback so a terminal
6570/// request failure can never render as an empty transcript turn.
6571fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
6572    let fallback = match message.stop_reason {
6573        rpi_ai::StopReason::Error => "Provider request failed.",
6574        rpi_ai::StopReason::Aborted => "Request aborted.",
6575        _ => return None,
6576    };
6577    Some(
6578        message
6579            .error_message
6580            .as_deref()
6581            .filter(|text| !text.trim().is_empty())
6582            .unwrap_or(fallback)
6583            .to_string(),
6584    )
6585}
6586
6587/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
6588/// tool result and mark the component complete. Mirrors the TS bash finalize
6589/// path; only the fields `BashExecutionComponent` needs are read.
6590fn finalize_bash(
6591    comp: &Arc<BashExecutionComponent>,
6592    result: &rpi_agent::AgentToolResult,
6593    is_error: bool,
6594) {
6595    // The exit code isn't in details directly (TS carries it elsewhere); use
6596    // `is_error` as the error signal and 0/1 as a best-effort exit code.
6597    let exit_code = if is_error { Some(1) } else { Some(0) };
6598    let truncated = result
6599        .details
6600        .get("truncation")
6601        .and_then(|t| t.get("truncated"))
6602        .and_then(|v| v.as_bool())
6603        .unwrap_or(false);
6604    let full_output_path = result
6605        .details
6606        .get("full_output_path")
6607        .and_then(|v| v.as_str())
6608        .map(|s| s.to_string());
6609    let truncation = BashTruncation {
6610        truncated,
6611        full_output_path,
6612    };
6613    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
6614    comp.set_complete(exit_code, cancelled, truncation);
6615}
6616
6617/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
6618/// display-diff string, render it with colors and attach to the component so
6619/// the changes show in the transcript. `write` has no diff (details: Null) and
6620/// stays a plain summary.
6621fn apply_edit_diff(
6622    comp: &Arc<ToolExecutionComponent>,
6623    tool_name: &str,
6624    details: &serde_json::Value,
6625    tui: &Arc<TuiAltScreen>,
6626) {
6627    if tool_name != "edit" {
6628        return;
6629    }
6630    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
6631        return;
6632    };
6633    if diff_text.is_empty() {
6634        return;
6635    }
6636    let width = tui.width();
6637    let lines = render_diff(diff_text, width);
6638    comp.set_diff(lines);
6639}
6640
6641/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
6642/// `None`. The name is the `SKILL.md` parent directory's basename (matching
6643/// native Pi's skill-file convention). Ordinary markdown/document reads
6644/// return `None` and remain regular `READ` tool panels.
6645fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
6646    if tool_name != "read" {
6647        return None;
6648    }
6649    let path = args.get("path").and_then(|value| value.as_str())?;
6650    let normalized = path.replace('\\', "/");
6651    let file_name = normalized.rsplit('/').next()?;
6652    if !file_name.eq_ignore_ascii_case("SKILL.md") {
6653        return None;
6654    }
6655    normalized
6656        .trim_end_matches('/')
6657        .rsplit('/')
6658        .nth(1)
6659        .filter(|name| !name.is_empty())
6660        .map(str::to_string)
6661}
6662
6663/// Render an `AgentToolResult` as a single-line summary for the
6664/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
6665fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
6666    use rpi_agent::TextContentOrImage;
6667    let mut parts: Vec<String> = Vec::new();
6668    for c in &result.content {
6669        if let TextContentOrImage::Text(t) = c {
6670            parts.push(t.text.clone());
6671        }
6672    }
6673    let joined = parts.join("\n");
6674    // Keep the tool line compact: collapse to a single line, trim length.
6675    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
6676    if one_line.chars().count() > 200 {
6677        let truncated: String = one_line.chars().take(200).collect();
6678        format!("{truncated}…")
6679    } else {
6680        one_line
6681    }
6682}
6683
6684/// The raw multi-line text of a tool result (no single-line collapsing). The
6685/// bash panel needs the original line structure — the old path fed it through
6686/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
6687/// crammed e.g. `ls -la`'s whole listing onto one line.
6688fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
6689    use rpi_agent::TextContentOrImage;
6690    let mut parts: Vec<String> = Vec::new();
6691    for c in &result.content {
6692        if let TextContentOrImage::Text(t) = c {
6693            parts.push(t.text.clone());
6694        }
6695    }
6696    parts.join("\n")
6697}
6698
6699/// Empty progress callbacks are valid (notably before a tool's start event),
6700/// but they do not contain anything useful to render. Defer those callbacks so
6701/// the first tool panel is created from `ToolExecutionStart` with real args.
6702fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
6703    !text.trim().is_empty() || !result.details.is_null()
6704}
6705
6706// ===========================================================================
6707// Selectors — editor-container swap (TS showSelector pattern)
6708// ===========================================================================
6709
6710/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
6711/// hiding the editor while the selector is open. Records the selector in
6712/// `state.active_selector` so the key loop routes to it.
6713fn open_selector(
6714    state: &Arc<TuiState>,
6715    editor_container: &Arc<Container>,
6716    editor: &Arc<Editor>,
6717    tui: &Arc<TuiAltScreen>,
6718    list: Arc<SelectList>,
6719    kind: SelectorKind,
6720) {
6721    open_selector_with_view(
6722        state,
6723        editor_container,
6724        editor,
6725        tui,
6726        list.clone(),
6727        list,
6728        kind,
6729    );
6730}
6731
6732/// Open a selector with an optional framed view. Native extension selectors
6733/// wrap the list with a title and hint while built-in selectors keep the list
6734/// as the complete view.
6735fn open_selector_with_view<C: Component + 'static>(
6736    state: &Arc<TuiState>,
6737    editor_container: &Arc<Container>,
6738    editor: &Arc<Editor>,
6739    tui: &Arc<TuiAltScreen>,
6740    list: Arc<SelectList>,
6741    view: Arc<C>,
6742    kind: SelectorKind,
6743) {
6744    // Unfocus the editor so its cursor marker doesn't render behind the list.
6745    editor.set_focused(false);
6746    // Swap: clear the container and add the selector view.
6747    editor_container.clear();
6748    editor_container.add_child(view.clone());
6749    *state.active_selector.lock().unwrap() = Some((list, kind));
6750    let focused: Arc<dyn Component> = view;
6751    tui.set_focus(Some(focused));
6752    tui.request_render(false);
6753}
6754
6755/// Restore the editor into the `editor_container` and clear the active
6756/// selector. Called by selector `on_cancel` and the Esc handler.
6757fn close_selector(
6758    state: &Arc<TuiState>,
6759    editor_container: &Arc<Container>,
6760    editor: &Arc<Editor>,
6761    tui: &Arc<TuiAltScreen>,
6762) {
6763    editor_container.clear();
6764    editor_container.add_child(editor.clone());
6765    editor.set_focused(true);
6766    *state.active_selector.lock().unwrap() = None;
6767    *state.active_extension_cancel.lock().unwrap() = None;
6768    tui.set_focus(Some(editor.clone()));
6769    tui.request_render(false);
6770}
6771
6772/// Build + open the `/model` selector. Items are the resolved catalog (display
6773/// label = model name; description = id), with the current model marked.
6774/// Selecting applies the model **live** via `lane.set_model` (takes effect on
6775/// the next user message — the in-flight run's config is already snapshotted),
6776/// updates the footer, and notes the next-prompt effect.
6777fn open_model_selector(
6778    state: &Arc<TuiState>,
6779    editor_container: &Arc<Container>,
6780    editor: &Arc<Editor>,
6781    tui: &Arc<TuiAltScreen>,
6782    catalog: &[rpi_ai::Model],
6783    lane: &Arc<dyn AgentLane>,
6784    lane_model_id: &str,
6785    chat: &Arc<Container>,
6786) {
6787    let items = model_selector_items(catalog, lane_model_id);
6788    if items.is_empty() {
6789        add_note_message(
6790            chat,
6791            "No models in the catalog. Use --model at startup to select one.",
6792        );
6793        tui.request_render(false);
6794        return;
6795    }
6796    let list = Arc::new(SelectList::new(items, 10));
6797
6798    // Capture the catalog + lane so the on_select closure can resolve the
6799    // chosen Model and apply it. `on_select` fires on the blocking key thread,
6800    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
6801    let catalog_arc = catalog.to_vec();
6802    let state_sel = state.clone();
6803    let ec_sel = editor_container.clone();
6804    let editor_sel = editor.clone();
6805    let tui_sel = tui.clone();
6806    let chat_sel = chat.clone();
6807    let lane_sel = lane.clone();
6808    list.on_select(Arc::new(move |item| {
6809        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
6810            add_note_message(
6811                &chat_sel,
6812                &format!("Model {} not found in catalog.", item.label),
6813            );
6814            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6815            return;
6816        };
6817        state_sel.set_current_model(&model);
6818        let lane = lane_sel.clone();
6819        tokio::spawn(async move {
6820            let _ = lane.set_model(model).await;
6821        });
6822        add_note_message(
6823            &chat_sel,
6824            &format!(
6825                "Model set to {} — applies to the next message.",
6826                short_model_name(&item.value)
6827            ),
6828        );
6829        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6830    }));
6831    let state_cancel = state.clone();
6832    let ec_cancel = editor_container.clone();
6833    let editor_cancel = editor.clone();
6834    let tui_cancel = tui.clone();
6835    list.on_cancel(Arc::new(move || {
6836        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6837    }));
6838
6839    open_selector(
6840        state,
6841        editor_container,
6842        editor,
6843        tui,
6844        list,
6845        SelectorKind::Model,
6846    );
6847}
6848
6849/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
6850/// Returns `None` only when the catalog is empty or the current id isn't
6851/// found (in which case the first entry is returned — a no-op if it IS the
6852/// current). Used by the Ctrl+M model-cycle hotkey.
6853fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
6854    if catalog.is_empty() {
6855        return None;
6856    }
6857    let idx = catalog
6858        .iter()
6859        .position(|m| m.id.eq_ignore_ascii_case(current_id));
6860    match idx {
6861        Some(i) => {
6862            let next = (i + 1) % catalog.len();
6863            Some(catalog[next].clone())
6864        }
6865        None => Some(catalog[0].clone()),
6866    }
6867}
6868
6869/// Build + open the `/session` selector. Lists JSONL session files under the
6870/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
6871/// fallback). Selecting reports "restore not
6872/// implemented in v1" (existing constraint) but shows the list for
6873/// discoverability.
6874fn open_session_selector(
6875    state: &Arc<TuiState>,
6876    editor_container: &Arc<Container>,
6877    editor: &Arc<Editor>,
6878    tui: &Arc<TuiAltScreen>,
6879    cwd: &std::path::Path,
6880    tx: &mpsc::UnboundedSender<TuiMessage>,
6881) {
6882    let dir = crate::session::default_session_dir(cwd);
6883    let mut items: Vec<SelectItem> = Vec::new();
6884    if let Ok(entries) = std::fs::read_dir(&dir) {
6885        for entry in entries.flatten() {
6886            let path = entry.path();
6887            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
6888                continue;
6889            }
6890            let stem = path
6891                .file_stem()
6892                .and_then(|s| s.to_str())
6893                .unwrap_or("(unnamed)")
6894                .to_string();
6895            let display = path
6896                .file_name()
6897                .and_then(|s| s.to_str())
6898                .unwrap_or(&stem)
6899                .to_string();
6900            items.push(SelectItem::new(&stem, &display));
6901        }
6902    }
6903    if items.is_empty() {
6904        add_note_message(
6905            &state.chat_container,
6906            "No saved sessions found. Sessions are created automatically in interactive mode.",
6907        );
6908        tui.request_render(false);
6909        return;
6910    }
6911    let list = Arc::new(SelectList::new(items, 10));
6912
6913    let state_sel = state.clone();
6914    let ec_sel = editor_container.clone();
6915    let editor_sel = editor.clone();
6916    let tui_sel = tui.clone();
6917    let tx_sel = tx.clone();
6918    list.on_select(Arc::new(move |item| {
6919        // Close the selector first, then ask the async loop to hot-switch:
6920        // opening the session file + swapping the harness backing is async
6921        // (repo list/open) and must not run on the blocking key thread.
6922        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
6923        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
6924    }));
6925    let state_cancel = state.clone();
6926    let ec_cancel = editor_container.clone();
6927    let editor_cancel = editor.clone();
6928    let tui_cancel = tui.clone();
6929    list.on_cancel(Arc::new(move || {
6930        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
6931    }));
6932
6933    open_selector(
6934        state,
6935        editor_container,
6936        editor,
6937        tui,
6938        list,
6939        SelectorKind::Session,
6940    );
6941}
6942
6943fn custom_entry_display_text(
6944    custom_type: &str,
6945    data: Option<&serde_json::Value>,
6946) -> Option<String> {
6947    let data = data?;
6948    let text = data
6949        .get("summary")
6950        .or_else(|| data.get("text"))
6951        .or_else(|| data.get("output"))
6952        .and_then(|value| value.as_str())
6953        .filter(|value| !value.trim().is_empty())?;
6954    let label = match custom_type {
6955        "compactionSummary" => "Compaction summary",
6956        "branchSummary" => "Branch summary",
6957        "bashExecution" => "Command output",
6958        other => other,
6959    };
6960    Some(format!("{label}: {text}"))
6961}
6962
6963/// Open a selector for the current session's persisted entry tree. Selecting a
6964/// message moves the main lane leaf to that entry, then the caller reloads the
6965/// visible branch from durable storage.
6966async fn open_tree_selector(
6967    harness: &AgentHarness,
6968    state: &Arc<TuiState>,
6969    editor_container: &Arc<Container>,
6970    editor: &Arc<Editor>,
6971    tui: &Arc<TuiAltScreen>,
6972    chat: &Arc<Container>,
6973    tx: &mpsc::UnboundedSender<TuiMessage>,
6974) {
6975    let entries = match harness
6976        .session()
6977        .view("main")
6978        .find_entries(&EntryQuery {
6979            order: Some(EntryOrder::OldestFirst),
6980            ..Default::default()
6981        })
6982        .await
6983    {
6984        Ok(entries) => entries,
6985        Err(error) => {
6986            add_error_message(chat, &format!("Could not read session tree: {error}"));
6987            tui.request_render(false);
6988            return;
6989        }
6990    };
6991    let current = harness.session().get_leaf_id().await.ok().flatten();
6992    let items: Vec<SelectItem> = entries
6993        .iter()
6994        .map(|entry| {
6995            let marker = if current.as_deref() == Some(entry.id()) {
6996                " (current)"
6997            } else {
6998                ""
6999            };
7000            SelectItem::new(
7001                entry.id(),
7002                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
7003            )
7004            .with_description(&entry.id()[..entry.id().len().min(12)])
7005        })
7006        .collect();
7007    if items.is_empty() {
7008        add_note_message(chat, "The current session has no entries to navigate.");
7009        tui.request_render(false);
7010        return;
7011    }
7012    let list = Arc::new(SelectList::new(items, 12));
7013    let state_sel = state.clone();
7014    let ec_sel = editor_container.clone();
7015    let editor_sel = editor.clone();
7016    let tui_sel = tui.clone();
7017    let tx_sel = tx.clone();
7018    list.on_select(Arc::new(move |item| {
7019        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
7020        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7021    }));
7022    let state_cancel = state.clone();
7023    let ec_cancel = editor_container.clone();
7024    let editor_cancel = editor.clone();
7025    let tui_cancel = tui.clone();
7026    list.on_cancel(Arc::new(move || {
7027        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7028    }));
7029    open_selector(
7030        state,
7031        editor_container,
7032        editor,
7033        tui,
7034        list,
7035        SelectorKind::Tree,
7036    );
7037}
7038
7039/// Build + open the `/theme` selector. Built-in presets and enabled package
7040/// themes are shown; selecting applies the theme live and re-renders.
7041fn open_theme_selector(
7042    state: &Arc<TuiState>,
7043    editor_container: &Arc<Container>,
7044    editor: &Arc<Editor>,
7045    tui: &Arc<TuiAltScreen>,
7046    cwd: &std::path::Path,
7047    package_resources: &Arc<crate::packages::PackageResources>,
7048) {
7049    let mut items = vec![
7050        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
7051        SelectItem::new("light", "Light").with_description("Light background"),
7052        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
7053    ];
7054    if state.themes_enabled {
7055        for path in package_resources.theme_files() {
7056            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
7057                items.push(SelectItem::new(name, name).with_description("Package theme"));
7058            }
7059        }
7060    }
7061    let list = Arc::new(SelectList::new(items, 10));
7062
7063    let state_sel = state.clone();
7064    let ec_sel = editor_container.clone();
7065    let editor_sel = editor.clone();
7066    let tui_sel = tui.clone();
7067    let chat_sel = state.chat_container.clone();
7068    let cwd_sel = cwd.to_path_buf();
7069    let package_resources_sel = package_resources.clone();
7070    list.on_select(Arc::new(move |item| {
7071        let preset = match item.value.as_str() {
7072            "light" => Some(ThemePreset::Light),
7073            "monochrome" => Some(ThemePreset::Monochrome),
7074            "dark" => Some(ThemePreset::Dark),
7075            name => {
7076                if state_sel.themes_enabled {
7077                    if let Ok(custom) = crate::packages::load_theme_with_resources(
7078                        &cwd_sel,
7079                        name,
7080                        &package_resources_sel,
7081                    ) {
7082                        rpi_tui::global_theme_manager().set(custom.clone());
7083                        state_sel.theme_manager.set(custom);
7084                    }
7085                }
7086                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7087                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7088                tui_sel.render_now(true);
7089                return;
7090            }
7091        };
7092        let Some(preset) = preset else { return };
7093        apply_theme_preset(preset);
7094        state_sel.theme_manager.apply_preset(preset);
7095        // A quick accent note so the user sees the change registered even if
7096        // the terminal's own colors mask the preset difference.
7097        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
7098        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7099        tui_sel.render_now(true);
7100    }));
7101    let state_cancel = state.clone();
7102    let ec_cancel = editor_container.clone();
7103    let editor_cancel = editor.clone();
7104    let tui_cancel = tui.clone();
7105    list.on_cancel(Arc::new(move || {
7106        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7107    }));
7108
7109    open_selector(
7110        state,
7111        editor_container,
7112        editor,
7113        tui,
7114        list,
7115        SelectorKind::Theme,
7116    );
7117}
7118
7119// ===========================================================================
7120// Feasible selectors — /thinking, /tools, /images
7121// ===========================================================================
7122
7123/// One-line descriptions for each thinking level, ported from
7124/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
7125fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7126    use rpi_ai::types::ThinkingLevel::*;
7127    match level {
7128        Off => "Off — No reasoning",
7129        Minimal => "Minimal — Brief reasoning (~1k tokens)",
7130        Low => "Low — Light reasoning (~1k tokens)",
7131        Medium => "Medium — Moderate reasoning (~80% of max)",
7132        High => "High — Extensive reasoning (~95% of max)",
7133        Xhigh => "Xhigh — Near-maximal reasoning",
7134        Max => "Max — Maximum reasoning",
7135    }
7136}
7137
7138/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
7139/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
7140fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
7141    use rpi_ai::types::ThinkingLevel::*;
7142    match level {
7143        Off => "off",
7144        Minimal => "minimal",
7145        Low => "low",
7146        Medium => "medium",
7147        High => "high",
7148        Xhigh => "xhigh",
7149        Max => "max",
7150    }
7151}
7152
7153/// Parse a thinking-level name back to the enum (case-insensitive). Returns
7154/// `None` for an unknown name; used by the `/thinking` selector callback.
7155fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
7156    use rpi_ai::types::ThinkingLevel::*;
7157    match name.to_ascii_lowercase().as_str() {
7158        "off" => Some(Off),
7159        "minimal" => Some(Minimal),
7160        "low" => Some(Low),
7161        "medium" => Some(Medium),
7162        "high" => Some(High),
7163        "xhigh" => Some(Xhigh),
7164        "max" => Some(Max),
7165        _ => None,
7166    }
7167}
7168
7169/// Build + open the `/thinking` selector. Items are the levels the current
7170/// model supports (`Model::supported_thinking_levels`), each with a
7171/// description; the current level (read beforehand via `lane.get_thinking_level`)
7172/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
7173///
7174/// `on_select` fires on the blocking key thread, so it can't await
7175/// `lane.get_thinking_level()` to know the current level — the opener resolves
7176/// it first (best-effort) and preselects; the toggle on_select just applies
7177/// whatever was picked.
7178fn open_thinking_selector(
7179    state: &Arc<TuiState>,
7180    editor_container: &Arc<Container>,
7181    editor: &Arc<Editor>,
7182    tui: &Arc<TuiAltScreen>,
7183    lane: &Arc<dyn AgentLane>,
7184    catalog: &[rpi_ai::Model],
7185    lane_model_id: &str,
7186    chat: &Arc<Container>,
7187) {
7188    // Find the current model in the catalog to read its supported levels. If
7189    // absent, fall back to all levels so the selector still opens.
7190    let model = catalog
7191        .iter()
7192        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
7193    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
7194        .map(|m| m.supported_thinking_levels())
7195        .unwrap_or_else(|| {
7196            use rpi_ai::types::ThinkingLevel::*;
7197            vec![Off, Minimal, Low, Medium, High]
7198        });
7199    let mut items: Vec<SelectItem> = Vec::new();
7200    for lvl in &levels {
7201        let name = thinking_level_name(*lvl);
7202        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
7203    }
7204    if items.is_empty() {
7205        add_note_message(chat, "This model has no supported thinking levels.");
7206        tui.request_render(false);
7207        return;
7208    }
7209    let list = Arc::new(SelectList::new(items, 10));
7210
7211    let state_sel = state.clone();
7212    let ec_sel = editor_container.clone();
7213    let editor_sel = editor.clone();
7214    let tui_sel = tui.clone();
7215    let chat_sel = chat.clone();
7216    let lane_sel = lane.clone();
7217    list.on_select(Arc::new(move |item| {
7218        let Some(level) = thinking_level_from_name(&item.value) else {
7219            add_note_message(
7220                &chat_sel,
7221                &format!("Unknown thinking level: {}.", item.label),
7222            );
7223            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7224            return;
7225        };
7226        let lane = lane_sel.clone();
7227        let footer_sel = state_sel.footer.clone();
7228        tokio::spawn(async move {
7229            let _ = lane.set_thinking_level(level).await;
7230        });
7231        // Reflect the chosen level in the footer's model suffix (pi parity:
7232        // `model • thinking off` / `model • medium`). The shown text for the
7233        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
7234        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
7235        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
7236        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7237    }));
7238    let state_cancel = state.clone();
7239    let ec_cancel = editor_container.clone();
7240    let editor_cancel = editor.clone();
7241    let tui_cancel = tui.clone();
7242    list.on_cancel(Arc::new(move || {
7243        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7244    }));
7245
7246    open_selector(
7247        state,
7248        editor_container,
7249        editor,
7250        tui,
7251        list,
7252        SelectorKind::Thinking,
7253    );
7254}
7255
7256/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
7257/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
7258/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
7259/// — the blocking key thread can't await) and selecting a tool **toggles** it
7260/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
7261fn open_tools_selector(
7262    state: &Arc<TuiState>,
7263    editor_container: &Arc<Container>,
7264    editor: &Arc<Editor>,
7265    tui: &Arc<TuiAltScreen>,
7266    lane: &Arc<dyn AgentLane>,
7267    chat: &Arc<Container>,
7268) {
7269    // Best-effort read of the current active set. The opener runs on the async
7270    // runtime (it's called from the main loop's channel dispatch or the submit
7271    // closure that lives on the blocking thread — but `handle.block_on` is safe
7272    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
7273    let mut active = match tokio::runtime::Handle::try_current() {
7274        Ok(h) => h
7275            .block_on(async { lane.get_active_tools().await })
7276            .unwrap_or_default(),
7277        Err(_) => Vec::new(),
7278    };
7279    // An empty active set is the harness sentinel for "all registered tools"
7280    // (the selector only exposes built-ins). Expand it before rendering and
7281    // toggling so the first `/tools` visit does not show every tool as off or
7282    // accidentally reduce the active set to the one item selected.
7283    if active.is_empty() {
7284        active = crate::session::BUILTIN_TOOL_NAMES
7285            .iter()
7286            .map(|name| (*name).to_string())
7287            .collect();
7288    }
7289    let mut items: Vec<SelectItem> = Vec::new();
7290    for name in crate::session::BUILTIN_TOOL_NAMES {
7291        let on = active.iter().any(|a| a == name);
7292        let label = if on {
7293            format!("{name} (on)")
7294        } else {
7295            (*name).to_string()
7296        };
7297        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
7298    }
7299    let list = Arc::new(SelectList::new(items, 10));
7300
7301    // Capture the active set so on_select can toggle without re-reading.
7302    let active_captured = active.clone();
7303    let state_sel = state.clone();
7304    let ec_sel = editor_container.clone();
7305    let editor_sel = editor.clone();
7306    let tui_sel = tui.clone();
7307    let chat_sel = chat.clone();
7308    let lane_sel = lane.clone();
7309    list.on_select(Arc::new(move |item| {
7310        let mut next = active_captured.clone();
7311        if let Some(pos) = next.iter().position(|a| a == &item.value) {
7312            next.remove(pos);
7313        } else {
7314            next.push(item.value.clone());
7315        }
7316        let on = next.iter().any(|a| a == &item.value);
7317        let lane = lane_sel.clone();
7318        let next_clone = next.clone();
7319        tokio::spawn(async move {
7320            let _ = lane.set_active_tools(next_clone).await;
7321        });
7322        let list_str = if next.is_empty() {
7323            "(none)".to_string()
7324        } else {
7325            next.join(", ")
7326        };
7327        add_note_message(
7328            &chat_sel,
7329            &format!(
7330                "{} {} — active tools: {}",
7331                item.value,
7332                if on { "enabled" } else { "disabled" },
7333                list_str
7334            ),
7335        );
7336        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7337    }));
7338    let state_cancel = state.clone();
7339    let ec_cancel = editor_container.clone();
7340    let editor_cancel = editor.clone();
7341    let tui_cancel = tui.clone();
7342    list.on_cancel(Arc::new(move || {
7343        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7344    }));
7345
7346    open_selector(
7347        state,
7348        editor_container,
7349        editor,
7350        tui,
7351        list,
7352        SelectorKind::Tools,
7353    );
7354}
7355
7356/// Build + open the `/images` selector (Yes/No). Stores the choice in
7357/// `state.show_images` and notes it. Image wiring is minimal this pass — the
7358/// flag is consulted where images would be shown and echoed back here.
7359fn open_images_selector(
7360    state: &Arc<TuiState>,
7361    editor_container: &Arc<Container>,
7362    editor: &Arc<Editor>,
7363    tui: &Arc<TuiAltScreen>,
7364    chat: &Arc<Container>,
7365) {
7366    let current = *state.show_images.lock().unwrap();
7367    let items = vec![
7368        SelectItem::new("yes", "Yes").with_description(if current {
7369            "Inline images (current)"
7370        } else {
7371            "Inline images"
7372        }),
7373        SelectItem::new("no", "No").with_description(if current {
7374            "Placeholder only"
7375        } else {
7376            "Placeholder only (current)"
7377        }),
7378    ];
7379    let list = Arc::new(SelectList::new(items, 5));
7380
7381    let state_sel = state.clone();
7382    let ec_sel = editor_container.clone();
7383    let editor_sel = editor.clone();
7384    let tui_sel = tui.clone();
7385    let chat_sel = chat.clone();
7386    list.on_select(Arc::new(move |item| {
7387        let on = item.value == "yes";
7388        *state_sel.show_images.lock().unwrap() = on;
7389        add_note_message(
7390            &chat_sel,
7391            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
7392        );
7393        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7394    }));
7395    let state_cancel = state.clone();
7396    let ec_cancel = editor_container.clone();
7397    let editor_cancel = editor.clone();
7398    let tui_cancel = tui.clone();
7399    list.on_cancel(Arc::new(move || {
7400        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
7401    }));
7402
7403    open_selector(
7404        state,
7405        editor_container,
7406        editor,
7407        tui,
7408        list,
7409        SelectorKind::Images,
7410    );
7411}
7412
7413// ===========================================================================
7414// Autocomplete
7415// ===========================================================================
7416
7417/// Refresh the autocomplete suggestion list from the current editor text +
7418/// cursor. Renders the suggestions into `autocomplete_container` (above the
7419/// editor) or clears it when there are none.
7420fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
7421    let text = editor.get_text();
7422    let (_row, col) = editor.cursor_position();
7423    // The editor's `cursor_col` is a byte offset into the current line; for
7424    // single-line input (the common case) that equals the byte offset into
7425    // `get_text()`, which is exactly what the autocomplete providers expect to
7426    // slice on. Clamp to the text length so a stale/multi-line col can't
7427    // overshoot. Providers snap to a char boundary internally as a safety net
7428    // (`autocomplete::snap_cursor`), so a byte col landing mid-character never
7429    // panics.
7430    let cursor = col.min(text.len());
7431    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
7432    render_autocomplete(state, suggestions);
7433}
7434
7435/// Render (or clear) the autocomplete suggestion list into the container.
7436fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
7437    state.autocomplete_container.clear();
7438    let Some(sugg) = suggestions else {
7439        return;
7440    };
7441    if sugg.items.is_empty() {
7442        return;
7443    }
7444    // Build a compact list: top item marked with `→`, rest with `  `.
7445    // Cap the list so the dock doesn't swallow the transcript.
7446    let accent = state.theme_manager.get().colors.accent;
7447    let muted = state.theme_manager.get().colors.muted;
7448    for (i, item) in sugg
7449        .items
7450        .iter()
7451        .take(state.autocomplete_max_visible)
7452        .enumerate()
7453    {
7454        let prefix = if i == 0 { "→ " } else { "  " };
7455        let label = item.display_text();
7456        let line = if i == 0 {
7457            format!(
7458                "{prefix}{} {}",
7459                accent.fg(label),
7460                muted.fg(item.description.as_deref().unwrap_or(""))
7461            )
7462        } else {
7463            format!(
7464                "{prefix}{} {}",
7465                muted.fg(label),
7466                muted.fg(item.description.as_deref().unwrap_or(""))
7467            )
7468        };
7469        state
7470            .autocomplete_container
7471            .add_child(Arc::new(Text::new(line, 1, 0)));
7472    }
7473}
7474
7475/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
7476/// suggestion text, reposition the caret, and clear the suggestion list.
7477/// Returns `true` if a suggestion was accepted.
7478fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
7479    let text = editor.get_text();
7480    let (_row, col) = editor.cursor_position();
7481    let cursor = col.min(text.len());
7482    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
7483        return false;
7484    };
7485    let Some(top) = sugg.items.first() else {
7486        return false;
7487    };
7488    // Replace the [start, end) span with the suggestion text. `start`/`end`
7489    // are byte offsets emitted by the providers on char boundaries, so the
7490    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
7491    let start = sugg.start.min(text.len());
7492    let end = sugg.end.min(text.len());
7493    let mut replaced = String::with_capacity(text.len() + top.text.len());
7494    replaced.push_str(&text[..start]);
7495    replaced.push_str(&top.text);
7496    // Keep the text AFTER the replaced span (mid-line completion: replacing
7497    // `[start, end)` must not drop the rest of the line).
7498    replaced.push_str(&text[end..]);
7499    if top.insert_space && !replaced.ends_with('/') {
7500        replaced.push(' ');
7501    }
7502    // New caret position: after the inserted text (byte offset; the editor
7503    // snaps `set_cursor` to a char boundary as a safety net).
7504    let new_cursor = replaced.len().min(
7505        start
7506            + top.text.len()
7507            + if top.insert_space && !top.text.ends_with('/') {
7508                1
7509            } else {
7510                0
7511            },
7512    );
7513    editor.set_text(&replaced);
7514    editor.set_cursor(0, new_cursor);
7515    state.autocomplete_container.clear();
7516    true
7517}
7518
7519// ===========================================================================
7520// Transcript message helpers
7521// ===========================================================================
7522
7523/// Add the welcome header to the chat container.
7524fn add_welcome_message(container: &Arc<Container>) {
7525    add_welcome_message_with_capabilities(container, &[], &[]);
7526}
7527
7528/// Add the startup welcome header and a compact snapshot of active tools and
7529/// discovered skills. The snapshot reflects the harness configuration used by
7530/// the first turn, including tools contributed by extensions.
7531fn add_welcome_message_with_capabilities(
7532    container: &Arc<Container>,
7533    active_tools: &[String],
7534    skills: &[String],
7535) {
7536    let c = current_theme().colors;
7537    // Accent logotype + a dim tagline, separated from the rest by a thin
7538    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
7539    // to the body text, so the header didn't read as a header.
7540    let title = format!(
7541        "{} {}",
7542        c.accent.fg(&tui_bold("rpi")),
7543        c.muted.fg("interactive TUI")
7544    );
7545    container.add_child(Arc::new(Text::new(title, 1, 0)));
7546    container.add_child(Arc::new(Spacer::new(1)));
7547    container.add_child(Arc::new(Text::new(
7548        c.dim.fg("Type your message and press Enter to send."),
7549        1,
7550        0,
7551    )));
7552    let hint = c
7553        .dim
7554        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
7555    container.add_child(Arc::new(Text::new(hint, 1, 0)));
7556    container.add_child(Arc::new(Spacer::new(1)));
7557    container.add_child(Arc::new(Text::new(
7558        welcome_capability_line("Tools", active_tools),
7559        1,
7560        0,
7561    )));
7562    container.add_child(Arc::new(Text::new(
7563        welcome_capability_line("Skills", skills),
7564        1,
7565        0,
7566    )));
7567    container.add_child(Arc::new(DynamicBorder::new()));
7568}
7569
7570/// Append update notices inside the live transcript. Fullscreen mode clears
7571/// pre-TUI stdout/stderr, so update state must be represented by components.
7572fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
7573    let colors = current_theme().colors;
7574    let group = Arc::new(Container::new());
7575
7576    for warning in &report.warnings {
7577        let body = format!(
7578            "{}\n{} {}{}",
7579            colors.error.fg(&warning.message),
7580            colors.muted.fg("Run"),
7581            colors.accent.fg(&warning.command),
7582            colors.muted.fg(" to retry.")
7583        );
7584        add_update_panel(&group, "Update Failed", &body, colors.error);
7585    }
7586
7587    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
7588        let body = format!(
7589            "{} {}{}",
7590            colors
7591                .muted
7592                .fg(&format!("New version {} is available. Run", notice.latest)),
7593            colors.accent.fg(&notice.command),
7594            colors.muted.fg(".")
7595        );
7596        add_update_panel(&group, "Update Available", &body, colors.warning);
7597    }
7598
7599    let package_notices = report
7600        .notices
7601        .iter()
7602        .filter(|notice| notice.name != "rpi")
7603        .collect::<Vec<_>>();
7604    if !package_notices.is_empty() {
7605        let command = package_notices[0].command.as_str();
7606        let mut lines = vec![format!(
7607            "{} {}{}",
7608            colors.muted.fg("Package updates are available. Run"),
7609            colors.accent.fg(command),
7610            colors.muted.fg(".")
7611        )];
7612        lines.push(colors.muted.fg("Packages:"));
7613        lines.extend(
7614            package_notices
7615                .into_iter()
7616                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
7617        );
7618        add_update_panel(
7619            &group,
7620            "Package Updates Available",
7621            &lines.join("\n"),
7622            colors.warning,
7623        );
7624    }
7625
7626    // Other transcript producers append concurrently. Add the fully built
7627    // group in one operation so card borders and content cannot interleave
7628    // with user, assistant, tool, or extension messages.
7629    if group.child_count() > 0 {
7630        container.add_child(group);
7631    }
7632}
7633
7634fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
7635    container.add_child(Arc::new(Spacer::new(1)));
7636    container.add_child(Arc::new(DynamicBorder::with_color(color)));
7637    container.add_child(Arc::new(Text::new(
7638        format!("{}\n{body}", color.fg(&tui_bold(title))),
7639        1,
7640        0,
7641    )));
7642    container.add_child(Arc::new(DynamicBorder::with_color(color)));
7643}
7644
7645fn welcome_capability_line(label: &str, names: &[String]) -> String {
7646    let c = current_theme().colors;
7647    let value = if names.is_empty() {
7648        "none".to_string()
7649    } else {
7650        names.join(" · ")
7651    };
7652    format!(
7653        "{} {}",
7654        c.accent.fg(&format!("{label} ({})", names.len())),
7655        c.muted.fg(&value)
7656    )
7657}
7658
7659/// Add the `/help` command listing to the chat container.
7660fn add_help_message(container: &Arc<Container>) {
7661    let c = current_theme().colors;
7662    // Section header + a thin themed rule, then a two-column command table:
7663    // `cmd` in accent, `— desc` in muted. The old single-space layout made
7664    // the description column wander depending on command length.
7665    container.add_child(Arc::new(Text::new(
7666        c.md_heading.fg(&tui_bold("📚 Available Commands")),
7667        1,
7668        0,
7669    )));
7670    container.add_child(Arc::new(Spacer::new(1)));
7671
7672    let cmds: &[(&str, &str)] = &[
7673        ("/help, /?", "Show this help message"),
7674        ("/clear, /new", "Clear the conversation"),
7675        ("/exit, /quit, /q", "Exit the application"),
7676        ("/version, /v", "Show version information"),
7677        ("/changelog", "Show recent release changes"),
7678        ("/model, /m", "Choose a model (live switch)"),
7679        ("/thinking, /think", "Set reasoning depth (selector)"),
7680        ("/tools", "Toggle built-in tools on/off"),
7681        ("/images", "Toggle inline image rendering"),
7682        ("/session", "List saved sessions"),
7683        ("/theme", "Choose a theme (selector)"),
7684        ("/compact", "Compact the conversation"),
7685        ("/copy", "Copy last reply to clipboard"),
7686        ("/hotkeys", "Show keyboard shortcuts"),
7687        ("/armin", "🐾 Easter egg"),
7688        ("/earendil", "Earendil announcement"),
7689    ];
7690    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7691    for (cmd, desc) in cmds {
7692        let row = format!(
7693            "  {:<cmd_w$}  {}  {}",
7694            c.accent.fg(cmd),
7695            c.dim.fg("—"),
7696            c.muted.fg(desc)
7697        );
7698        container.add_child(Arc::new(Text::new(row, 1, 0)));
7699    }
7700    container.add_child(Arc::new(Spacer::new(1)));
7701}
7702
7703/// Add the `/version` block to the chat container.
7704fn add_version_message(container: &Arc<Container>) {
7705    let c = current_theme().colors;
7706    container.add_child(Arc::new(Text::new(
7707        c.md_heading.fg(&tui_bold("📦 Version Information")),
7708        1,
7709        0,
7710    )));
7711    container.add_child(Arc::new(Spacer::new(1)));
7712    // Use the crate version (kept in sync via `version.workspace = true`)
7713    // instead of the stale hardcoded "v0.1.2".
7714    container.add_child(Arc::new(Text::new(
7715        format!(
7716            "  {} {}",
7717            c.muted.fg("rpi-cli"),
7718            c.text.fg(&format!("v{}", crate::VERSION))
7719        ),
7720        1,
7721        0,
7722    )));
7723    container.add_child(Arc::new(Text::new(
7724        format!(
7725            "  {}",
7726            c.dim.fg("Rust implementation of pi coding agent TUI")
7727        ),
7728        1,
7729        0,
7730    )));
7731    container.add_child(Arc::new(Spacer::new(1)));
7732}
7733
7734/// Add a compact `/changelog` block to the chat container. Keep this local to
7735/// the binary so the command remains useful in installed builds without a
7736/// source checkout or a network request.
7737fn add_changelog_message(container: &Arc<Container>) {
7738    let c = current_theme().colors;
7739    container.add_child(Arc::new(Text::new(
7740        c.md_heading.fg(&tui_bold("Recent Changes")),
7741        1,
7742        0,
7743    )));
7744    container.add_child(Arc::new(Spacer::new(1)));
7745    let entries = [
7746        (
7747            "Native parity phase 1",
7748            "models, images, trust, export, and JSON events",
7749        ),
7750        (
7751            "TUI controls",
7752            "external editor, thinking levels, and tool output toggles",
7753        ),
7754        (
7755            "Provider auth",
7756            "OpenAI-compatible API key aliases and gateway headers",
7757        ),
7758    ];
7759    for (release, summary) in entries {
7760        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
7761        container.add_child(Arc::new(Text::new(row, 1, 0)));
7762    }
7763    container.add_child(Arc::new(Text::new(
7764        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
7765        1,
7766        0,
7767    )));
7768    container.add_child(Arc::new(Spacer::new(1)));
7769}
7770
7771/// Add the `/hotkeys` block to the chat container.
7772fn add_hotkeys_message(container: &Arc<Container>) {
7773    let c = current_theme().colors;
7774    container.add_child(Arc::new(Text::new(
7775        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
7776        1,
7777        0,
7778    )));
7779    container.add_child(Arc::new(Spacer::new(1)));
7780    let keys: &[(&str, &str)] = &[
7781        ("Enter", "Send message"),
7782        ("Shift+Enter", "New line"),
7783        ("Tab", "Accept autocomplete suggestion"),
7784        ("Ctrl+A / Ctrl+E", "Line start / end"),
7785        (
7786            "Ctrl+K / Ctrl+U",
7787            "Kill to end / start of line (Ctrl+Y yanks)",
7788        ),
7789        ("Ctrl+- / Ctrl+R", "Undo / redo"),
7790        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
7791        ("Alt+Backspace", "Kill previous word"),
7792        ("Ctrl+C", "Abort a run, or exit when idle"),
7793        ("Esc", "Abort a running prompt"),
7794        ("Ctrl+L", "Open model selector"),
7795        ("Ctrl+M", "Cycle to the next model (live)"),
7796        ("Ctrl+O", "Expand/collapse all tool output"),
7797        ("Ctrl+T", "Show/hide reasoning blocks"),
7798        ("PageUp/Down", "Scroll transcript by one page"),
7799        ("Home / End", "Jump to transcript start / latest output"),
7800    ];
7801    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
7802    for (key, desc) in keys {
7803        let row = format!(
7804            "  {:<key_w$}  {}  {}",
7805            c.accent.fg(key),
7806            c.dim.fg("—"),
7807            c.muted.fg(desc)
7808        );
7809        container.add_child(Arc::new(Text::new(row, 1, 0)));
7810    }
7811    container.add_child(Arc::new(Spacer::new(1)));
7812}
7813
7814/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
7815/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
7816/// plain `> text` echo. A trailing Spacer(1) separates it from the next
7817// transcript entry (every entry contributes one trailing spacer so
7818// consecutive turns are separated by exactly one blank line).
7819fn add_user_message(container: &Arc<Container>, text: &str) {
7820    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
7821    container.add_child(Arc::new(Spacer::new(1)));
7822}
7823
7824/// Add an error message to the chat container.
7825fn add_error_message(container: &Arc<Container>, text: &str) {
7826    let c = current_theme().colors;
7827    container.add_child(Arc::new(Text::new(
7828        format!("  {} {}", c.error.fg("✗"), c.error.fg(text)),
7829        1,
7830        0,
7831    )));
7832    container.add_child(Arc::new(Spacer::new(1)));
7833}
7834
7835/// Add a neutral note (e.g. unsupported-command message) to the chat container.
7836fn add_note_message(container: &Arc<Container>, text: &str) {
7837    let c = current_theme().colors;
7838    container.add_child(Arc::new(Text::new(
7839        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
7840        1,
7841        0,
7842    )));
7843    container.add_child(Arc::new(Spacer::new(1)));
7844}
7845
7846/// Render the `/context` panel: a transcript message listing the discovered
7847/// context files, skills, and prompt templates loaded for this session
7848/// (Part A resource discovery). Reads the harness resources snapshot captured
7849/// at TUI startup (the blocking submit handler can't `await get_resources()`.
7850///
7851/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
7852/// via `/reload`); here it's a transcript note rather than an overlay since the
7853/// resource set is session-static between `/reload`s (deferred).
7854fn show_context_panel(
7855    chat: &Arc<Container>,
7856    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
7857) {
7858    let skills = resources.skills.as_deref().unwrap_or(&[]);
7859    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
7860    let mut lines: Vec<String> = Vec::new();
7861    lines.push("📂 Discovered resources for this session:".into());
7862
7863    if skills.is_empty() {
7864        lines.push(
7865            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
7866        );
7867    } else {
7868        lines.push(format!("  Skills ({}):", skills.len()));
7869        for s in skills {
7870            let marker = if s.disable_model_invocation == Some(true) {
7871                " [hidden]"
7872            } else {
7873                ""
7874            };
7875            let desc: String = s.description.chars().take(72).collect();
7876            lines.push(format!("    • {}{marker} — {desc}", s.name));
7877        }
7878    }
7879
7880    if templates.is_empty() {
7881        lines.push(
7882            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
7883        );
7884    } else {
7885        lines.push(format!("  Prompt templates ({}):", templates.len()));
7886        for t in templates {
7887            let desc = t
7888                .description
7889                .as_deref()
7890                .unwrap_or("(no description)")
7891                .chars()
7892                .take(72)
7893                .collect::<String>();
7894            lines.push(format!("    • /{} — {desc}", t.name));
7895        }
7896    }
7897    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
7898    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
7899    lines.push(
7900        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
7901            .into(),
7902    );
7903    let body = lines.join("\n");
7904    container_note_block(chat, &body);
7905}
7906
7907/// Append a multi-line neutral note (header line + body) to the chat container.
7908fn container_note_block(container: &Arc<Container>, body: &str) {
7909    for line in body.lines() {
7910        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
7911    }
7912    container.add_child(Arc::new(Spacer::new(1)));
7913}
7914
7915// ===========================================================================
7916// TUI support + entry detection
7917// ===========================================================================
7918
7919/// Check if the terminal supports TUI mode.
7920pub fn is_tui_supported() -> bool {
7921    std::io::stdout().is_terminal()
7922}
7923
7924// Keep the `Color` import used (theme accent rendering in autocomplete).
7925#[allow(unused_imports)]
7926use rpi_tui::Color as _Color;
7927
7928#[cfg(test)]
7929mod tests {
7930    use super::*;
7931    use rpi_tui::Component;
7932
7933    #[test]
7934    fn verbose_overrides_quiet_startup_listing() {
7935        assert!(should_show_startup_listing(false, false));
7936        assert!(should_show_startup_listing(true, false));
7937        assert!(should_show_startup_listing(true, true));
7938        assert!(!should_show_startup_listing(false, true));
7939    }
7940
7941    #[test]
7942    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
7943        let tmp = tempfile::tempdir().unwrap();
7944        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
7945        let local_root = tmp.path().join("local-package");
7946        std::fs::create_dir_all(git_root.join(".git")).unwrap();
7947        std::fs::create_dir_all(&local_root).unwrap();
7948        std::fs::write(
7949            git_root.join("package.json"),
7950            r#"{"name":"git-demo","version":"1.0.0"}"#,
7951        )
7952        .unwrap();
7953        std::fs::write(
7954            local_root.join("package.json"),
7955            r#"{"name":"local-demo","version":"1.0.0"}"#,
7956        )
7957        .unwrap();
7958        let resources = crate::packages::discover(
7959            tmp.path(),
7960            &[
7961                "git:github.com/example/repo".to_string(),
7962                local_root.to_string_lossy().into_owned(),
7963            ],
7964        );
7965        assert_eq!(resources.packages.len(), 2);
7966
7967        let filtered = git_only_update_resources(resources);
7968
7969        assert_eq!(filtered.packages.len(), 1);
7970        assert!(matches!(
7971            filtered.packages[0].source,
7972            crate::packages::PackageSource::Git
7973        ));
7974    }
7975
7976    #[test]
7977    fn trust_command_uses_the_session_cwd_after_process_chdir() {
7978        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
7979        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
7980        const TEST_NAME: &str =
7981            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
7982
7983        if std::env::var_os(CHILD_ENV).is_some() {
7984            let session_cwd = std::path::PathBuf::from(
7985                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
7986            );
7987            let changed_cwd = std::env::current_dir().unwrap();
7988
7989            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
7990
7991            assert_eq!(
7992                crate::config::project_trust_decision(&session_cwd).unwrap(),
7993                Some(true)
7994            );
7995            assert_eq!(
7996                crate::config::project_trust_decision(&changed_cwd).unwrap(),
7997                None
7998            );
7999            return;
8000        }
8001
8002        let tmp = tempfile::tempdir().unwrap();
8003        let session_cwd = tmp.path().join("session-project");
8004        let changed_cwd = tmp.path().join("extension-cwd");
8005        let agent_dir = tmp.path().join("agent");
8006        std::fs::create_dir_all(&session_cwd).unwrap();
8007        std::fs::create_dir_all(&changed_cwd).unwrap();
8008        std::fs::create_dir_all(&agent_dir).unwrap();
8009
8010        let status = std::process::Command::new(std::env::current_exe().unwrap())
8011            .arg("--exact")
8012            .arg(TEST_NAME)
8013            .arg("--nocapture")
8014            .env(CHILD_ENV, "1")
8015            .env(SESSION_CWD_ENV, &session_cwd)
8016            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
8017            .current_dir(&changed_cwd)
8018            .status()
8019            .unwrap();
8020
8021        assert!(status.success(), "child test process failed: {status}");
8022    }
8023
8024    #[test]
8025    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
8026        let global = crate::settings::Settings {
8027            editor_padding_x: Some(3),
8028            autocomplete_max_visible: Some(6),
8029            hide_thinking_block: Some(false),
8030            quiet_startup: Some(true),
8031            show_terminal_progress: Some(true),
8032            ..Default::default()
8033        };
8034        let rpi_project = crate::settings::Settings {
8035            editor_padding_x: Some(0),
8036            hide_thinking_block: Some(true),
8037            quiet_startup: Some(false),
8038            show_terminal_progress: Some(false),
8039            ..Default::default()
8040        };
8041        let pi_project = crate::settings::Settings {
8042            editor_padding_x: Some(9),
8043            autocomplete_max_visible: Some(12),
8044            quiet_startup: Some(true),
8045            ..Default::default()
8046        };
8047
8048        assert_eq!(
8049            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
8050            TuiStartupSettings {
8051                editor_padding_x: 0,
8052                autocomplete_max_visible: 12,
8053                hide_thinking: true,
8054                quiet_startup: false,
8055                show_terminal_progress: false,
8056            }
8057        );
8058    }
8059
8060    #[test]
8061    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
8062        let global = crate::settings::Settings {
8063            quiet_startup: Some(false),
8064            ..Default::default()
8065        };
8066        let rpi_project = crate::settings::Settings::default();
8067        let pi_project = crate::settings::Settings {
8068            quiet_startup: Some(true),
8069            ..Default::default()
8070        };
8071
8072        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
8073
8074        assert!(resolved.quiet_startup);
8075    }
8076
8077    #[test]
8078    fn tui_startup_settings_use_global_values_without_project_fields() {
8079        let global = crate::settings::Settings {
8080            editor_padding_x: Some(7),
8081            autocomplete_max_visible: Some(8),
8082            hide_thinking_block: Some(true),
8083            quiet_startup: Some(true),
8084            show_terminal_progress: Some(false),
8085            ..Default::default()
8086        };
8087
8088        assert_eq!(
8089            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
8090            TuiStartupSettings {
8091                editor_padding_x: 7,
8092                autocomplete_max_visible: 8,
8093                hide_thinking: true,
8094                quiet_startup: true,
8095                show_terminal_progress: false,
8096            }
8097        );
8098    }
8099
8100    #[test]
8101    fn tui_startup_settings_ignore_untrusted_project_values() {
8102        let global = crate::settings::Settings {
8103            quiet_startup: Some(false),
8104            show_terminal_progress: Some(true),
8105            ..Default::default()
8106        };
8107        let project = crate::settings::Settings {
8108            quiet_startup: Some(true),
8109            show_terminal_progress: Some(false),
8110            ..Default::default()
8111        };
8112
8113        let resolved = resolve_tui_startup_settings(&global, &[project], false);
8114
8115        assert!(!resolved.quiet_startup);
8116        assert!(resolved.show_terminal_progress);
8117    }
8118
8119    #[test]
8120    fn transcript_page_uses_viewport_with_overlap() {
8121        assert_eq!(transcript_page_size(24), 20);
8122        assert_eq!(transcript_page_size(4), 1);
8123        assert_eq!(transcript_page_size(0), 1);
8124    }
8125
8126    #[test]
8127    fn key_repeat_is_dispatched_but_release_is_not() {
8128        assert!(should_dispatch_key(KeyEventKind::Press));
8129        assert!(should_dispatch_key(KeyEventKind::Repeat));
8130        assert!(!should_dispatch_key(KeyEventKind::Release));
8131    }
8132
8133    #[test]
8134    fn key_event_encoding_matches_pi_keybinding_protocol() {
8135        let key = |code, modifiers| KeyEvent::new(code, modifiers);
8136        assert_eq!(
8137            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
8138            "\r"
8139        );
8140        assert_eq!(
8141            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
8142            "\x1b[13;2u"
8143        );
8144        assert_eq!(
8145            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
8146            "\x1b[9;2u"
8147        );
8148        assert_eq!(
8149            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
8150            "\x1b[Z"
8151        );
8152        assert_eq!(
8153            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
8154            "\x1b[Z"
8155        );
8156        assert_eq!(
8157            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
8158            "\x03"
8159        );
8160        assert_eq!(
8161            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
8162            "\x0f"
8163        );
8164        assert_eq!(
8165            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
8166            "!"
8167        );
8168        assert_eq!(
8169            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
8170            "1"
8171        );
8172    }
8173
8174    #[test]
8175    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
8176        let bridge = JsDialogBridge::default();
8177        let (sender, receiver) = std_mpsc::channel();
8178        bridge.pending.lock().unwrap().push_back(JsDialogPending {
8179            request: JsDialogRequest {
8180                id: "dialog-1".into(),
8181                method: "input".into(),
8182                title: String::new(),
8183                message: String::new(),
8184                options: Vec::new(),
8185                placeholder: None,
8186                prefill: None,
8187            },
8188            result: sender,
8189        });
8190
8191        // Model the cancellation arriving after the queue entry has been
8192        // removed but before the TUI has installed the native widget.
8193        bridge.cancel("dialog-1");
8194        assert!(bridge.take_pending().is_none());
8195        assert_eq!(
8196            receiver.recv().unwrap(),
8197            serde_json::json!({ "cancelled": true })
8198        );
8199        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
8200    }
8201
8202    #[test]
8203    fn test_layout_renders_welcome_message() {
8204        let chat = Arc::new(Container::new());
8205        add_welcome_message(&chat);
8206
8207        let scroll = Arc::new(ScrollView::new(
8208            chat.clone(),
8209            ScrollViewOptions {
8210                follow: FollowMode::End,
8211                primary: true,
8212                ..Default::default()
8213            },
8214        ));
8215
8216        let editor = Arc::new(Editor::new(
8217            EditorOptions {
8218                padding_x: 1,
8219                ..Default::default()
8220            },
8221            EditorStyle::default(),
8222            Arc::new(rpi_tui::Keybindings::new()),
8223        ));
8224        let dock = Arc::new(Container::new());
8225        dock.add_child(editor);
8226
8227        let footer = Arc::new(FooterComponent::new());
8228
8229        let root = VStack::from_children(vec![
8230            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
8231            StackChild::Entry(StackEntry::new(dock)),
8232            StackChild::Entry(StackEntry::new(footer)),
8233        ]);
8234
8235        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
8236
8237        let all: String = frame.lines.join("\n");
8238        assert!(
8239            all.contains("rpi"),
8240            "Welcome message not found. Rendered: {}",
8241            all
8242        );
8243        assert!(
8244            all.contains("Type your message"),
8245            "Help text not found. Rendered: {}",
8246            all
8247        );
8248    }
8249
8250    #[test]
8251    fn test_chat_container_has_welcome_content() {
8252        let chat = Arc::new(Container::new());
8253        add_welcome_message_with_capabilities(
8254            &chat,
8255            &["read".into(), "bash".into(), "web_fetch".into()],
8256            &["rust-review".into(), "release".into()],
8257        );
8258
8259        let lines = chat.render(80);
8260        let all: String = lines.join("\n");
8261        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
8262        // joined by an ANSI reset — strip ANSI before checking the substring.
8263        let plain = strip_ansi(&all);
8264        assert!(
8265            plain.contains("rpi"),
8266            "Welcome message not in chat container: {:?}",
8267            lines
8268        );
8269        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
8270        assert!(
8271            plain.contains("read · bash · web_fetch"),
8272            "Tool names missing: {plain}"
8273        );
8274        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
8275        assert!(
8276            plain.contains("rust-review · release"),
8277            "Skill names missing: {plain}"
8278        );
8279    }
8280
8281    #[test]
8282    fn update_notices_render_inside_the_transcript() {
8283        let chat = Arc::new(Container::new());
8284        let report = crate::updates::UpdateReport {
8285            notices: vec![
8286                crate::updates::UpdateNotice {
8287                    name: "rpi".into(),
8288                    current: "0.1.10".into(),
8289                    latest: "0.1.11".into(),
8290                    command: "rpi self-update".into(),
8291                },
8292                crate::updates::UpdateNotice {
8293                    name: "rpi-search".into(),
8294                    current: "0.1.0".into(),
8295                    latest: "0.1.1".into(),
8296                    command: "rpi pi-update".into(),
8297                },
8298            ],
8299            warnings: vec![crate::updates::UpdateWarning {
8300                message: "The previously scheduled rpi self-update failed: access denied".into(),
8301                command: "rpi self-update".into(),
8302            }],
8303        };
8304
8305        add_update_notices(&chat, &report);
8306
8307        assert_eq!(chat.child_count(), 1);
8308        let plain = strip_ansi(&chat.render(80).join("\n"));
8309        assert!(plain.contains("Update Failed"), "{plain}");
8310        assert!(
8311            plain.contains("self-update failed: access denied"),
8312            "{plain}"
8313        );
8314        assert!(plain.contains("Update Available"), "{plain}");
8315        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
8316        assert!(plain.contains("rpi self-update"), "{plain}");
8317        assert!(plain.contains("Package Updates Available"), "{plain}");
8318        assert!(plain.contains("rpi pi-update"), "{plain}");
8319        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
8320    }
8321
8322    #[test]
8323    fn empty_update_report_does_not_add_transcript_content() {
8324        let chat = Arc::new(Container::new());
8325
8326        add_update_notices(&chat, &crate::updates::UpdateReport::default());
8327
8328        assert_eq!(chat.child_count(), 0);
8329    }
8330
8331    #[test]
8332    fn skill_reads_are_detected_by_path() {
8333        let name = skill_tool_name(
8334            "read",
8335            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
8336        );
8337        assert_eq!(name.as_deref(), Some("release"));
8338
8339        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
8340        assert!(name.is_none());
8341
8342        // Only `read` (not other tools) triggers the skill box.
8343        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
8344    }
8345
8346    #[test]
8347    fn welcome_capabilities_show_empty_state() {
8348        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
8349        assert_eq!(plain, "Skills (0) none");
8350    }
8351
8352    #[test]
8353    fn empty_tool_progress_is_deferred_until_start() {
8354        let empty = rpi_agent::AgentToolResult::default();
8355        assert!(!tool_update_has_payload("", &empty));
8356
8357        let text = rpi_agent::AgentToolResult::text("partial output");
8358        assert!(tool_update_has_payload("partial output", &text));
8359
8360        let details = rpi_agent::AgentToolResult {
8361            details: serde_json::json!({"path": "src/lib.rs"}),
8362            ..Default::default()
8363        };
8364        assert!(tool_update_has_payload("", &details));
8365    }
8366
8367    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
8368    /// replaces the editor text, the NEXT rendered frame must show the
8369    /// completed text (" /model " with the caret after it), not the old
8370    /// prefix. Mirrors the real dock layout (autocomplete_container above the
8371    /// bordered editor) and drives the same accept path the Tab handler uses.
8372    #[test]
8373    fn tab_accept_suggestion_reflects_in_next_render() {
8374        use rpi_tui::render_layout_frame;
8375
8376        let editor = Arc::new(Editor::new(
8377            EditorOptions {
8378                padding_x: 1,
8379                ..Default::default()
8380            },
8381            EditorStyle::default(),
8382            Arc::new(rpi_tui::Keybindings::new()),
8383        ));
8384        editor.set_focused(true);
8385        let editor_container = Arc::new(Container::new());
8386        editor_container.add_child(editor.clone());
8387        let autocomplete_container = Arc::new(Container::new());
8388        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
8389        let dock = Arc::new(VStack::from_children(vec![
8390            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
8391            StackChild::Entry(
8392                StackEntry::new(editor_container.clone())
8393                    .shrink(0)
8394                    .min_size(3),
8395            ),
8396            StackChild::Entry(StackEntry::new(footer)),
8397        ]));
8398
8399        // Simulate the user typing "/mo" (the popup shows suggestions).
8400        let mut manager = AutocompleteManager::new();
8401        let mut combined = CombinedAutocompleteProvider::new();
8402        combined.add_provider(Arc::new(
8403            SlashCommandAutocompleteProvider::with_default_commands(),
8404        ));
8405        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
8406        manager.set_provider(Arc::new(combined));
8407        // Simulate typing "/mo" via the real insert path (advances the caret
8408        // by char length, like `handle_key` does).
8409        editor.insert("/mo");
8410        assert_eq!(editor.cursor_position(), (0, 3));
8411
8412        let frame_before = render_layout_frame(dock.clone(), 80, 10);
8413        assert!(
8414            frame_before.lines.iter().any(|l| l.contains("/mo")),
8415            "precondition: editor shows the typed prefix. Frame rows:\n{}",
8416            frame_before
8417                .lines
8418                .iter()
8419                .map(|l| format!("  [{l}]"))
8420                .collect::<Vec<_>>()
8421                .join("\n")
8422        );
8423
8424        // Tab: accept the top suggestion (the same code path as the key loop).
8425        let text = editor.get_text();
8426        let (_row, col) = editor.cursor_position();
8427        let cursor = col.min(text.len());
8428        let sugg = manager
8429            .get_suggestions(&text, cursor)
8430            .expect("slash suggestions for /mo");
8431        let top = sugg.items.first().expect("at least one suggestion");
8432        let start = sugg.start.min(text.len());
8433        let end = sugg.end.min(text.len());
8434        let mut replaced = String::new();
8435        replaced.push_str(&text[..start]);
8436        replaced.push_str(&top.text);
8437        replaced.push_str(&text[end..]);
8438        if top.insert_space && !replaced.ends_with('/') {
8439            replaced.push(' ');
8440        }
8441        editor.set_text(&replaced);
8442        editor.set_cursor(0, replaced.len().min(start + top.text.len()));
8443        autocomplete_container.clear();
8444        assert_eq!(editor.get_text(), "/model");
8445
8446        // The next render MUST display the completed text.
8447        let frame_after = render_layout_frame(dock, 80, 10);
8448        let all: String = frame_after.lines.join("\n");
8449        assert!(
8450            all.contains("/model"),
8451            "completed text missing from next render. Got:\n{all}"
8452        );
8453        // The caret must sit AFTER the completed command (the snap_boundary
8454        // regression put it one char early: "/mode|l" with the final char
8455        // dangling past the caret).
8456        let editor_line = frame_after
8457            .lines
8458            .iter()
8459            .find(|l| l.contains("/model"))
8460            .expect("editor row with completed text");
8461        assert!(
8462            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
8463            "caret must follow the full completed text. Got: {editor_line:?}"
8464        );
8465    }
8466
8467    #[test]
8468    fn test_slash_command_dispatch() {
8469        // The registry is the single source of truth for dispatch: `find(token)`
8470        // returns the command (by name or alias) whose `name()` is the canonical
8471        // form, or `None` for an unknown token. This replaces the old enum-based
8472        // `handle_slash_command` assertions with equivalent registry lookups.
8473        let registry = build_builtin_registry();
8474
8475        // Helper: a token resolves to the command with this canonical name.
8476        let resolves_to = |token: &str, canonical: &str| {
8477            let found = registry.find(token).expect("{token} should resolve");
8478            assert_eq!(
8479                found.name(),
8480                canonical,
8481                "{token} resolved to {} (expected {canonical})",
8482                found.name()
8483            );
8484        };
8485
8486        resolves_to("/help", "/help");
8487        resolves_to("/?", "/help"); // alias → canonical
8488        resolves_to("/clear", "/clear");
8489        resolves_to("/new", "/clear"); // alias
8490        resolves_to("/q", "/exit"); // alias
8491        resolves_to("/quit", "/exit"); // alias
8492        resolves_to("/version", "/version");
8493        resolves_to("/v", "/version"); // alias
8494        resolves_to("/changelog", "/changelog");
8495        resolves_to("/hotkeys", "/hotkeys");
8496        resolves_to("/model", "/model");
8497        resolves_to("/m", "/model"); // alias
8498        resolves_to("/theme", "/theme");
8499        resolves_to("/session", "/session");
8500        resolves_to("/resume", "/session"); // alias
8501        resolves_to("/compact", "/compact");
8502        resolves_to("/copy", "/copy");
8503        resolves_to("/thinking", "/thinking");
8504        resolves_to("/think", "/thinking"); // alias
8505        resolves_to("/tools", "/tools");
8506        resolves_to("/images", "/images");
8507        resolves_to("/armin", "/armin");
8508        resolves_to("/earendil", "/earendil");
8509        resolves_to("/context", "/context");
8510        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
8511        resolves_to("/settings", "/settings");
8512        resolves_to("/name", "/name");
8513        resolves_to("/export", "/export");
8514
8515        // Unknown token → not found.
8516        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
8517    }
8518
8519    #[test]
8520
8521    fn test_registry_visible_entries_cover_dispatch() {
8522        // The autocomplete list is derived from the registry, so every visible
8523        // command the dispatcher recognizes must appear in it — by construction,
8524        // but this guards against a future command being registered with
8525        // `visible()` / a non-empty description that the builder drops.
8526        let registry = build_builtin_registry();
8527        let names: Vec<String> = registry
8528            .visible_entries()
8529            .iter()
8530            .map(|c| c.name.clone())
8531            .collect();
8532        for recognized in [
8533            "/help",
8534            "/clear",
8535            "/new",
8536            "/exit",
8537            "/quit",
8538            "/version",
8539            "/changelog",
8540            "/model",
8541            "/session",
8542            "/theme",
8543            "/compact",
8544            "/copy",
8545            "/hotkeys",
8546            "/tools",
8547            "/images",
8548            "/thinking",
8549            "/armin",
8550            "/earendil",
8551        ] {
8552            assert!(
8553                names.contains(&recognized.to_string()),
8554                "{recognized} missing from autocomplete list"
8555            );
8556        }
8557        // Hidden commands stay off the list.
8558        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
8559            assert!(
8560                !names.contains(&hidden.to_string()),
8561                "{hidden} should be hidden from autocomplete"
8562            );
8563        }
8564    }
8565
8566    #[test]
8567    fn test_agent_event_mapping_creates_assistant_and_tool() {
8568        // Synthetic AgentEvent sequence → UI mutations, exercised against the
8569        // real drain handler with a no-op TUI stand-in.
8570        use rpi_ai::types::{
8571            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
8572            ToolCall, ToolCallType, Usage,
8573        };
8574
8575        let state = Arc::new(TuiState {
8576            current_assistant: std::sync::Mutex::new(None),
8577            tool_components: std::sync::Mutex::new(HashMap::new()),
8578            bash_components: std::sync::Mutex::new(HashMap::new()),
8579            themes_enabled: true,
8580            hide_thinking: std::sync::Mutex::new(false),
8581            tool_outputs_expanded: std::sync::Mutex::new(false),
8582            show_terminal_progress: true,
8583            status: std::sync::Mutex::new(RunStatus::Idle),
8584            js_preparation_cancel: std::sync::Mutex::new(None),
8585            footer: Arc::new(FooterComponent::new()),
8586            status_container: Arc::new(Container::new()),
8587            chat_container: Arc::new(Container::new()),
8588            loader: Arc::new(Loader::new()),
8589            last_assistant_text: std::sync::Mutex::new(String::new()),
8590            active_selector: std::sync::Mutex::new(None),
8591            active_extension_editor: std::sync::Mutex::new(None),
8592            active_extension_input: std::sync::Mutex::new(None),
8593            active_extension_cancel: std::sync::Mutex::new(None),
8594            autocomplete: AutocompleteManager::new(),
8595            autocomplete_container: Arc::new(Container::new()),
8596            autocomplete_max_visible: 5,
8597            pending_images: std::sync::Mutex::new(Vec::new()),
8598            theme_manager: Arc::new(ThemeManager::new()),
8599            tui: None,
8600            current_model_id: std::sync::Mutex::new(String::new()),
8601            show_images: std::sync::Mutex::new(true),
8602            history: std::sync::Mutex::new(Vec::new()),
8603            history_index: std::sync::Mutex::new(-1),
8604            history_draft: std::sync::Mutex::new(None),
8605            last_input_tokens: std::sync::Mutex::new(0),
8606            scoped_edit: std::sync::Mutex::new(None),
8607            markdown_transformer: std::sync::Mutex::new(None),
8608            extension_session: Arc::new(std::sync::Mutex::new(
8609                rpi_extensions::ExtensionSession::none(),
8610            )),
8611        });
8612
8613        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
8614        // terminal; instead, exercise the *mutation* half directly against a
8615        // captured chat container via a synthetic message-start event's data.
8616        let assistant = AssistantMessage {
8617            role: rpi_ai::types::AssistantRole,
8618            content: vec![
8619                Content::Thinking(ThinkingContent {
8620                    kind: ThinkingContentType,
8621                    thinking: "Reasoning about the reply.".into(),
8622                    thinking_signature: None,
8623                    redacted: false,
8624                }),
8625                Content::Text(TextContent {
8626                    kind: TextContentType,
8627                    text: "Hello.".into(),
8628                    text_signature: None,
8629                }),
8630                Content::ToolCall(ToolCall {
8631                    kind: ToolCallType,
8632                    id: "tc1".into(),
8633                    name: "bash".into(),
8634                    arguments: serde_json::json!({"command": "echo hi"}),
8635                    thought_signature: None,
8636                    namespace: None,
8637                }),
8638            ],
8639            api: rpi_ai::Api::AnthropicMessages,
8640            provider: "anthropic".into(),
8641            model: "claude-sonnet-5".into(),
8642            response_model: None,
8643            response_id: None,
8644            usage: Usage::zero(),
8645            stop_reason: StopReason::Stop,
8646            deferred: None,
8647            error_message: None,
8648            raw_stop_reason: None,
8649            end_turn: None,
8650            timestamp: 0,
8651        };
8652
8653        // Manually apply the MessageStart assistant branch logic (mirrors the
8654        // drain handler, without needing a TuiAltScreen).
8655        let comp = Arc::new(AssistantMessageComponent::new(
8656            AssistantMessageOptions::default(),
8657        ));
8658        comp.set_streaming(true);
8659        comp.update_blocks(&assistant_blocks(&assistant));
8660        let chat = Arc::new(Container::new());
8661        chat.add_child(comp.clone());
8662        *state.current_assistant.lock().unwrap() = Some(comp);
8663
8664        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
8665        for c in &assistant.content {
8666            if let Content::ToolCall(tc) = c {
8667                let mut tools = state.tool_components.lock().unwrap();
8668                if !tools.contains_key(&tc.id) {
8669                    let tc_comp = Arc::new(ToolExecutionComponent::new(
8670                        &tc.name,
8671                        &tc.arguments.to_string(),
8672                    ));
8673                    tc_comp.set_running();
8674                    chat.add_child(tc_comp.clone());
8675                    tools.insert(tc.id.clone(), tc_comp);
8676                }
8677            }
8678        }
8679
8680        // Assert: the assistant component rendered the text + the thinking
8681        // block (the update_blocks path keeps thinking visible), and a tool
8682        // component was registered.
8683        let rendered = chat.render(80);
8684        let joined: String = rendered.join("\n");
8685        assert!(
8686            joined.contains("Hello."),
8687            "assistant text not rendered: {joined}"
8688        );
8689        assert!(
8690            joined.contains("Reasoning about the reply."),
8691            "thinking block not rendered: {joined}"
8692        );
8693        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
8694        assert!(state.current_assistant.lock().unwrap().is_some());
8695
8696        // Manually apply ToolExecutionEnd (mirrors drain).
8697        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
8698        ended.set_result("hi", false);
8699        assert!(state.tool_components.lock().unwrap().is_empty());
8700
8701        // A running bash panel owns the visible spinner. The global loader is
8702        // hidden until the last concurrent bash tool completes, then restored
8703        // while the agent remains in the Working state.
8704        assert!(state.try_start_working());
8705        assert!(
8706            !state.try_start_working(),
8707            "a second submit must be rejected"
8708        );
8709        state.set_status(RunStatus::Idle);
8710        state.set_status(RunStatus::Working);
8711        assert_eq!(state.status_container.child_count(), 1);
8712        assert!(state.footer.get_status().is_empty());
8713        state.show_retry(3, 10, 8_000);
8714        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
8715        assert!(retry_status.contains("Retrying (3/10)"));
8716        state.set_status(RunStatus::Working);
8717        {
8718            let mut bash = state.bash_components.lock().unwrap();
8719            bash.insert(
8720                "bash-1".into(),
8721                Arc::new(BashExecutionComponent::new("one")),
8722            );
8723            bash.insert(
8724                "bash-2".into(),
8725                Arc::new(BashExecutionComponent::new("two")),
8726            );
8727        }
8728        state.sync_working_loader_with_bash();
8729        assert_eq!(state.status_container.child_count(), 0);
8730        state.bash_components.lock().unwrap().remove("bash-1");
8731        state.sync_working_loader_with_bash();
8732        assert_eq!(state.status_container.child_count(), 0);
8733        state.bash_components.lock().unwrap().remove("bash-2");
8734        state.sync_working_loader_with_bash();
8735        assert_eq!(state.status_container.child_count(), 1);
8736
8737        state.set_status(RunStatus::Aborting);
8738        assert_eq!(state.status_container.child_count(), 0);
8739        assert!(!state.loader.is_running());
8740    }
8741
8742    #[test]
8743    fn fresh_launch_does_not_restore_old_history() {
8744        let fresh = Args::default();
8745        assert!(!launch_restores_history(&fresh));
8746
8747        let continued = Args {
8748            continue_session: true,
8749            ..Args::default()
8750        };
8751        assert!(launch_restores_history(&continued));
8752
8753        let selected = Args {
8754            session: Some("session-id".into()),
8755            ..Args::default()
8756        };
8757        assert!(launch_restores_history(&selected));
8758    }
8759
8760    #[test]
8761    fn test_short_model_name() {
8762        assert_eq!(
8763            short_model_name("anthropic:claude-sonnet-5"),
8764            "claude-sonnet-5"
8765        );
8766        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
8767    }
8768
8769    #[test]
8770    fn model_selector_items_are_deduplicated_and_provider_qualified() {
8771        use rpi_ai::{Api, Model};
8772
8773        let mut gateway = Model::new(
8774            "gpt-5.6-sol",
8775            "GPT 5.6 Sol",
8776            Api::OpenaiCompletions,
8777            "routeryo-copy",
8778            "https://gateway.example.com",
8779        );
8780        let duplicate = gateway.clone();
8781        let anthropic = Model::new(
8782            "claude-sonnet-5",
8783            "Claude Sonnet 5",
8784            Api::AnthropicMessages,
8785            "anthropic",
8786            "https://api.anthropic.com",
8787        );
8788        gateway.headers = Some(std::collections::BTreeMap::from([(
8789            "authorization".into(),
8790            "Bearer test".into(),
8791        )]));
8792
8793        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
8794        assert_eq!(items.len(), 2);
8795        assert_eq!(items[0].value, "gpt-5.6-sol");
8796        assert_eq!(items[0].label, "GPT 5.6 Sol");
8797        assert_eq!(
8798            items[0].description.as_deref(),
8799            Some("routeryo-copy/gpt-5.6-sol (current)")
8800        );
8801        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
8802    }
8803
8804    #[test]
8805    fn model_selector_match_accepts_bare_and_qualified_ids() {
8806        use rpi_ai::{Api, Model};
8807
8808        let gateway = Model::new(
8809            "gpt-5.6-sol",
8810            "GPT 5.6 Sol",
8811            Api::OpenaiCompletions,
8812            "routeryo-copy",
8813            "https://gateway.example.com",
8814        );
8815        let anthropic = Model::new(
8816            "claude-sonnet-5",
8817            "Claude Sonnet 5",
8818            Api::AnthropicMessages,
8819            "anthropic",
8820            "https://api.anthropic.com",
8821        );
8822        let catalog = [gateway, anthropic];
8823        assert_eq!(
8824            find_model_selector_match(&catalog, "gpt-5.6-sol")
8825                .unwrap()
8826                .provider,
8827            "routeryo-copy"
8828        );
8829        assert_eq!(
8830            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
8831                .unwrap()
8832                .id,
8833            "gpt-5.6-sol"
8834        );
8835        assert_eq!(
8836            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
8837                .unwrap()
8838                .id,
8839            "claude-sonnet-5"
8840        );
8841        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
8842    }
8843
8844    #[test]
8845    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
8846        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
8847
8848        let failed = AssistantMessage {
8849            role: AssistantRole,
8850            content: Vec::new(),
8851            api: rpi_ai::Api::AnthropicMessages,
8852            provider: "anthropic".into(),
8853            model: "claude-sonnet-5".into(),
8854            response_model: None,
8855            response_id: None,
8856            usage: Usage::zero(),
8857            stop_reason: StopReason::Error,
8858            deferred: None,
8859            error_message: Some("upstream returned 401".into()),
8860            raw_stop_reason: None,
8861            end_turn: None,
8862            timestamp: 0,
8863        };
8864        assert_eq!(
8865            assistant_error_text(&failed).as_deref(),
8866            Some("upstream returned 401")
8867        );
8868
8869        let mut no_detail = failed;
8870        no_detail.error_message = Some("  ".into());
8871        assert_eq!(
8872            assistant_error_text(&no_detail).as_deref(),
8873            Some("Provider request failed.")
8874        );
8875
8876        let mut aborted = no_detail;
8877        aborted.stop_reason = StopReason::Aborted;
8878        aborted.error_message = Some("abort error: Request aborted".into());
8879        assert_eq!(
8880            assistant_error_text(&aborted).as_deref(),
8881            Some("abort error: Request aborted")
8882        );
8883
8884        aborted.error_message = None;
8885        assert_eq!(
8886            assistant_error_text(&aborted).as_deref(),
8887            Some("Request aborted.")
8888        );
8889    }
8890
8891    #[test]
8892    fn test_cycle_next_model_wraps_around() {
8893        use rpi_ai::{Api, Model};
8894        let mk = |id: &str| {
8895            Model::new(
8896                id,
8897                id,
8898                Api::AnthropicMessages,
8899                "anthropic",
8900                "https://api.anthropic.com",
8901            )
8902        };
8903        let catalog = [mk("a"), mk("b"), mk("c")];
8904        // Next after "a" is "b"; after "c" wraps to "a".
8905        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
8906        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
8907        // An unknown current id falls back to the first model.
8908        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
8909        // Empty catalog yields None.
8910        let empty: Vec<Model> = vec![];
8911        assert!(cycle_next_model(&empty, "a").is_none());
8912    }
8913
8914    #[test]
8915    fn test_autocomplete_slash_suggestions_render() {
8916        // The autocomplete container should render at least one suggestion
8917        // line when the editor holds a `/` prefix, and clear when it doesn't.
8918        let state = Arc::new(TuiState {
8919            current_assistant: std::sync::Mutex::new(None),
8920            tool_components: std::sync::Mutex::new(HashMap::new()),
8921            bash_components: std::sync::Mutex::new(HashMap::new()),
8922            themes_enabled: true,
8923            hide_thinking: std::sync::Mutex::new(false),
8924            tool_outputs_expanded: std::sync::Mutex::new(false),
8925            show_terminal_progress: true,
8926            status: std::sync::Mutex::new(RunStatus::Idle),
8927            js_preparation_cancel: std::sync::Mutex::new(None),
8928            footer: Arc::new(FooterComponent::new()),
8929            status_container: Arc::new(Container::new()),
8930            chat_container: Arc::new(Container::new()),
8931            loader: Arc::new(Loader::new()),
8932            last_assistant_text: std::sync::Mutex::new(String::new()),
8933            active_selector: std::sync::Mutex::new(None),
8934            active_extension_editor: std::sync::Mutex::new(None),
8935            active_extension_input: std::sync::Mutex::new(None),
8936            active_extension_cancel: std::sync::Mutex::new(None),
8937            autocomplete: AutocompleteManager::new(),
8938            autocomplete_container: Arc::new(Container::new()),
8939            autocomplete_max_visible: 5,
8940            pending_images: std::sync::Mutex::new(Vec::new()),
8941            theme_manager: Arc::new(ThemeManager::new()),
8942            tui: None,
8943            current_model_id: std::sync::Mutex::new(String::new()),
8944            show_images: std::sync::Mutex::new(true),
8945            history: std::sync::Mutex::new(Vec::new()),
8946            history_index: std::sync::Mutex::new(-1),
8947            history_draft: std::sync::Mutex::new(None),
8948            last_input_tokens: std::sync::Mutex::new(0),
8949            scoped_edit: std::sync::Mutex::new(None),
8950            markdown_transformer: std::sync::Mutex::new(None),
8951            extension_session: Arc::new(std::sync::Mutex::new(
8952                rpi_extensions::ExtensionSession::none(),
8953            )),
8954        });
8955        {
8956            let mut combined = CombinedAutocompleteProvider::new();
8957            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
8958                build_builtin_registry().visible_entries(),
8959            )));
8960            state.autocomplete.set_provider(Arc::new(combined));
8961        }
8962
8963        let editor = Arc::new(Editor::simple());
8964        editor.set_text("/he");
8965        editor.set_cursor(0, 3);
8966        refresh_autocomplete(&state, &editor);
8967        let lines = state.autocomplete_container.render(80);
8968        let joined: String = lines.join("\n");
8969        assert!(
8970            joined.contains("/help"),
8971            "slash suggestions not rendered: {joined}"
8972        );
8973
8974        // Clear: no suggestions for plain text.
8975        editor.set_text("hello");
8976        editor.set_cursor(0, 5);
8977        refresh_autocomplete(&state, &editor);
8978        assert!(state.autocomplete_container.render(80).is_empty());
8979    }
8980
8981    #[test]
8982    fn test_select_list_swap_restores_editor() {
8983        // The editor-container swap: opening a selector replaces the editor
8984        // child; closing restores it. Verify the container child count + the
8985        // active_selector flag round-trip.
8986        let state = Arc::new(TuiState {
8987            current_assistant: std::sync::Mutex::new(None),
8988            tool_components: std::sync::Mutex::new(HashMap::new()),
8989            bash_components: std::sync::Mutex::new(HashMap::new()),
8990            themes_enabled: true,
8991            hide_thinking: std::sync::Mutex::new(false),
8992            tool_outputs_expanded: std::sync::Mutex::new(false),
8993            show_terminal_progress: true,
8994            status: std::sync::Mutex::new(RunStatus::Idle),
8995            js_preparation_cancel: std::sync::Mutex::new(None),
8996            footer: Arc::new(FooterComponent::new()),
8997            status_container: Arc::new(Container::new()),
8998            chat_container: Arc::new(Container::new()),
8999            loader: Arc::new(Loader::new()),
9000            last_assistant_text: std::sync::Mutex::new(String::new()),
9001            active_selector: std::sync::Mutex::new(None),
9002            active_extension_editor: std::sync::Mutex::new(None),
9003            active_extension_input: std::sync::Mutex::new(None),
9004            active_extension_cancel: std::sync::Mutex::new(None),
9005            autocomplete: AutocompleteManager::new(),
9006            autocomplete_container: Arc::new(Container::new()),
9007            autocomplete_max_visible: 5,
9008            pending_images: std::sync::Mutex::new(Vec::new()),
9009            theme_manager: Arc::new(ThemeManager::new()),
9010            tui: None,
9011            current_model_id: std::sync::Mutex::new(String::new()),
9012            show_images: std::sync::Mutex::new(true),
9013            history: std::sync::Mutex::new(Vec::new()),
9014            history_index: std::sync::Mutex::new(-1),
9015            history_draft: std::sync::Mutex::new(None),
9016            last_input_tokens: std::sync::Mutex::new(0),
9017            scoped_edit: std::sync::Mutex::new(None),
9018            markdown_transformer: std::sync::Mutex::new(None),
9019            extension_session: Arc::new(std::sync::Mutex::new(
9020                rpi_extensions::ExtensionSession::none(),
9021            )),
9022        });
9023        let editor_container = Arc::new(Container::new());
9024        let editor = Arc::new(Editor::simple());
9025        editor_container.add_child(editor.clone());
9026        assert!(!state.selector_open());
9027
9028        let tui_terminal = Box::new(ProcessTerminal::new());
9029        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
9030        let list = Arc::new(SelectList::new(
9031            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
9032            5,
9033        ));
9034        open_selector(
9035            &state,
9036            &editor_container,
9037            &editor,
9038            &tui,
9039            list,
9040            SelectorKind::Theme,
9041        );
9042        assert!(state.selector_open());
9043        // list only (editor swapped out).
9044        assert_eq!(editor_container.child_count(), 1);
9045
9046        close_selector(&state, &editor_container, &editor, &tui);
9047        assert!(!state.selector_open());
9048        // editor restored.
9049        assert_eq!(editor_container.child_count(), 1);
9050    }
9051
9052    #[test]
9053    fn test_message_history_browse_restores_draft() {
9054        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
9055        // messages, browse older → newer → back past the newest restores the
9056        // draft the user was typing.
9057        let state = Arc::new(TuiState {
9058            current_assistant: std::sync::Mutex::new(None),
9059            tool_components: std::sync::Mutex::new(HashMap::new()),
9060            bash_components: std::sync::Mutex::new(HashMap::new()),
9061            themes_enabled: true,
9062            hide_thinking: std::sync::Mutex::new(false),
9063            tool_outputs_expanded: std::sync::Mutex::new(false),
9064            show_terminal_progress: true,
9065            status: std::sync::Mutex::new(RunStatus::Idle),
9066            js_preparation_cancel: std::sync::Mutex::new(None),
9067            footer: Arc::new(FooterComponent::new()),
9068            status_container: Arc::new(Container::new()),
9069            chat_container: Arc::new(Container::new()),
9070            loader: Arc::new(Loader::new()),
9071            last_assistant_text: std::sync::Mutex::new(String::new()),
9072            active_selector: std::sync::Mutex::new(None),
9073            active_extension_editor: std::sync::Mutex::new(None),
9074            active_extension_input: std::sync::Mutex::new(None),
9075            active_extension_cancel: std::sync::Mutex::new(None),
9076            autocomplete: AutocompleteManager::new(),
9077            autocomplete_container: Arc::new(Container::new()),
9078            autocomplete_max_visible: 5,
9079            pending_images: std::sync::Mutex::new(Vec::new()),
9080            theme_manager: Arc::new(ThemeManager::new()),
9081            tui: None,
9082            current_model_id: std::sync::Mutex::new(String::new()),
9083            show_images: std::sync::Mutex::new(true),
9084            history: std::sync::Mutex::new(Vec::new()),
9085            history_index: std::sync::Mutex::new(-1),
9086            history_draft: std::sync::Mutex::new(None),
9087            last_input_tokens: std::sync::Mutex::new(0),
9088            scoped_edit: std::sync::Mutex::new(None),
9089            markdown_transformer: std::sync::Mutex::new(None),
9090            extension_session: Arc::new(std::sync::Mutex::new(
9091                rpi_extensions::ExtensionSession::none(),
9092            )),
9093        });
9094        let editor = Arc::new(Editor::simple());
9095
9096        push_history(&state, "first message");
9097        push_history(&state, "second message");
9098        // Consecutive duplicate is skipped.
9099        push_history(&state, "second message");
9100        push_history(&state, "   "); // empty → skipped
9101        assert_eq!(state.history.lock().unwrap().len(), 2);
9102        assert_eq!(state.history.lock().unwrap()[0], "second message");
9103
9104        // User starts typing a fresh prompt.
9105        editor.set_text("half-typed");
9106        editor.set_cursor(0, 11);
9107
9108        // ↑ → most recent.
9109        navigate_history(&state, &editor, -1);
9110        assert_eq!(editor.get_text(), "second message");
9111        assert_eq!(*state.history_index.lock().unwrap(), 0);
9112        // ↑ → older.
9113        navigate_history(&state, &editor, -1);
9114        assert_eq!(editor.get_text(), "first message");
9115        assert_eq!(*state.history_index.lock().unwrap(), 1);
9116        // ↑ past the oldest → stays (no wrap).
9117        navigate_history(&state, &editor, -1);
9118        assert_eq!(editor.get_text(), "first message");
9119        // ↓ → newer.
9120        navigate_history(&state, &editor, 1);
9121        assert_eq!(editor.get_text(), "second message");
9122        // ↓ past the newest → restores the draft.
9123        navigate_history(&state, &editor, 1);
9124        assert_eq!(editor.get_text(), "half-typed");
9125        assert_eq!(*state.history_index.lock().unwrap(), -1);
9126    }
9127
9128    #[test]
9129    fn test_accept_top_suggestion_replaces_prefix() {
9130        // `/he` + Tab → `/help ` (slash command provider inserts a space).
9131        let state = Arc::new(TuiState {
9132            current_assistant: std::sync::Mutex::new(None),
9133            tool_components: std::sync::Mutex::new(HashMap::new()),
9134            bash_components: std::sync::Mutex::new(HashMap::new()),
9135            themes_enabled: true,
9136            hide_thinking: std::sync::Mutex::new(false),
9137            tool_outputs_expanded: std::sync::Mutex::new(false),
9138            show_terminal_progress: true,
9139            status: std::sync::Mutex::new(RunStatus::Idle),
9140            js_preparation_cancel: std::sync::Mutex::new(None),
9141            footer: Arc::new(FooterComponent::new()),
9142            status_container: Arc::new(Container::new()),
9143            chat_container: Arc::new(Container::new()),
9144            loader: Arc::new(Loader::new()),
9145            last_assistant_text: std::sync::Mutex::new(String::new()),
9146            active_selector: std::sync::Mutex::new(None),
9147            active_extension_editor: std::sync::Mutex::new(None),
9148            active_extension_input: std::sync::Mutex::new(None),
9149            active_extension_cancel: std::sync::Mutex::new(None),
9150            autocomplete: AutocompleteManager::new(),
9151            autocomplete_container: Arc::new(Container::new()),
9152            autocomplete_max_visible: 5,
9153            pending_images: std::sync::Mutex::new(Vec::new()),
9154            theme_manager: Arc::new(ThemeManager::new()),
9155            tui: None,
9156            current_model_id: std::sync::Mutex::new(String::new()),
9157            show_images: std::sync::Mutex::new(true),
9158            history: std::sync::Mutex::new(Vec::new()),
9159            history_index: std::sync::Mutex::new(-1),
9160            history_draft: std::sync::Mutex::new(None),
9161            last_input_tokens: std::sync::Mutex::new(0),
9162            scoped_edit: std::sync::Mutex::new(None),
9163            markdown_transformer: std::sync::Mutex::new(None),
9164            extension_session: Arc::new(std::sync::Mutex::new(
9165                rpi_extensions::ExtensionSession::none(),
9166            )),
9167        });
9168        {
9169            let mut combined = CombinedAutocompleteProvider::new();
9170            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
9171                build_builtin_registry().visible_entries(),
9172            )));
9173            state.autocomplete.set_provider(Arc::new(combined));
9174        }
9175        let editor = Arc::new(Editor::simple());
9176        editor.set_text("/he");
9177        editor.set_cursor(0, 3);
9178        refresh_autocomplete(&state, &editor);
9179        let accepted = accept_top_suggestion(&state, &editor);
9180        assert!(accepted, "should accept the top suggestion");
9181        let text = editor.get_text();
9182        assert!(
9183            text.starts_with("/help"),
9184            "editor text should start with /help, got {text}"
9185        );
9186    }
9187
9188    #[test]
9189    fn configured_key_parser_supports_native_notation() {
9190        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
9191        assert_eq!(combo.code, KeyCode::Char('g'));
9192        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
9193        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
9194        assert_eq!(combo.code, KeyCode::BackTab);
9195    }
9196
9197    #[test]
9198    fn double_escape_trigger_has_half_second_window() {
9199        let now = std::time::Instant::now();
9200        assert!(!double_escape_trigger(None, now));
9201        assert!(double_escape_trigger(
9202            Some(now - std::time::Duration::from_millis(500)),
9203            now
9204        ));
9205        assert!(!double_escape_trigger(
9206            Some(now - std::time::Duration::from_millis(501)),
9207            now
9208        ));
9209    }
9210}