Skip to main content

rpi_cli/
interactive_tui.rs

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