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