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