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, Markdown, ProcessTerminal, ScrollView,
48    ScrollViewOptions, SearchBar, SelectItem, SelectList, SlashCommand as SlashCommandEntry,
49    SlashCommandAutocompleteProvider, Spacer, StackChild, StackEntry, StatusIndicator, Text,
50    ThemeManager, ThemePreset, ToolExecutionComponent, TuiAltScreen, UserMessageComponent, VStack,
51    TUI, AltScreenSearch,
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    /// The product-layer session (`AgentSession`). Commands that need durable
547    /// reads, export, or usage stats go through this instead of reaching into
548    /// the harness directly, so the product surface is one object.
549    session: Arc<crate::agent_session::AgentSession>,
550}
551
552/// One slash command.
553trait SlashCommand: Send + Sync {
554    /// Canonical name, with the leading `/` (e.g. "/model").
555    fn name(&self) -> &str;
556    /// Aliases, also `/`-prefixed. Matched alongside `name()` during dispatch.
557    /// Use [`SlashCommand::alias_visible`] to also surface an alias in the
558    /// `/`-autocomplete list (most aliases stay hidden).
559    fn aliases(&self) -> &'static [&'static str] {
560        &[]
561    }
562    /// Whether the canonical name appears in the `/` autocomplete list. Hidden
563    /// commands (`/context`, `/name`, …) return `false`.
564    fn visible(&self) -> bool {
565        true
566    }
567    /// Aliases that should also appear in the `/` autocomplete list. Defaults to
568    /// none — most aliases (`/q`, `/m`, `/think`, `/resume`, `/v`) are kept off
569    /// the list to keep it short. `/new` and `/quit` override this to surface.
570    fn alias_visible(&self) -> &'static [&'static str] {
571        &[]
572    }
573    /// Description shown in autocomplete and `/help`. A non-empty description is
574    /// required to surface in autocomplete even when `visible()` is true.
575    fn description(&self) -> &'static str {
576        ""
577    }
578    fn description_owned(&self) -> String {
579        self.description().to_string()
580    }
581    /// Execute the command. Only invoked for inputs starting with `/` whose
582    /// first token matches `name()` or an alias. `args` is the whitespace-
583    /// trimmed remainder after the command token ("" when none). Must stay
584    /// synchronous (see the module-level note) — async work goes through
585    /// `ctx.tx.send(TuiMessage::…)` or `tokio::spawn`.
586    fn execute(&self, ctx: &CommandContext, args: &str);
587}
588
589/// Holds all registered slash commands; the single source of truth for both
590/// dispatch and the autocomplete list.
591struct CommandRegistry {
592    commands: Vec<Arc<dyn SlashCommand>>,
593}
594
595impl CommandRegistry {
596    fn new() -> Self {
597        Self {
598            commands: Vec::new(),
599        }
600    }
601
602    fn register(&mut self, cmd: Arc<dyn SlashCommand>) {
603        self.commands.push(cmd);
604    }
605
606    /// Find the command whose `name()` or an alias matches `token` (e.g. "/q").
607    /// `token` is the first whitespace-delimited word of the input, `/`-prefixed.
608    fn find(&self, token: &str) -> Option<&Arc<dyn SlashCommand>> {
609        self.commands
610            .iter()
611            .find(|c| c.name() == token || c.aliases().contains(&token))
612    }
613
614    /// The autocomplete entries, derived from the registry so it can never drift
615    /// from what dispatch recognizes. Surfaces the canonical name when
616    /// `visible()` + non-empty description, plus any `alias_visible()` entries.
617    /// Order = registration order; built-ins are registered before templates,
618    /// so they win on a fuzzy tie (unchanged).
619    fn visible_entries(&self) -> Vec<SlashCommandEntry> {
620        let mut out: Vec<SlashCommandEntry> = Vec::new();
621        for c in &self.commands {
622            let description = c.description_owned();
623            if c.visible() && !description.is_empty() {
624                out.push(SlashCommandEntry {
625                    name: c.name().into(),
626                    description: description.clone(),
627                });
628            }
629            // Surfaced aliases share the command's description.
630            for alias in c.alias_visible() {
631                out.push(SlashCommandEntry {
632                    name: (*alias).into(),
633                    description: description.clone(),
634                });
635            }
636        }
637        out
638    }
639}
640
641/// Dispatch a ui_prompt_start event to extensions.
642fn dispatch_ui_prompt_event(state: &TuiState) {
643    use rpi_plugin_sdk::EventTag;
644    
645    if let Ok(session) = state.extension_session.lock() {
646        if let Some(snapshot) = session.snapshot_arc() {
647            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptStart);
648        }
649    }
650}
651
652/// Dispatch a ui_prompt_end event to extensions.
653fn dispatch_ui_prompt_event_end(state: &TuiState) {
654    use rpi_plugin_sdk::EventTag;
655    
656    if let Ok(session) = state.extension_session.lock() {
657        if let Some(snapshot) = session.snapshot_arc() {
658            rpi_extensions::dispatch_empty_event(&snapshot, EventTag::UiPromptEnd);
659        }
660    }
661}
662
663/// Resolve the command for a `/`-prefixed input and run it, or emit the
664/// unknown-command error if nothing matches. Non-slash text never reaches here
665/// — callers route only `/`-prefixed inputs and send plain text directly.
666fn dispatch_slash(text: &str, ctx: &CommandContext, registry: &CommandRegistry) {
667    let mut parts = text.split_whitespace();
668    let token = parts.next().unwrap_or("");
669    let args = parts.collect::<Vec<_>>().join(" ");
670    match registry.find(token) {
671        Some(cmd) => cmd.execute(ctx, &args),
672        None => {
673            add_error_message(
674                &ctx.chat,
675                &format!("Unknown command: {text}. Type /help for available commands."),
676            );
677            ctx.tui.request_render(false);
678        }
679    }
680}
681
682/// Encode a crossterm key into the raw key data consumed by the Node TUI
683/// compatibility layer. Plain keys retain the usual terminal sequences;
684/// modified functional keys use Kitty CSI-u so Shift/Alt/Ctrl combinations are
685/// not collapsed into their unmodified equivalent (notably Shift+Enter).
686fn key_event_to_input(key: crossterm::event::KeyEvent) -> String {
687    use crossterm::event::{KeyCode, KeyModifiers};
688
689    let modifiers = key.modifiers;
690    let ctrl = modifiers.contains(KeyModifiers::CONTROL);
691    let shift = modifiers.contains(KeyModifiers::SHIFT);
692    let alt = modifiers.contains(KeyModifiers::ALT);
693    let super_key = modifiers.contains(KeyModifiers::SUPER);
694
695    if modifiers == KeyModifiers::NONE {
696        return match key.code {
697            KeyCode::Char(ch) => ch.to_string(),
698            KeyCode::Enter => "\r".into(),
699            KeyCode::Esc => "\x1b".into(),
700            KeyCode::Backspace => "\x7f".into(),
701            KeyCode::Tab => "\t".into(),
702            // Crossterm represents Shift+Tab as `BackTab` on both Unix
703            // (`ESC[Z`) and Windows. Preserve the canonical terminal form
704            // so the Node keybinding matcher sees `shift+tab`.
705            KeyCode::BackTab => "\x1b[Z".into(),
706            KeyCode::Up => "\x1b[A".into(),
707            KeyCode::Down => "\x1b[B".into(),
708            KeyCode::Right => "\x1b[C".into(),
709            KeyCode::Left => "\x1b[D".into(),
710            KeyCode::Home => "\x1b[H".into(),
711            KeyCode::End => "\x1b[F".into(),
712            KeyCode::PageUp => "\x1b[5~".into(),
713            KeyCode::PageDown => "\x1b[6~".into(),
714            KeyCode::Delete => "\x1b[3~".into(),
715            KeyCode::Insert => "\x1b[2~".into(),
716            KeyCode::F(n) => format!("\x1b[{}~", 10 + n as u16),
717            _ => String::new(),
718        };
719    }
720
721    // Legacy control bytes are what the native `matchesKey` implementation
722    // expects for the common Ctrl+letter actions (Ctrl+C, Ctrl+O, Ctrl+J...).
723    if ctrl && !shift && !alt && !super_key {
724        if let KeyCode::Char(ch) = key.code {
725            if let Some(code) = control_code(ch) {
726                return char::from(code).to_string();
727            }
728        }
729    }
730
731    // Legacy Alt+character input is unambiguous when no other modifier is
732    // present and is accepted by pi's `matchesKey` fallback parser.
733    if alt && !ctrl && !shift && !super_key {
734        if let KeyCode::Char(ch) = key.code {
735            return format!("\x1b{ch}");
736        }
737    }
738
739    // Crossterm has already resolved the keyboard layout for character events
740    // (for example, Windows reports Shift+1 as `Char('!')`). Pass that actual
741    // character through unchanged so custom components receive text instead
742    // of a CSI-u escape sequence. Functional keys and combined modifiers use
743    // CSI-u below so their modifier identity remains available to keybindings.
744    if shift && !ctrl && !alt && !super_key {
745        if let KeyCode::Char(ch) = key.code {
746            return ch.to_string();
747        }
748    }
749
750    if let Some(sequence) = modified_functional_sequence(key.code, modifiers) {
751        return sequence;
752    }
753    let Some(codepoint) = key_codepoint(key.code, ctrl) else {
754        return String::new();
755    };
756    kitty_key_sequence(codepoint, modifiers)
757}
758
759fn control_code(ch: char) -> Option<u8> {
760    let ch = ch.to_ascii_lowercase();
761    Some(match ch {
762        '@' | ' ' => 0,
763        'a'..='z' => (ch as u8) & 0x1f,
764        '[' => 0x1b,
765        '\\' => 0x1c,
766        ']' => 0x1d,
767        '^' => 0x1e,
768        '_' | '-' => 0x1f,
769        _ => return None,
770    })
771}
772
773fn key_codepoint(code: crossterm::event::KeyCode, ctrl: bool) -> Option<u32> {
774    use crossterm::event::KeyCode;
775    Some(match code {
776        KeyCode::Char(ch) => {
777            if ctrl {
778                ch.to_ascii_lowercase() as u32
779            } else {
780                ch as u32
781            }
782        }
783        KeyCode::Enter => 13,
784        KeyCode::Esc => 27,
785        KeyCode::Backspace => 127,
786        KeyCode::Tab => 9,
787        // Keep modified BackTab combinations representable through CSI-u;
788        // the unmodified/SHIFT form is handled as the legacy `ESC[Z` above.
789        KeyCode::BackTab => 9,
790        _ => return None,
791    })
792}
793
794fn modified_functional_sequence(
795    code: crossterm::event::KeyCode,
796    modifiers: crossterm::event::KeyModifiers,
797) -> Option<String> {
798    use crossterm::event::{KeyCode, KeyModifiers};
799    // `BackTab` is already a semantic Shift+Tab event. Crossterm normally
800    // includes SHIFT in its modifier bits, but preserving the legacy sequence
801    // for a synthetic event without that bit keeps the adapter portable.
802    if code == KeyCode::BackTab
803        && !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
804    {
805        return Some("\x1b[Z".into());
806    }
807    let modifier = kitty_modifier(modifiers);
808    let sequence = match code {
809        KeyCode::Up => format!("\x1b[1;{modifier}A"),
810        KeyCode::Down => format!("\x1b[1;{modifier}B"),
811        KeyCode::Right => format!("\x1b[1;{modifier}C"),
812        KeyCode::Left => format!("\x1b[1;{modifier}D"),
813        KeyCode::Home => format!("\x1b[1;{modifier}H"),
814        KeyCode::End => format!("\x1b[1;{modifier}F"),
815        KeyCode::Insert => format!("\x1b[2;{modifier}~"),
816        KeyCode::Delete => format!("\x1b[3;{modifier}~"),
817        KeyCode::PageUp => format!("\x1b[5;{modifier}~"),
818        KeyCode::PageDown => format!("\x1b[6;{modifier}~"),
819        _ => return None,
820    };
821    Some(sequence)
822}
823
824fn kitty_modifier(modifiers: crossterm::event::KeyModifiers) -> u8 {
825    use crossterm::event::KeyModifiers;
826    let mut modifier = 1u8;
827    if modifiers.contains(KeyModifiers::SHIFT) {
828        modifier += 1;
829    }
830    if modifiers.contains(KeyModifiers::ALT) {
831        modifier += 2;
832    }
833    if modifiers.contains(KeyModifiers::CONTROL) {
834        modifier += 4;
835    }
836    if modifiers.contains(KeyModifiers::SUPER) {
837        modifier += 8;
838    }
839    modifier
840}
841
842fn kitty_key_sequence(codepoint: u32, modifiers: crossterm::event::KeyModifiers) -> String {
843    let modifier = kitty_modifier(modifiers);
844    format!("\x1b[{codepoint};{modifier}u")
845}
846
847/// A slash command registered by a native extension. The command metadata is
848/// captured for autocomplete, while the handler is looked up from the live
849/// session on every invocation so `/reload` takes effect without rebuilding
850/// the editor callback.
851struct ExtensionCommand {
852    name: String,
853    description: String,
854    session: crate::session::ExtensionSessionCell,
855}
856
857struct JsExtensionCommand {
858    name: String,
859    session: crate::js_extensions::JsExtensionSession,
860}
861
862impl SlashCommand for JsExtensionCommand {
863    fn name(&self) -> &str {
864        &self.name
865    }
866    fn description(&self) -> &'static str {
867        "JS extension command"
868    }
869    fn description_owned(&self) -> String {
870        "JS extension command".to_string()
871    }
872    fn execute(&self, ctx: &CommandContext, args: &str) {
873        // JS commands may own the terminal for their entire lifetime (for
874        // example pi-btw's fullscreen side thread). Running them inline here
875        // would block the crossterm key thread, so no input could reach the
876        // extension while it is waiting for `ui.custom()` to complete.
877        let session = self.session.clone();
878        let command = self.name.trim_start_matches('/').to_string();
879        let args = args.to_string();
880        let ctx = ctx.clone();
881        // The command runs off the key thread. Keep the startup snapshot so a
882        // late editorText result cannot overwrite text typed while the command
883        // was in flight.
884        let initial_editor_text = ctx.editor.get_text();
885        tokio::task::spawn_blocking(move || {
886            match session.invoke_command_with_context(
887                &command,
888                &args,
889                serde_json::json!({"editorText": initial_editor_text}),
890            ) {
891                Ok(value) => {
892                    if let Some(editor_text) = value.get("editorText").and_then(|v| v.as_str()) {
893                        if ctx.editor.get_text() == initial_editor_text
894                            && editor_text != initial_editor_text
895                        {
896                            let cursor = editor_text.chars().count();
897                            ctx.editor.set_text(editor_text);
898                            ctx.editor.set_cursor(0, cursor);
899                        }
900                    }
901                    if let Some(notifications) =
902                        value.get("notifications").and_then(|v| v.as_array())
903                    {
904                        for notification in notifications {
905                            let message = notification
906                                .get("message")
907                                .and_then(|v| v.as_str())
908                                .unwrap_or_default();
909                            if message.is_empty() {
910                                continue;
911                            }
912                            match notification.get("level").and_then(|v| v.as_str()) {
913                                Some("error") => add_error_message(&ctx.chat, message),
914                                _ => add_note_message(&ctx.chat, message),
915                            }
916                        }
917                    }
918                    let result = value.get("result").unwrap_or(&value);
919                    let text = result
920                        .get("text")
921                        .and_then(|item| item.as_str())
922                        .map(str::to_string)
923                        .or_else(|| result.as_str().map(str::to_string))
924                        .filter(|text| !text.is_empty() && text != "null");
925                    if let Some(text) = text {
926                        add_note_message(&ctx.chat, &text);
927                    }
928                }
929                Err(error) => {
930                    add_error_message(&ctx.chat, &format!("JS extension command failed: {error}"))
931                }
932            }
933            ctx.tui.request_render(false);
934        });
935    }
936}
937
938impl SlashCommand for ExtensionCommand {
939    fn name(&self) -> &str {
940        &self.name
941    }
942
943    fn description(&self) -> &'static str {
944        "extension command"
945    }
946
947    fn description_owned(&self) -> String {
948        self.description.clone()
949    }
950
951    fn execute(&self, ctx: &CommandContext, args: &str) {
952        let result = invoke_extension_command(&self.session, &self.name, args);
953        handle_extension_ui_result(result, ctx, self.session.clone(), self.name.clone());
954    }
955}
956
957fn invoke_extension_command(
958    session: &crate::session::ExtensionSessionCell,
959    name: &str,
960    args: &str,
961) -> Option<serde_json::Value> {
962    let command = session
963        .lock()
964        .ok()
965        .and_then(|s| s.snapshot_arc())
966        .and_then(|snap| {
967            snap.commands()
968                .iter()
969                .find(|c| c.name.trim_start_matches('/') == name.trim_start_matches('/'))
970                .cloned()
971        })?;
972    let input = serde_json::json!({ "args": args, "command": name });
973    let input = serde_json::to_string(&input).ok()?;
974    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
975        let mut out = rpi_plugin_sdk::StbString::empty();
976        let rc = (command.handler)(
977            rpi_plugin_sdk::StbStringRef::from_str(&input),
978            &mut out as *mut rpi_plugin_sdk::StbString,
979            command.user_data,
980        );
981        let text = if rc == 0 {
982            Some(out.to_string_lossy())
983        } else {
984            None
985        };
986        rpi_extensions::host_free_string(out);
987        text
988    }))
989    .ok()
990    .flatten()?;
991    serde_json::from_str(&outcome).ok()
992}
993
994fn handle_extension_ui_result(
995    result: Option<serde_json::Value>,
996    ctx: &CommandContext,
997    session: crate::session::ExtensionSessionCell,
998    command_name: String,
999) {
1000    let Some(value) = result else {
1001        add_error_message(&ctx.chat, "Extension command failed.");
1002        ctx.tui.request_render(false);
1003        return;
1004    };
1005    // A cancellation continuation may intentionally return JSON null. Native
1006    // pi resolves the pending promise with `undefined` and does not add a
1007    // visible "null" message to the transcript.
1008    if value.is_null() {
1009        ctx.tui.request_render(false);
1010        return;
1011    }
1012    match value.get("kind").and_then(|v| v.as_str()) {
1013        Some("message") | None => {
1014            let fallback = value.to_string();
1015            let text = value
1016                .get("text")
1017                .and_then(|v| v.as_str())
1018                .unwrap_or(&fallback)
1019                .to_string();
1020            if !text.is_empty() {
1021                add_note_message(&ctx.chat, &text);
1022            }
1023            ctx.tui.request_render(false);
1024        }
1025        Some("selector") => open_extension_selector(ctx, session, command_name, value),
1026        Some("editor") => open_extension_editor(ctx, session, command_name, value),
1027        // Native pi exposes `ctx.ui.input(title, placeholder)` separately
1028        // from the multiline editor. Render it as a focused single-line
1029        // dialog in the swapped input slot.
1030        Some("input") => open_extension_input(ctx, session, command_name, value),
1031        Some(other) => {
1032            add_error_message(&ctx.chat, &format!("Unsupported extension UI: {other}"));
1033            ctx.tui.request_render(false);
1034        }
1035    }
1036}
1037
1038/// Render the title used by the native pi extension dialogs.  Keeping it in
1039/// the swapped editor container makes the question stay visible while the
1040/// extension waits for the answer, instead of adding a transient chat note.
1041fn extension_dialog_title(title: &str, bold: bool) -> Arc<Text> {
1042    let colors = current_theme().colors;
1043    let text = if bold {
1044        tui_bold(title)
1045    } else {
1046        title.to_string()
1047    };
1048    Arc::new(Text::new(colors.accent.fg(&text), 1, 0))
1049}
1050
1051fn extension_dialog_hint(label: &str) -> Arc<Text> {
1052    Arc::new(Text::new(current_theme().colors.muted.fg(label), 1, 0))
1053}
1054
1055/// Markdown-aware version of `extension_dialog_title` for extension dialogs
1056/// that may contain markdown content (tables, lists, bold, etc).
1057fn extension_dialog_title_md(title: &str, bold: bool) -> Arc<dyn Component> {
1058    let text = if bold {
1059        format!("**{}**", title)
1060    } else {
1061        title.to_string()
1062    };
1063    Arc::new(Markdown::new(text, 1, 0))
1064}
1065
1066/// Markdown-aware version of `extension_dialog_hint` for extension dialogs
1067/// that may contain markdown content.
1068fn extension_dialog_hint_md(label: &str) -> Arc<dyn Component> {
1069    Arc::new(Markdown::new(label, 1, 0))
1070}
1071
1072/// Take and run the cancellation callback for the active extension dialog.
1073/// Taking it before invoking the callback breaks the temporary Arc cycle: the
1074/// callback owns the command context so it can process a follow-up result.
1075fn run_extension_cancel(state: &Arc<TuiState>) -> bool {
1076    let callback = state.active_extension_cancel.lock().unwrap().take();
1077    if let Some(callback) = callback {
1078        callback();
1079        true
1080    } else {
1081        false
1082    }
1083}
1084
1085fn open_extension_input(
1086    ctx: &CommandContext,
1087    session: crate::session::ExtensionSessionCell,
1088    command_name: String,
1089    value: serde_json::Value,
1090) {
1091    let title = value
1092        .get("title")
1093        .and_then(|v| v.as_str())
1094        .filter(|title| !title.is_empty())
1095        .unwrap_or("Input");
1096    let input = value
1097        .get("placeholder")
1098        .and_then(|v| v.as_str())
1099        .map(Input::with_placeholder)
1100        .unwrap_or_default();
1101    let input = Arc::new(input);
1102    if let Some(initial) = value
1103        .get("initialText")
1104        .or_else(|| value.get("text"))
1105        .and_then(|v| v.as_str())
1106    {
1107        input.set_value(initial);
1108    }
1109    input.set_focused(true);
1110
1111    let frame = Arc::new(Container::new());
1112    frame.add_child(Arc::new(DynamicBorder::new()));
1113    frame.add_child(Arc::new(Spacer::new(1)));
1114    frame.add_child(extension_dialog_title(title, false));
1115    frame.add_child(Arc::new(Spacer::new(1)));
1116    frame.add_child(input.clone());
1117    frame.add_child(Arc::new(Spacer::new(1)));
1118    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1119    frame.add_child(Arc::new(Spacer::new(1)));
1120    frame.add_child(Arc::new(DynamicBorder::new()));
1121
1122    *ctx.state.active_extension_editor.lock().unwrap() = None;
1123    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1124    ctx.editor_container.clear();
1125    ctx.editor_container.add_child(frame);
1126
1127    let state = ctx.state.clone();
1128    let ec = ctx.editor_container.clone();
1129    let original = ctx.editor.clone();
1130    let tui = ctx.tui.clone();
1131    let session_submit = session.clone();
1132    let command_submit = command_name.clone();
1133    let ctx_submit = ctx.clone();
1134    input.on_submit(Arc::new(move |text| {
1135        let args = serde_json::json!({ "action": "input", "value": text, "text": text });
1136        let result = invoke_extension_command(
1137            &session_submit,
1138            &command_submit,
1139            &serde_json::to_string(&args).unwrap_or_default(),
1140        );
1141        close_extension_editor(&state, &ec, &original, &tui);
1142        handle_extension_ui_result(
1143            result,
1144            &ctx_submit,
1145            session_submit.clone(),
1146            command_submit.clone(),
1147        );
1148    }));
1149
1150    let state_cancel = ctx.state.clone();
1151    let ec_cancel = ctx.editor_container.clone();
1152    let original_cancel = ctx.editor.clone();
1153    let tui_cancel = ctx.tui.clone();
1154    let session_cancel = session.clone();
1155    let command_cancel = command_name.clone();
1156    let ctx_cancel = ctx.clone();
1157    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1158        let args = serde_json::json!({ "action": "cancel" });
1159        let result = invoke_extension_command(
1160            &session_cancel,
1161            &command_cancel,
1162            &serde_json::to_string(&args).unwrap_or_default(),
1163        );
1164        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1165        handle_extension_ui_result(
1166            result,
1167            &ctx_cancel,
1168            session_cancel.clone(),
1169            command_cancel.clone(),
1170        );
1171    }));
1172
1173    ctx.tui.set_focus(Some(input));
1174    ctx.tui.request_render(false);
1175}
1176
1177fn open_extension_selector(
1178    ctx: &CommandContext,
1179    session: crate::session::ExtensionSessionCell,
1180    command_name: String,
1181    value: serde_json::Value,
1182) {
1183    let items = value
1184        .get("items")
1185        .and_then(|v| v.as_array())
1186        .map(|items| {
1187            items
1188                .iter()
1189                .filter_map(|item| {
1190                    // Native pi's selector accepts `string[]`; the Rust ABI
1191                    // also permits `{value,label,description}` objects.
1192                    let value = if let Some(value) = item.as_str() {
1193                        value
1194                    } else {
1195                        item.get("value")?.as_str()?
1196                    };
1197                    let label = item.get("label").and_then(|v| v.as_str()).unwrap_or(value);
1198                    let mut out = SelectItem::new(value, label);
1199                    if let Some(desc) = item.get("description").and_then(|v| v.as_str()) {
1200                        out = out.with_description(desc);
1201                    }
1202                    Some(out)
1203                })
1204                .collect::<Vec<_>>()
1205        })
1206        .unwrap_or_default();
1207    if items.is_empty() {
1208        add_error_message(&ctx.chat, "Extension selector has no items.");
1209        ctx.tui.request_render(false);
1210        return;
1211    }
1212    let list = Arc::new(SelectList::new(items, 10));
1213    let title = value
1214        .get("title")
1215        .and_then(|v| v.as_str())
1216        .filter(|title| !title.is_empty())
1217        .unwrap_or("Select");
1218    let frame = Arc::new(Container::new());
1219    frame.add_child(Arc::new(DynamicBorder::new()));
1220    frame.add_child(Arc::new(Spacer::new(1)));
1221    frame.add_child(extension_dialog_title(title, true));
1222    frame.add_child(Arc::new(Spacer::new(1)));
1223    frame.add_child(list.clone());
1224    frame.add_child(Arc::new(Spacer::new(1)));
1225    frame.add_child(extension_dialog_hint(
1226        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1227    ));
1228    frame.add_child(Arc::new(Spacer::new(1)));
1229    frame.add_child(Arc::new(DynamicBorder::new()));
1230
1231    let state = ctx.state.clone();
1232    let ec = ctx.editor_container.clone();
1233    let editor = ctx.editor.clone();
1234    let tui = ctx.tui.clone();
1235    let session_select = session.clone();
1236    let command_select = command_name.clone();
1237    let ctx_select = ctx.clone();
1238    list.on_select(Arc::new(move |item| {
1239        let args = serde_json::json!({ "action": "select", "value": item.value });
1240        let result = invoke_extension_command(
1241            &session_select,
1242            &command_select,
1243            &serde_json::to_string(&args).unwrap_or_default(),
1244        );
1245        close_selector(&state, &ec, &editor, &tui);
1246        handle_extension_ui_result(
1247            result,
1248            &ctx_select,
1249            session_select.clone(),
1250            command_select.clone(),
1251        );
1252    }));
1253    let state_cancel = ctx.state.clone();
1254    let ec_cancel = ctx.editor_container.clone();
1255    let editor_cancel = ctx.editor.clone();
1256    let tui_cancel = ctx.tui.clone();
1257    list.on_cancel(Arc::new(move || {
1258        if !run_extension_cancel(&state_cancel) {
1259            close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1260        }
1261    }));
1262    let state_cancel = ctx.state.clone();
1263    let ec_cancel = ctx.editor_container.clone();
1264    let editor_cancel = ctx.editor.clone();
1265    let tui_cancel = ctx.tui.clone();
1266    let session_cancel = session.clone();
1267    let command_cancel = command_name.clone();
1268    let ctx_cancel = ctx.clone();
1269    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1270        let args = serde_json::json!({ "action": "cancel" });
1271        let result = invoke_extension_command(
1272            &session_cancel,
1273            &command_cancel,
1274            &serde_json::to_string(&args).unwrap_or_default(),
1275        );
1276        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1277        handle_extension_ui_result(
1278            result,
1279            &ctx_cancel,
1280            session_cancel.clone(),
1281            command_cancel.clone(),
1282        );
1283    }));
1284    open_selector_with_view(
1285        &ctx.state,
1286        &ctx.editor_container,
1287        &ctx.editor,
1288        &ctx.tui,
1289        list,
1290        frame,
1291        SelectorKind::Extension,
1292    );
1293}
1294
1295fn open_extension_editor(
1296    ctx: &CommandContext,
1297    session: crate::session::ExtensionSessionCell,
1298    command_name: String,
1299    value: serde_json::Value,
1300) {
1301    let initial = value
1302        .get("initialText")
1303        .or_else(|| value.get("text"))
1304        .and_then(|v| v.as_str())
1305        .unwrap_or_default()
1306        .to_string();
1307    let title = value
1308        .get("title")
1309        .and_then(|v| v.as_str())
1310        .filter(|title| !title.is_empty())
1311        .unwrap_or("Editor");
1312    let editor = Arc::new(Editor::new(
1313        EditorOptions {
1314            padding_x: 1,
1315            autocomplete_max_visible: 0,
1316            placeholder: value
1317                .get("placeholder")
1318                .and_then(|v| v.as_str())
1319                .map(str::to_string),
1320            initial_text: Some(initial),
1321        },
1322        EditorStyle {
1323            prompt: "> ".to_string(),
1324            placeholder: String::new(),
1325        },
1326        Arc::new(rpi_tui::Keybindings::new()),
1327    ));
1328    editor.set_focused(true);
1329    let frame = Arc::new(Container::new());
1330    frame.add_child(Arc::new(DynamicBorder::new()));
1331    frame.add_child(Arc::new(Spacer::new(1)));
1332    frame.add_child(extension_dialog_title(title, false));
1333    frame.add_child(Arc::new(Spacer::new(1)));
1334    frame.add_child(editor.clone());
1335    frame.add_child(Arc::new(Spacer::new(1)));
1336    frame.add_child(extension_dialog_hint(
1337        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1338    ));
1339    frame.add_child(Arc::new(Spacer::new(1)));
1340    frame.add_child(Arc::new(DynamicBorder::new()));
1341
1342    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1343    *ctx.state.active_extension_input.lock().unwrap() = None;
1344    ctx.editor_container.clear();
1345    ctx.editor_container.add_child(frame);
1346
1347    let state = ctx.state.clone();
1348    let ec = ctx.editor_container.clone();
1349    let original = ctx.editor.clone();
1350    let tui = ctx.tui.clone();
1351    let session_submit = session.clone();
1352    let command_submit = command_name.clone();
1353    let ctx_submit = ctx.clone();
1354    editor.on_submit(Arc::new(move |text| {
1355        let args = serde_json::json!({ "action": "edit", "text": text });
1356        let result = invoke_extension_command(
1357            &session_submit,
1358            &command_submit,
1359            &serde_json::to_string(&args).unwrap_or_default(),
1360        );
1361        close_extension_editor(&state, &ec, &original, &tui);
1362        handle_extension_ui_result(
1363            result,
1364            &ctx_submit,
1365            session_submit.clone(),
1366            command_submit.clone(),
1367        );
1368    }));
1369
1370    let state_cancel = ctx.state.clone();
1371    let ec_cancel = ctx.editor_container.clone();
1372    let original_cancel = ctx.editor.clone();
1373    let tui_cancel = ctx.tui.clone();
1374    let session_cancel = session.clone();
1375    let command_cancel = command_name.clone();
1376    let ctx_cancel = ctx.clone();
1377    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1378        let args = serde_json::json!({ "action": "cancel" });
1379        let result = invoke_extension_command(
1380            &session_cancel,
1381            &command_cancel,
1382            &serde_json::to_string(&args).unwrap_or_default(),
1383        );
1384        close_extension_editor(&state_cancel, &ec_cancel, &original_cancel, &tui_cancel);
1385        handle_extension_ui_result(
1386            result,
1387            &ctx_cancel,
1388            session_cancel.clone(),
1389            command_cancel.clone(),
1390        );
1391    }));
1392
1393    ctx.tui.set_focus(Some(editor));
1394    ctx.tui.request_render(false);
1395}
1396
1397fn close_extension_editor(
1398    state: &Arc<TuiState>,
1399    editor_container: &Arc<Container>,
1400    editor: &Arc<Editor>,
1401    tui: &Arc<TuiAltScreen>,
1402) {
1403    editor_container.clear();
1404    editor_container.add_child(editor.clone());
1405    state.autocomplete_container.clear();
1406    *state.active_extension_editor.lock().unwrap() = None;
1407    *state.active_extension_input.lock().unwrap() = None;
1408    *state.active_extension_cancel.lock().unwrap() = None;
1409    editor.set_focused(true);
1410    tui.set_focus(Some(editor.clone()));
1411    tui.request_render(false);
1412}
1413
1414/// Open one Node `ctx.ui.*` request in the native editor slot. The Node host
1415/// waits on the runtime response while these callbacks resolve the bridge on
1416/// Enter, selection, or cancellation.
1417fn open_js_dialog(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1418    match request.method.as_str() {
1419        "select" => open_js_selector(ctx, bridge, request, false),
1420        "confirm" => open_js_selector(ctx, bridge, request, true),
1421        "input" => open_js_input(ctx, bridge, request),
1422        "editor" => open_js_editor(ctx, bridge, request),
1423        _ => {
1424            bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1425        }
1426    }
1427}
1428
1429fn open_js_selector(
1430    ctx: &CommandContext,
1431    bridge: Arc<JsDialogBridge>,
1432    request: JsDialogRequest,
1433    confirm: bool,
1434) {
1435    let values = if confirm {
1436        vec!["Yes".to_string(), "No".to_string()]
1437    } else {
1438        request.options.clone()
1439    };
1440    if values.is_empty() {
1441        bridge.respond(&request.id, serde_json::json!({ "cancelled": true }));
1442        return;
1443    }
1444    let items = values
1445        .iter()
1446        .map(|value| SelectItem::new(value, value))
1447        .collect::<Vec<_>>();
1448    let list = Arc::new(SelectList::new(items, 10));
1449    let frame = Arc::new(Container::new());
1450    frame.add_child(Arc::new(DynamicBorder::new()));
1451    frame.add_child(Arc::new(Spacer::new(1)));
1452    frame.add_child(extension_dialog_title(
1453        if request.title.is_empty() {
1454            if confirm {
1455                "Confirm"
1456            } else {
1457                "Select"
1458            }
1459        } else {
1460            request.title.as_str()
1461        },
1462        true,
1463    ));
1464    if !request.message.is_empty() {
1465        frame.add_child(Arc::new(Spacer::new(1)));
1466        frame.add_child(Arc::new(Text::new(request.message.clone(), 1, 0)));
1467    }
1468    frame.add_child(Arc::new(Spacer::new(1)));
1469    frame.add_child(list.clone());
1470    frame.add_child(Arc::new(Spacer::new(1)));
1471    frame.add_child(extension_dialog_hint(
1472        "↑↓ navigate · Enter select · Esc/Ctrl+C cancel",
1473    ));
1474    frame.add_child(Arc::new(Spacer::new(1)));
1475    frame.add_child(Arc::new(DynamicBorder::new()));
1476
1477    let id = request.id.clone();
1478    let bridge_select = bridge.clone();
1479    let state_select = ctx.state.clone();
1480    let ec_select = ctx.editor_container.clone();
1481    let editor_select = ctx.editor.clone();
1482    let tui_select = ctx.tui.clone();
1483    list.on_select(Arc::new(move |item| {
1484        let result = if confirm {
1485            serde_json::json!({ "confirmed": item.value == "Yes" })
1486        } else {
1487            serde_json::json!({ "value": item.value })
1488        };
1489        bridge_select.respond(&id, result);
1490        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1491    }));
1492
1493    let id_cancel = request.id.clone();
1494    let bridge_cancel = bridge.clone();
1495    let state_cancel = ctx.state.clone();
1496    let ec_cancel = ctx.editor_container.clone();
1497    let editor_cancel = ctx.editor.clone();
1498    let tui_cancel = ctx.tui.clone();
1499    list.on_cancel(Arc::new(move || {
1500        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1501        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1502    }));
1503
1504    let id_abort = request.id.clone();
1505    let bridge_abort = bridge.clone();
1506    let state_abort = ctx.state.clone();
1507    let ec_abort = ctx.editor_container.clone();
1508    let editor_abort = ctx.editor.clone();
1509    let tui_abort = ctx.tui.clone();
1510    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1511        bridge_abort.respond(&id_abort, serde_json::json!({ "cancelled": true }));
1512        close_selector(&state_abort, &ec_abort, &editor_abort, &tui_abort);
1513    }));
1514
1515    open_selector_with_view(
1516        &ctx.state,
1517        &ctx.editor_container,
1518        &ctx.editor,
1519        &ctx.tui,
1520        list,
1521        frame,
1522        SelectorKind::Extension,
1523    );
1524}
1525
1526fn open_js_input(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1527    let input = request
1528        .placeholder
1529        .as_deref()
1530        .map(Input::with_placeholder)
1531        .unwrap_or_default();
1532    let input = Arc::new(input);
1533    input.set_focused(true);
1534
1535    let frame = Arc::new(Container::new());
1536    frame.add_child(Arc::new(DynamicBorder::new()));
1537    frame.add_child(Arc::new(Spacer::new(1)));
1538    frame.add_child(extension_dialog_title(
1539        if request.title.is_empty() {
1540            "Input"
1541        } else {
1542            &request.title
1543        },
1544        false,
1545    ));
1546    frame.add_child(Arc::new(Spacer::new(1)));
1547    frame.add_child(input.clone());
1548    frame.add_child(Arc::new(Spacer::new(1)));
1549    frame.add_child(extension_dialog_hint("Enter submit · Esc/Ctrl+C cancel"));
1550    frame.add_child(Arc::new(Spacer::new(1)));
1551    frame.add_child(Arc::new(DynamicBorder::new()));
1552
1553    *ctx.state.active_extension_editor.lock().unwrap() = None;
1554    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
1555    ctx.editor_container.clear();
1556    ctx.editor_container.add_child(frame);
1557
1558    let id = request.id.clone();
1559    let bridge_submit = bridge.clone();
1560    let state_submit = ctx.state.clone();
1561    let ec_submit = ctx.editor_container.clone();
1562    let editor_submit = ctx.editor.clone();
1563    let tui_submit = ctx.tui.clone();
1564    input.on_submit(Arc::new(move |value| {
1565        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1566        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1567    }));
1568
1569    let id_cancel = request.id.clone();
1570    let bridge_cancel = bridge.clone();
1571    let state_cancel = ctx.state.clone();
1572    let ec_cancel = ctx.editor_container.clone();
1573    let editor_cancel = ctx.editor.clone();
1574    let tui_cancel = ctx.tui.clone();
1575    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1576        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1577        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1578    }));
1579    ctx.tui.set_focus(Some(input));
1580    ctx.tui.request_render(false);
1581}
1582
1583fn open_js_editor(ctx: &CommandContext, bridge: Arc<JsDialogBridge>, request: JsDialogRequest) {
1584    let editor = Arc::new(Editor::new(
1585        EditorOptions {
1586            padding_x: 1,
1587            autocomplete_max_visible: 0,
1588            initial_text: request.prefill.clone(),
1589            ..Default::default()
1590        },
1591        EditorStyle {
1592            prompt: "> ".to_string(),
1593            placeholder: String::new(),
1594        },
1595        Arc::new(rpi_tui::Keybindings::new()),
1596    ));
1597    editor.set_focused(true);
1598
1599    let frame = Arc::new(Container::new());
1600    frame.add_child(Arc::new(DynamicBorder::new()));
1601    frame.add_child(Arc::new(Spacer::new(1)));
1602    frame.add_child(extension_dialog_title(
1603        if request.title.is_empty() {
1604            "Editor"
1605        } else {
1606            &request.title
1607        },
1608        false,
1609    ));
1610    frame.add_child(Arc::new(Spacer::new(1)));
1611    frame.add_child(editor.clone());
1612    frame.add_child(Arc::new(Spacer::new(1)));
1613    frame.add_child(extension_dialog_hint(
1614        "Enter submit · Shift+Enter newline · Esc/Ctrl+C cancel",
1615    ));
1616    frame.add_child(Arc::new(Spacer::new(1)));
1617    frame.add_child(Arc::new(DynamicBorder::new()));
1618
1619    *ctx.state.active_extension_editor.lock().unwrap() = Some(editor.clone());
1620    *ctx.state.active_extension_input.lock().unwrap() = None;
1621    ctx.editor_container.clear();
1622    ctx.editor_container.add_child(frame);
1623
1624    let id = request.id.clone();
1625    let bridge_submit = bridge.clone();
1626    let state_submit = ctx.state.clone();
1627    let ec_submit = ctx.editor_container.clone();
1628    let editor_submit = ctx.editor.clone();
1629    let tui_submit = ctx.tui.clone();
1630    editor.on_submit(Arc::new(move |value| {
1631        bridge_submit.respond(&id, serde_json::json!({ "value": value }));
1632        close_extension_editor(&state_submit, &ec_submit, &editor_submit, &tui_submit);
1633    }));
1634
1635    let id_cancel = request.id.clone();
1636    let bridge_cancel = bridge.clone();
1637    let state_cancel = ctx.state.clone();
1638    let ec_cancel = ctx.editor_container.clone();
1639    let editor_cancel = ctx.editor.clone();
1640    let tui_cancel = ctx.tui.clone();
1641    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
1642        bridge_cancel.respond(&id_cancel, serde_json::json!({ "cancelled": true }));
1643        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
1644    }));
1645    ctx.tui.set_focus(Some(editor));
1646    ctx.tui.request_render(false);
1647}
1648
1649fn cancel_js_dialog_ui(ctx: &CommandContext, bridge: &Arc<JsDialogBridge>) {
1650    for id in bridge.cancelled_active_ids() {
1651        // Several commands can ask for a dialog concurrently. Only the id
1652        // currently occupying the TUI slot may close the visible component;
1653        // an older cancellation must leave a newer ask dialog untouched.
1654        if !bridge.is_visible(&id) {
1655            bridge.finish(&id);
1656            continue;
1657        }
1658        if let Some((selector, _)) = ctx.state.active_selector.lock().unwrap().clone() {
1659            selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
1660        } else if ctx.state.extension_dialog_open() {
1661            if !run_extension_cancel(&ctx.state) {
1662                close_extension_editor(&ctx.state, &ctx.editor_container, &ctx.editor, &ctx.tui);
1663            }
1664        }
1665        // `respond` normally removes the active entry from the callback. The
1666        // fallback path above can run before a callback was installed, so
1667        // always discard the id after routing the cancellation.
1668        bridge.finish(&id);
1669    }
1670}
1671
1672// ===========================================================================
1673// ask_user — plugin UI-dialog bridge (runtime action 17)
1674// ===========================================================================
1675
1676/// Sentinel option value that switches a freeform-capable selector into the
1677/// single-line input slot (native `allowFreeform` affordance).
1678const ASK_USER_FREEFORM: &str = "\u{0}ask-user-freeform";
1679
1680/// One in-flight `ask_user` request being shown in the TUI. Unlike
1681/// [`JsDialogBridge`], the plugin side is a synchronous `poll` loop that reads
1682/// answers back through the shared [`rpi_extensions::UiDialogMailbox`], so this
1683/// bridge only tracks which request id currently occupies the input slot.
1684#[derive(Clone)]
1685struct AskUserBridge {
1686    mailbox: rpi_extensions::UiDialogMailbox,
1687    visible: Arc<Mutex<Option<String>>>,
1688}
1689
1690impl AskUserBridge {
1691    fn new(mailbox: rpi_extensions::UiDialogMailbox) -> Self {
1692        Self {
1693            mailbox,
1694            visible: Arc::new(Mutex::new(None)),
1695        }
1696    }
1697
1698    /// Mark the interactive consumer as available. Idempotent.
1699    fn attach(&self) {
1700        self.mailbox.attach();
1701    }
1702
1703    /// Take the next pending request and mark it visible. Only the single key
1704    /// loop calls this while no other dialog is open, so the visible-id
1705    /// bookkeeping is race-free without a separate lock.
1706    fn take_pending(&self) -> Option<rpi_extensions::UiDialogRequest> {
1707        let mut visible = self.visible.lock().ok()?;
1708        if visible.is_some() {
1709            return None;
1710        }
1711        let request = self.mailbox.take_pending()?;
1712        *visible = Some(request.request_id.clone());
1713        Some(request)
1714    }
1715
1716    fn clear_visible(&self, id: &str) {
1717        if let Ok(mut visible) = self.visible.lock() {
1718            if visible.as_deref() == Some(id) {
1719                *visible = None;
1720            }
1721        }
1722    }
1723
1724    /// Record an answer for `id` and release the input slot.
1725    fn respond(&self, id: &str, answer: serde_json::Value) {
1726        let _ = self.mailbox.respond(id, answer);
1727        self.clear_visible(id);
1728    }
1729
1730    /// Cancel `id` and release the input slot.
1731    fn cancel(&self, id: &str) {
1732        let _ = self.mailbox.cancel(id);
1733        self.clear_visible(id);
1734    }
1735
1736    /// Cancel every outstanding request (run abort). Pending prompts from the
1737    /// aborted turn must not surface in a later turn.
1738    fn cancel_all(&self) {
1739        self.mailbox.cancel_all();
1740        if let Ok(mut visible) = self.visible.lock() {
1741            *visible = None;
1742        }
1743    }
1744
1745    /// Detach + cancel everything (TUI shutdown) so a parked plugin `poll`
1746    /// observes a terminal state instead of blocking on a dead session.
1747    fn shutdown(&self) {
1748        self.mailbox.detach();
1749        if let Ok(mut visible) = self.visible.lock() {
1750            *visible = None;
1751        }
1752    }
1753}
1754
1755/// Parsed presentation data for one `ask_user` question. Supports both the
1756/// flat native question contract and the earlier `questions[0]` alias, plus a
1757/// `confirm` summary.
1758#[derive(Clone)]
1759struct AskUserPrompt {
1760    question_id: String,
1761    question: String,
1762    header: Option<String>,
1763    context: Option<String>,
1764    options: Vec<(String, Option<String>)>,
1765    allow_multiple: bool,
1766    allow_freeform: bool,
1767    suggest: Option<String>,
1768    kind: String,
1769}
1770
1771fn ask_user_str(value: &serde_json::Value, keys: &[&str]) -> Option<String> {
1772    keys.iter().find_map(|key| {
1773        value
1774            .get(*key)
1775            .and_then(serde_json::Value::as_str)
1776            .map(str::trim)
1777            .filter(|text| !text.is_empty())
1778            .map(str::to_string)
1779    })
1780}
1781
1782fn ask_user_bool(value: &serde_json::Value, keys: &[&str]) -> Option<bool> {
1783    keys.iter()
1784        .find_map(|key| value.get(*key).and_then(serde_json::Value::as_bool))
1785}
1786
1787fn ask_user_options(value: &serde_json::Value) -> Vec<(String, Option<String>)> {
1788    let mut options = Vec::new();
1789    let Some(items) = value.get("options").and_then(serde_json::Value::as_array) else {
1790        return options;
1791    };
1792    for item in items {
1793        if let Some(title) = item.as_str() {
1794            let title = title.trim();
1795            if !title.is_empty() {
1796                options.push((title.to_string(), None));
1797            }
1798            continue;
1799        }
1800        let Some(object) = item.as_object() else { continue };
1801        let title = ["title", "label", "text", "value", "name"]
1802            .iter()
1803            .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))
1804            .map(str::trim)
1805            .filter(|title| !title.is_empty());
1806        let Some(title) = title else { continue };
1807        let description = object
1808            .get("description")
1809            .and_then(serde_json::Value::as_str)
1810            .map(str::trim)
1811            .filter(|text| !text.is_empty())
1812            .map(str::to_string);
1813        options.push((title.to_string(), description));
1814    }
1815    options
1816}
1817
1818fn parse_ask_user_prompt(request: &rpi_extensions::UiDialogRequest) -> AskUserPrompt {
1819    let ui = &request.ui;
1820    let kind = ui
1821        .get("kind")
1822        .and_then(serde_json::Value::as_str)
1823        .unwrap_or("selector")
1824        .to_string();
1825    // A request may carry a single flat question or a `questions[]` array. The
1826    // plugin emits one question per request, but accepting `questions[0]` keeps
1827    // the host compatible with hosts/plugins that batch.
1828    let nested = ui
1829        .get("questions")
1830        .and_then(serde_json::Value::as_array)
1831        .and_then(|items| items.first());
1832    let source = nested.filter(|value| value.is_object()).unwrap_or(ui);
1833
1834    let question = ask_user_str(source, &["question", "summary", "title"])
1835        .or_else(|| ask_user_str(ui, &["question", "summary", "title"]))
1836        .unwrap_or_default();
1837    let question_id = ask_user_str(source, &["id", "questionId"])
1838        .or_else(|| ask_user_str(ui, &["id", "questionId"]))
1839        .unwrap_or_else(|| request.request_id.clone());
1840    let header = ask_user_str(source, &["header"]).or_else(|| ask_user_str(ui, &["header"]));
1841    let context = ask_user_str(source, &["context", "message"])
1842        .or_else(|| ask_user_str(ui, &["context", "message"]));
1843    let mut options = ask_user_options(source);
1844    if options.is_empty() {
1845        options = ask_user_options(ui);
1846    }
1847    if kind == "confirm" && options.is_empty() {
1848        options = vec![
1849            ("Yes".to_string(), None),
1850            ("No".to_string(), None),
1851        ];
1852    }
1853    let allow_multiple = ask_user_bool(source, &["allowMultiple", "allow_multiple", "multiple"])
1854        .or_else(|| ask_user_bool(ui, &["allowMultiple", "allow_multiple", "multiple"]))
1855        .unwrap_or(false);
1856    // Freeform defaults on when there is nothing to pick from, matching the
1857    // "no options ⇒ free input" contract.
1858    let allow_freeform = ask_user_bool(
1859        source,
1860        &["allowFreeform", "allow_freeform", "allow_custom"],
1861    )
1862    .or_else(|| {
1863        ask_user_bool(
1864            ui,
1865            &["allowFreeform", "allow_freeform", "allow_custom"],
1866        )
1867    })
1868    .unwrap_or(options.is_empty());
1869    let suggest = ask_user_str(source, &["suggest", "placeholder"])
1870        .or_else(|| ask_user_str(ui, &["suggest", "placeholder"]));
1871
1872    AskUserPrompt {
1873        question_id,
1874        question,
1875        header,
1876        context,
1877        options,
1878        allow_multiple,
1879        allow_freeform,
1880        suggest,
1881        kind,
1882    }
1883}
1884
1885/// Build the shared header frame for an ask-user prompt.
1886/// Uses markdown-aware rendering for question and context content.
1887fn ask_user_frame(
1888    prompt: &AskUserPrompt,
1889    body: Arc<dyn Component>,
1890    hint: &str,
1891) -> Arc<Container> {
1892    let frame = Arc::new(Container::new());
1893    frame.add_child(Arc::new(DynamicBorder::new()));
1894    frame.add_child(Arc::new(Spacer::new(1)));
1895    if let Some(header) = prompt.header.as_deref() {
1896        frame.add_child(extension_dialog_title_md(header, true));
1897    }
1898    if !prompt.question.trim().is_empty() {
1899        frame.add_child(extension_dialog_title_md(
1900            &prompt.question,
1901            prompt.header.is_none(),
1902        ));
1903    }
1904    if let Some(context) = prompt.context.as_deref() {
1905        frame.add_child(extension_dialog_hint_md(context));
1906    }
1907    frame.add_child(Arc::new(Spacer::new(1)));
1908    frame.add_child(body);
1909    frame.add_child(Arc::new(Spacer::new(1)));
1910    frame.add_child(extension_dialog_hint(hint));
1911    frame.add_child(Arc::new(Spacer::new(1)));
1912    frame.add_child(Arc::new(DynamicBorder::new()));
1913    frame
1914}
1915
1916/// Open one `ask_user` request in the TUI input slot.
1917fn open_ask_user_dialog(
1918    ctx: &CommandContext,
1919    bridge: AskUserBridge,
1920    request: rpi_extensions::UiDialogRequest,
1921) {
1922    let prompt = parse_ask_user_prompt(&request);
1923    if prompt.kind == "input" || prompt.options.is_empty() {
1924        open_ask_user_input(ctx, bridge, request, prompt);
1925    } else {
1926        open_ask_user_selector(ctx, bridge, request, prompt);
1927    }
1928}
1929
1930fn open_ask_user_selector(
1931    ctx: &CommandContext,
1932    bridge: AskUserBridge,
1933    request: rpi_extensions::UiDialogRequest,
1934    prompt: AskUserPrompt,
1935) {
1936    let multi = prompt.allow_multiple && prompt.kind != "confirm";
1937    let mut items: Vec<SelectItem> = Vec::new();
1938    for (title, description) in &prompt.options {
1939        let item = SelectItem::new(title, title);
1940        let item = match description.as_deref() {
1941            Some(description) => item.with_description(description),
1942            None => item,
1943        };
1944        items.push(item);
1945    }
1946    // A freeform-capable single selector gets an extra sentinel row that opens
1947    // the input slot for a typed answer.
1948    if prompt.allow_freeform && !multi && prompt.kind != "confirm" {
1949        items.push(SelectItem::new(ASK_USER_FREEFORM, "Type another answer…"));
1950    }
1951    let list = if multi {
1952        Arc::new(SelectList::new_multi(items, 10))
1953    } else {
1954        Arc::new(SelectList::new(items, 10))
1955    };
1956
1957    let request_id = request.request_id.clone();
1958    let state = ctx.state.clone();
1959    let editor_container = ctx.editor_container.clone();
1960    let editor = ctx.editor.clone();
1961    let tui = ctx.tui.clone();
1962
1963    // Single-select: respond with the chosen title, or fall through to the
1964    // input slot for the freeform sentinel.
1965    let bridge_select = bridge.clone();
1966    let request_for_freeform = request.clone();
1967    let prompt_for_freeform = AskUserPrompt {
1968        question_id: prompt.question_id.clone(),
1969        question: prompt.question.clone(),
1970        header: prompt.header.clone(),
1971        context: prompt.context.clone(),
1972        options: Vec::new(),
1973        allow_multiple: false,
1974        allow_freeform: true,
1975        suggest: prompt.suggest.clone(),
1976        kind: "input".to_string(),
1977    };
1978    let state_select = state.clone();
1979    let ec_select = editor_container.clone();
1980    let editor_select = editor.clone();
1981    let tui_select = tui.clone();
1982    let ctx_select = ctx.clone();
1983    list.on_select(Arc::new(move |item: &SelectItem| {
1984        if item.value == ASK_USER_FREEFORM {
1985            close_selector(&state_select, &ec_select, &editor_select, &tui_select);
1986            open_ask_user_input(
1987                &ctx_select,
1988                bridge_select.clone(),
1989                request_for_freeform.clone(),
1990                prompt_for_freeform.clone(),
1991            );
1992            return;
1993        }
1994        bridge_select.respond(
1995            &request_id,
1996            serde_json::json!({
1997                "values": [item.display_value()],
1998                "kind": "selector",
1999            }),
2000        );
2001        close_selector(&state_select, &ec_select, &editor_select, &tui_select);
2002    }));
2003
2004    if multi {
2005        let bridge_multi = bridge.clone();
2006        let request_id_multi = request.request_id.clone();
2007        let state_multi = state.clone();
2008        let ec_multi = editor_container.clone();
2009        let editor_multi = editor.clone();
2010        let tui_multi = tui.clone();
2011        list.on_multi_select(Arc::new(move |selected: &[SelectItem]| {
2012            let values: Vec<String> = selected
2013                .iter()
2014                .map(|item| item.display_value().to_string())
2015                .collect();
2016            bridge_multi.respond(
2017                &request_id_multi,
2018                serde_json::json!({"values": values, "kind": "selector"}),
2019            );
2020            close_selector(&state_multi, &ec_multi, &editor_multi, &tui_multi);
2021        }));
2022    }
2023
2024    let bridge_cancel = bridge.clone();
2025    let request_id_cancel = request.request_id.clone();
2026    let state_cancel = state.clone();
2027    let ec_cancel = editor_container.clone();
2028    let editor_cancel = editor.clone();
2029    let tui_cancel = tui.clone();
2030    list.on_cancel(Arc::new(move || {
2031        bridge_cancel.cancel(&request_id_cancel);
2032        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2033    }));
2034
2035    let hint = if multi {
2036        "Space toggle · Enter submit · Esc cancel"
2037    } else {
2038        "Enter select · Esc cancel"
2039    };
2040    let frame = ask_user_frame(&prompt, list.clone(), hint);
2041    open_selector_with_view(
2042        &state,
2043        &editor_container,
2044        &editor,
2045        &tui,
2046        list,
2047        frame,
2048        SelectorKind::Extension,
2049    );
2050}
2051
2052fn open_ask_user_input(
2053    ctx: &CommandContext,
2054    bridge: AskUserBridge,
2055    request: rpi_extensions::UiDialogRequest,
2056    prompt: AskUserPrompt,
2057) {
2058    let placeholder = prompt
2059        .suggest
2060        .clone()
2061        .unwrap_or_else(|| "Type your answer…".to_string());
2062    let input = Arc::new(Input::with_placeholder(&placeholder));
2063    input.set_focused(true);
2064
2065    let frame = ask_user_frame(&prompt, input.clone(), "Enter submit · Esc cancel");
2066    *ctx.state.active_extension_editor.lock().unwrap() = None;
2067    *ctx.state.active_extension_input.lock().unwrap() = Some(input.clone());
2068    ctx.editor_container.clear();
2069    ctx.editor_container.add_child(frame);
2070    ctx.tui.set_focus(Some(input.clone()));
2071    ctx.tui.request_render(false);
2072
2073    let state = ctx.state.clone();
2074    let ec = ctx.editor_container.clone();
2075    let editor = ctx.editor.clone();
2076    let tui = ctx.tui.clone();
2077    let bridge_submit = bridge.clone();
2078    let request_id = request.request_id.clone();
2079    input.on_submit(Arc::new(move |text: &str| {
2080        bridge_submit.respond(
2081            &request_id,
2082            serde_json::json!({
2083                "values": [text],
2084                "value": text,
2085                "text": text,
2086                "kind": "input",
2087            }),
2088        );
2089        close_extension_editor(&state, &ec, &editor, &tui);
2090    }));
2091
2092    let state_cancel = ctx.state.clone();
2093    let ec_cancel = ctx.editor_container.clone();
2094    let editor_cancel = ctx.editor.clone();
2095    let tui_cancel = ctx.tui.clone();
2096    let bridge_cancel = bridge.clone();
2097    let request_id_cancel = request.request_id.clone();
2098    *ctx.state.active_extension_cancel.lock().unwrap() = Some(Arc::new(move || {
2099        bridge_cancel.cancel(&request_id_cancel);
2100        close_extension_editor(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
2101    }));
2102}
2103
2104// ---- Built-in command implementations ----
2105
2106struct HelpCommand;
2107impl SlashCommand for HelpCommand {
2108    fn name(&self) -> &'static str {
2109        "/help"
2110    }
2111    fn aliases(&self) -> &'static [&'static str] {
2112        &["/?"]
2113    }
2114    fn description(&self) -> &'static str {
2115        "Show available commands"
2116    }
2117    fn execute(&self, ctx: &CommandContext, _args: &str) {
2118        add_help_message(&ctx.chat);
2119        ctx.tui.request_render(false);
2120    }
2121}
2122
2123struct ClearChatCommand;
2124impl SlashCommand for ClearChatCommand {
2125    fn name(&self) -> &'static str {
2126        "/clear"
2127    }
2128    fn aliases(&self) -> &'static [&'static str] {
2129        &["/new"]
2130    }
2131    // `/new` carries its own weight as a discoverable entry, so surface it.
2132    fn alias_visible(&self) -> &'static [&'static str] {
2133        &["/new"]
2134    }
2135    fn description(&self) -> &'static str {
2136        "Clear the conversation"
2137    }
2138    fn execute(&self, ctx: &CommandContext, _args: &str) {
2139        let _ = ctx.tx.send(TuiMessage::ClearChat);
2140    }
2141}
2142
2143struct ExitCommand;
2144impl SlashCommand for ExitCommand {
2145    fn name(&self) -> &'static str {
2146        "/exit"
2147    }
2148    fn aliases(&self) -> &'static [&'static str] {
2149        &["/quit", "/q"]
2150    }
2151    // `/quit` is surfaced (matches pi's BUILTIN list); `/q` stays a hidden alias.
2152    fn alias_visible(&self) -> &'static [&'static str] {
2153        &["/quit"]
2154    }
2155    fn description(&self) -> &'static str {
2156        "Exit the application"
2157    }
2158    fn execute(&self, ctx: &CommandContext, _args: &str) {
2159        ctx.state.cancel_js_preparation();
2160        let _ = ctx.tx.send(TuiMessage::Exit);
2161    }
2162}
2163
2164struct VersionCommand;
2165impl SlashCommand for VersionCommand {
2166    fn name(&self) -> &'static str {
2167        "/version"
2168    }
2169    fn aliases(&self) -> &'static [&'static str] {
2170        &["/v"]
2171    }
2172    fn description(&self) -> &'static str {
2173        "Show version information"
2174    }
2175    fn execute(&self, ctx: &CommandContext, _args: &str) {
2176        add_version_message(&ctx.chat);
2177        ctx.tui.request_render(false);
2178    }
2179}
2180
2181struct ChangelogCommand;
2182impl SlashCommand for ChangelogCommand {
2183    fn name(&self) -> &'static str {
2184        "/changelog"
2185    }
2186    fn description(&self) -> &'static str {
2187        "Show recent release changes"
2188    }
2189    fn execute(&self, ctx: &CommandContext, _args: &str) {
2190        add_changelog_message(&ctx.chat);
2191        ctx.tui.request_render(false);
2192    }
2193}
2194
2195struct HotkeysCommand;
2196impl SlashCommand for HotkeysCommand {
2197    fn name(&self) -> &'static str {
2198        "/hotkeys"
2199    }
2200    fn description(&self) -> &'static str {
2201        "Show keyboard shortcuts"
2202    }
2203    fn execute(&self, ctx: &CommandContext, _args: &str) {
2204        add_hotkeys_message(&ctx.chat);
2205        ctx.tui.request_render(false);
2206    }
2207}
2208
2209struct ModelCommand;
2210impl SlashCommand for ModelCommand {
2211    fn name(&self) -> &'static str {
2212        "/model"
2213    }
2214    fn aliases(&self) -> &'static [&'static str] {
2215        &["/m"]
2216    }
2217    fn description(&self) -> &'static str {
2218        "Choose a model (selector)"
2219    }
2220    fn execute(&self, ctx: &CommandContext, args: &str) {
2221        let term = args.trim();
2222        if !term.is_empty() {
2223            // /model <name> — direct switch by id (pi handleModelCommand).
2224            let Some(model) = find_model_selector_match(&ctx.model_catalog, term) else {
2225                add_error_message(
2226                    &ctx.chat,
2227                    &format!("No model matches \"{term}\". Try /model for the list."),
2228                );
2229                ctx.tui.request_render(false);
2230                return;
2231            };
2232            let model_id = model.id.clone();
2233            ctx.state.set_current_model(&model);
2234            let lane = ctx.lane.clone();
2235            tokio::spawn(async move {
2236                let _ = lane.set_model(model).await;
2237            });
2238            add_note_message(
2239                &ctx.chat,
2240                &format!(
2241                    "Model set to {} — applies to the next message.",
2242                    short_model_name(&model_id)
2243                ),
2244            );
2245            ctx.tui.request_render(false);
2246            return;
2247        }
2248        open_model_selector(
2249            &ctx.state,
2250            &ctx.editor_container,
2251            &ctx.editor,
2252            &ctx.tui,
2253            &ctx.model_catalog,
2254            &ctx.lane,
2255            &ctx.lane_model_id,
2256            &ctx.chat,
2257        );
2258    }
2259}
2260
2261struct ThinkingCommand;
2262impl SlashCommand for ThinkingCommand {
2263    fn name(&self) -> &'static str {
2264        "/thinking"
2265    }
2266    fn aliases(&self) -> &'static [&'static str] {
2267        &["/think"]
2268    }
2269    fn description(&self) -> &'static str {
2270        "Set thinking level (selector)"
2271    }
2272    fn execute(&self, ctx: &CommandContext, args: &str) {
2273        let level_name = args.trim();
2274        if !level_name.is_empty() {
2275            // /thinking <level> — direct set (pi supports the param form).
2276            let Some(level) = thinking_level_from_name(level_name) else {
2277                add_error_message(
2278                    &ctx.chat,
2279                    &format!(
2280                        "Unknown thinking level \"{level_name}\". Valid: {}",
2281                        crate::args::VALID_THINKING_LEVELS.join(", ")
2282                    ),
2283                );
2284                ctx.tui.request_render(false);
2285                return;
2286            };
2287            let lane = ctx.lane.clone();
2288            let footer = ctx.state.footer.clone();
2289            tokio::spawn(async move {
2290                let _ = lane.set_thinking_level(level).await;
2291            });
2292            footer.set_thinking_level(Some(thinking_level_name(level)));
2293            add_note_message(&ctx.chat, &format!("Thinking set to {level_name}."));
2294            ctx.tui.request_render(false);
2295            return;
2296        }
2297        open_thinking_selector(
2298            &ctx.state,
2299            &ctx.editor_container,
2300            &ctx.editor,
2301            &ctx.tui,
2302            &ctx.lane,
2303            &ctx.model_catalog,
2304            &ctx.lane_model_id,
2305            &ctx.chat,
2306        );
2307    }
2308}
2309
2310struct ToolsCommand;
2311impl SlashCommand for ToolsCommand {
2312    fn name(&self) -> &'static str {
2313        "/tools"
2314    }
2315    fn description(&self) -> &'static str {
2316        "Toggle tools on/off"
2317    }
2318    fn execute(&self, ctx: &CommandContext, _args: &str) {
2319        open_tools_selector(
2320            &ctx.state,
2321            &ctx.editor_container,
2322            &ctx.editor,
2323            &ctx.tui,
2324            &ctx.lane,
2325            &ctx.chat,
2326        );
2327    }
2328}
2329
2330struct ImagesCommand;
2331impl SlashCommand for ImagesCommand {
2332    fn name(&self) -> &'static str {
2333        "/images"
2334    }
2335    fn description(&self) -> &'static str {
2336        "Toggle inline images"
2337    }
2338    fn execute(&self, ctx: &CommandContext, _args: &str) {
2339        open_images_selector(
2340            &ctx.state,
2341            &ctx.editor_container,
2342            &ctx.editor,
2343            &ctx.tui,
2344            &ctx.chat,
2345        );
2346    }
2347}
2348
2349struct SessionCommand;
2350impl SlashCommand for SessionCommand {
2351    fn name(&self) -> &'static str {
2352        "/session"
2353    }
2354    fn aliases(&self) -> &'static [&'static str] {
2355        &["/resume"]
2356    }
2357    fn description(&self) -> &'static str {
2358        "List saved sessions"
2359    }
2360    fn execute(&self, ctx: &CommandContext, _args: &str) {
2361        open_session_selector(
2362            &ctx.state,
2363            &ctx.editor_container,
2364            &ctx.editor,
2365            &ctx.tui,
2366            &ctx.cwd,
2367            &ctx.tx,
2368        );
2369    }
2370}
2371
2372struct ThemeCommand;
2373impl SlashCommand for ThemeCommand {
2374    fn name(&self) -> &'static str {
2375        "/theme"
2376    }
2377    fn description(&self) -> &'static str {
2378        "Choose a theme (selector)"
2379    }
2380    fn execute(&self, ctx: &CommandContext, args: &str) {
2381        let name = args.trim().to_ascii_lowercase();
2382        if !name.is_empty() {
2383            // /theme <name> — direct apply + persist (matches /settings Theme).
2384            let preset = match name.as_str() {
2385                "light" => ThemePreset::Light,
2386                "monochrome" => ThemePreset::Monochrome,
2387                "dark" => ThemePreset::Dark,
2388                _ => {
2389                    add_error_message(
2390                        &ctx.chat,
2391                        &format!("Unknown theme \"{name}\". Valid: dark, light, monochrome."),
2392                    );
2393                    ctx.tui.request_render(false);
2394                    return;
2395                }
2396            };
2397            apply_theme_preset(preset);
2398            let mut settings = crate::settings::load_settings().unwrap_or_default();
2399            settings.theme = Some(name.clone());
2400            let _ = crate::settings::save_settings(&settings);
2401            add_note_message(&ctx.chat, &format!("Theme set to {name} (saved)."));
2402            ctx.tui.request_render(false);
2403            ctx.tui.render_now(true);
2404            return;
2405        }
2406        open_theme_selector(
2407            &ctx.state,
2408            &ctx.editor_container,
2409            &ctx.editor,
2410            &ctx.tui,
2411            &ctx.cwd,
2412            &ctx.package_resources,
2413        );
2414    }
2415}
2416
2417struct CompactCommand;
2418impl SlashCommand for CompactCommand {
2419    fn name(&self) -> &'static str {
2420        "/compact"
2421    }
2422    fn description(&self) -> &'static str {
2423        "Compact the conversation"
2424    }
2425    fn execute(&self, ctx: &CommandContext, _args: &str) {
2426        let _ = ctx.tx.send(TuiMessage::Compact);
2427    }
2428}
2429
2430struct CopyCommand;
2431impl SlashCommand for CopyCommand {
2432    fn name(&self) -> &'static str {
2433        "/copy"
2434    }
2435    fn description(&self) -> &'static str {
2436        "Copy last reply to clipboard"
2437    }
2438    fn execute(&self, ctx: &CommandContext, _args: &str) {
2439        let _ = ctx.tx.send(TuiMessage::Copy);
2440    }
2441}
2442
2443struct ExportCommand;
2444impl SlashCommand for ExportCommand {
2445    fn name(&self) -> &'static str {
2446        "/export"
2447    }
2448    fn description(&self) -> &'static str {
2449        "Export session (supports: md, html, jsonl)"
2450    }
2451    fn execute(&self, ctx: &CommandContext, args: &str) {
2452        let format = args.trim().to_lowercase();
2453        if format.is_empty() || format == "md" || format == "markdown" {
2454            let _ = ctx.tx.send(TuiMessage::ExportSession);
2455        } else if format == "html" {
2456            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(crate::export::ExportFormat::Html));
2457        } else if format == "jsonl" {
2458            let _ = ctx.tx.send(TuiMessage::ExportSessionWithFormat(crate::export::ExportFormat::Jsonl));
2459        } else {
2460            // Invalid format, show error
2461            add_error_message(&ctx.chat, &format!("Unknown export format: {}. Supported: md, html, jsonl", args.trim()));
2462            ctx.tui.request_render(false);
2463        }
2464    }
2465}
2466
2467struct ForkCommand;
2468impl SlashCommand for ForkCommand {
2469    fn name(&self) -> &'static str {
2470        "/fork"
2471    }
2472    fn description(&self) -> &'static str {
2473        "Fork the session into a new one"
2474    }
2475    fn execute(&self, ctx: &CommandContext, _args: &str) {
2476        let _ = ctx.tx.send(TuiMessage::ForkSession);
2477    }
2478}
2479
2480/// `/clone` is the native Pi spelling for duplicating the current session.
2481/// Reuse the same durable fork path as `/fork`; both create a child session
2482/// and rebind the live harness to it.
2483struct CloneCommand;
2484impl SlashCommand for CloneCommand {
2485    fn name(&self) -> &'static str {
2486        "/clone"
2487    }
2488    fn description(&self) -> &'static str {
2489        "Duplicate the current session"
2490    }
2491    fn execute(&self, ctx: &CommandContext, _args: &str) {
2492        let _ = ctx.tx.send(TuiMessage::ForkSession);
2493    }
2494}
2495
2496struct TreeCommand;
2497impl SlashCommand for TreeCommand {
2498    fn name(&self) -> &'static str {
2499        "/tree"
2500    }
2501    fn description(&self) -> &'static str {
2502        "Navigate the current session tree"
2503    }
2504    fn execute(&self, ctx: &CommandContext, _args: &str) {
2505        let _ = ctx.tx.send(TuiMessage::OpenTree);
2506    }
2507}
2508
2509struct LoginCommand;
2510impl SlashCommand for LoginCommand {
2511    fn name(&self) -> &'static str {
2512        "/login"
2513    }
2514    fn description(&self) -> &'static str {
2515        "Save an Anthropic API key"
2516    }
2517    fn execute(&self, ctx: &CommandContext, args: &str) {
2518        let key = args.trim();
2519        if key.is_empty() {
2520            add_note_message(&ctx.chat, "Usage: /login <api-key>");
2521        } else {
2522            let result = crate::config::upsert_credential(
2523                "anthropic",
2524                crate::config::Credential::ApiKey {
2525                    key: Some(key.to_string()),
2526                    env: None,
2527                },
2528            );
2529            match result {
2530                Ok(()) => add_note_message(&ctx.chat, "Saved Anthropic credentials."),
2531                Err(error) => {
2532                    add_error_message(&ctx.chat, &format!("Could not save credentials: {error}"))
2533                }
2534            }
2535        }
2536        ctx.tui.request_render(false);
2537    }
2538}
2539
2540struct LogoutCommand;
2541impl SlashCommand for LogoutCommand {
2542    fn name(&self) -> &'static str {
2543        "/logout"
2544    }
2545    fn description(&self) -> &'static str {
2546        "Remove saved Anthropic credentials"
2547    }
2548    fn execute(&self, ctx: &CommandContext, _args: &str) {
2549        match crate::config::delete_credential("anthropic") {
2550            Ok(true) => add_note_message(&ctx.chat, "Removed saved Anthropic credentials."),
2551            Ok(false) => add_note_message(&ctx.chat, "No saved Anthropic credentials found."),
2552            Err(error) => {
2553                add_error_message(&ctx.chat, &format!("Could not remove credentials: {error}"))
2554            }
2555        }
2556        ctx.tui.request_render(false);
2557    }
2558}
2559
2560fn set_project_trust_for_command(
2561    cwd: &std::path::Path,
2562    value: Option<bool>,
2563) -> Result<(), crate::config::ConfigError> {
2564    crate::config::set_project_trust(cwd, value)
2565}
2566
2567struct TrustCommand;
2568impl SlashCommand for TrustCommand {
2569    fn name(&self) -> &'static str {
2570        "/trust"
2571    }
2572    fn description(&self) -> &'static str {
2573        "Trust the current project"
2574    }
2575    fn execute(&self, ctx: &CommandContext, args: &str) {
2576        let value = match args.trim().to_ascii_lowercase().as_str() {
2577            "" | "yes" | "y" | "true" => Some(true),
2578            "no" | "n" | "false" => Some(false),
2579            "clear" | "reset" | "none" => None,
2580            _ => {
2581                add_note_message(&ctx.chat, "Usage: /trust [yes|no|clear]");
2582                ctx.tui.request_render(false);
2583                return;
2584            }
2585        };
2586        match set_project_trust_for_command(&ctx.cwd, value) {
2587            Ok(()) => {
2588                let label = match value {
2589                    Some(true) => "trusted",
2590                    Some(false) => "untrusted",
2591                    None => "trust decision cleared",
2592                };
2593                add_note_message(&ctx.chat, &format!("Current project marked {label}."));
2594            }
2595            Err(error) => add_error_message(
2596                &ctx.chat,
2597                &format!("Could not save trust decision: {error}"),
2598            ),
2599        }
2600        ctx.tui.request_render(false);
2601    }
2602}
2603
2604struct NameCommand;
2605impl SlashCommand for NameCommand {
2606    fn name(&self) -> &'static str {
2607        "/name"
2608    }
2609    fn description(&self) -> &'static str {
2610        "Set session display name"
2611    }
2612    fn execute(&self, ctx: &CommandContext, args: &str) {
2613        let name = args.trim();
2614        if name.is_empty() {
2615            add_note_message(
2616                &ctx.chat,
2617                "Usage: /name <display name> — sets the current session's name.",
2618            );
2619            ctx.tui.request_render(false);
2620            return;
2621        }
2622        let _ = ctx.tx.send(TuiMessage::SetSessionName(name.to_string()));
2623    }
2624}
2625
2626struct ImportCommand;
2627impl SlashCommand for ImportCommand {
2628    fn name(&self) -> &'static str {
2629        "/import"
2630    }
2631    fn description(&self) -> &'static str {
2632        "Import a session file (path)"
2633    }
2634    fn execute(&self, ctx: &CommandContext, args: &str) {
2635        let path = args.trim();
2636        if path.is_empty() {
2637            add_note_message(
2638                &ctx.chat,
2639                "Usage: /import <path-to-session.jsonl> — copies the file into the session dir and switches to it.",
2640            );
2641            ctx.tui.request_render(false);
2642            return;
2643        }
2644        let _ = ctx.tx.send(TuiMessage::ImportSession(path.to_string()));
2645    }
2646}
2647
2648struct SettingsCommand;
2649impl SlashCommand for SettingsCommand {
2650    fn name(&self) -> &'static str {
2651        "/settings"
2652    }
2653    fn description(&self) -> &'static str {
2654        "Open settings menu"
2655    }
2656    fn execute(&self, ctx: &CommandContext, _args: &str) {
2657        open_settings_selector(
2658            &ctx.state,
2659            &ctx.editor_container,
2660            &ctx.editor,
2661            &ctx.tui,
2662            &ctx.lane,
2663            &ctx.model_catalog,
2664            &ctx.lane_model_id,
2665            &ctx.chat,
2666            &ctx.cwd,
2667            &ctx.package_resources,
2668        );
2669    }
2670}
2671
2672struct ScopedModelsCommand;
2673impl SlashCommand for ScopedModelsCommand {
2674    fn name(&self) -> &'static str {
2675        "/scoped-models"
2676    }
2677    fn description(&self) -> &'static str {
2678        "Choose models for Ctrl+M cycling"
2679    }
2680    fn execute(&self, ctx: &CommandContext, _args: &str) {
2681        open_scoped_models_selector(
2682            &ctx.state,
2683            &ctx.editor_container,
2684            &ctx.editor,
2685            &ctx.tui,
2686            &ctx.model_catalog,
2687            &ctx.chat,
2688        );
2689    }
2690}
2691
2692struct ShareCommand;
2693impl SlashCommand for ShareCommand {
2694    fn name(&self) -> &'static str {
2695        "/share"
2696    }
2697    fn description(&self) -> &'static str {
2698        "Share session (gist via gh, or clipboard)"
2699    }
2700    fn execute(&self, ctx: &CommandContext, _args: &str) {
2701        let _ = ctx.tx.send(TuiMessage::ShareSession);
2702    }
2703}
2704
2705struct ArminCommand;
2706impl SlashCommand for ArminCommand {
2707    fn name(&self) -> &'static str {
2708        "/armin"
2709    }
2710    fn description(&self) -> &'static str {
2711        "??? (easter egg)"
2712    }
2713    fn execute(&self, ctx: &CommandContext, _args: &str) {
2714        crate::extras::add_armin(&ctx.chat);
2715        ctx.tui.request_render(false);
2716    }
2717}
2718
2719struct EarendilCommand;
2720impl SlashCommand for EarendilCommand {
2721    fn name(&self) -> &'static str {
2722        "/earendil"
2723    }
2724    fn description(&self) -> &'static str {
2725        "Announcement"
2726    }
2727    fn execute(&self, ctx: &CommandContext, _args: &str) {
2728        crate::extras::add_earendil(&ctx.chat);
2729        ctx.tui.request_render(false);
2730    }
2731}
2732
2733/// `/context` — lists discovered context files, skills, and prompt templates.
2734/// Hidden from autocomplete (needs the resources snapshot to be meaningful as a
2735/// discovery surface; like `/name`, it's recognized-v1 but kept off the list).
2736struct ContextCommand;
2737impl SlashCommand for ContextCommand {
2738    fn name(&self) -> &'static str {
2739        "/context"
2740    }
2741    fn visible(&self) -> bool {
2742        false
2743    }
2744    fn execute(&self, ctx: &CommandContext, _args: &str) {
2745        show_context_panel(&ctx.chat, &ctx.resources);
2746        // Live session stats are async, so ask the main loop to append them.
2747        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2748        ctx.tui.request_render(false);
2749    }
2750}
2751
2752/// `/usage` — token/cost/cache totals read through the product-layer
2753/// `AgentSession`. This is the same numbers the footer shows, but expanded and
2754/// with the cache-hit breakdown that the one-line footer cannot fit.
2755struct UsageCommand;
2756impl SlashCommand for UsageCommand {
2757    fn name(&self) -> &'static str {
2758        "/usage"
2759    }
2760    fn description(&self) -> &'static str {
2761        "Show token, cost, and cache totals for this session"
2762    }
2763    fn execute(&self, ctx: &CommandContext, _args: &str) {
2764        let _ = ctx.tx.send(TuiMessage::ShowUsage);
2765        ctx.tui.request_render(false);
2766    }
2767}
2768
2769/// `/reload` — re-run extension + resource discovery into the LIVE harness
2770/// (B5d): reload the cdylib plugins, invalidate the old `ActionBridge` +
2771/// registry snapshot, rebuild skills/prompts/context/SYSTEM.md/APPEND_SYSTEM.md
2772/// + the `TeeEmitter`, and push the rebuilt state via the B5d harness setters.
2773/// The command itself runs on the blocking submit thread, so it can't drive
2774/// the async `reload_extension_resources` routine directly — it signals the main
2775/// loop via `TuiMessage::ReloadExtensions`, which awaits it on the async runtime.
2776/// (A plugin's `runtime_action(Reload)` signals the same loop via the
2777/// `ReloadMailbox` the TUI installs — the B5d async-reload design avoids the
2778/// self-unmapping race a synchronous plugin-initiated reload would have.)
2779struct ReloadCommand;
2780impl SlashCommand for ReloadCommand {
2781    fn name(&self) -> &'static str {
2782        "/reload"
2783    }
2784    fn description(&self) -> &'static str {
2785        "Reload extensions, skills, prompts"
2786    }
2787    fn execute(&self, ctx: &CommandContext, _args: &str) {
2788        // Signal the main loop. It owns the `&AgentHarness` borrow the
2789        // `reload_extension_resources` routine needs (the blocking submit thread
2790        // only has the context's `Arc<ReloadContext>` + the `Arc<dyn AgentLane>`).
2791        add_note_message(&ctx.chat, "Reloading extensions + resources…");
2792        ctx.tui.request_render(false);
2793        let _ = ctx.tx.send(TuiMessage::ReloadExtensions);
2794    }
2795}
2796
2797/// Build the full command registry: active built-ins first (so they win on a
2798/// fuzzy autocomplete tie), then the v1-out-of-scope stubs. Prompt-template
2799/// commands are merged in separately by the autocomplete builder (they dispatch
2800/// via template expansion, not this registry).
2801fn build_builtin_registry() -> CommandRegistry {
2802    let mut r = CommandRegistry::new();
2803    r.register(Arc::new(HelpCommand));
2804    r.register(Arc::new(ClearChatCommand));
2805    r.register(Arc::new(ExitCommand));
2806    r.register(Arc::new(VersionCommand));
2807    r.register(Arc::new(ChangelogCommand));
2808    r.register(Arc::new(ModelCommand));
2809    r.register(Arc::new(ThinkingCommand));
2810    r.register(Arc::new(ToolsCommand));
2811    r.register(Arc::new(ImagesCommand));
2812    r.register(Arc::new(SessionCommand));
2813    r.register(Arc::new(ThemeCommand));
2814    r.register(Arc::new(CompactCommand));
2815    r.register(Arc::new(CopyCommand));
2816    r.register(Arc::new(HotkeysCommand));
2817    r.register(Arc::new(ArminCommand));
2818    r.register(Arc::new(EarendilCommand));
2819    r.register(Arc::new(ContextCommand));
2820    r.register(Arc::new(UsageCommand));
2821    // Recognized but inert in v1 (one struct backs them all). The TS builtins
2822    // out of v1 scope; each carries a description so autocomplete surfaces its
2823    // existence even though running it reports "not supported".
2824    r.register(Arc::new(NameCommand));
2825    r.register(Arc::new(SettingsCommand));
2826    r.register(Arc::new(ScopedModelsCommand));
2827    r.register(Arc::new(ExportCommand));
2828    r.register(Arc::new(ImportCommand));
2829    r.register(Arc::new(ShareCommand));
2830    r.register(Arc::new(ForkCommand));
2831    r.register(Arc::new(CloneCommand));
2832    r.register(Arc::new(TreeCommand));
2833    r.register(Arc::new(TrustCommand));
2834    r.register(Arc::new(LoginCommand));
2835    r.register(Arc::new(LogoutCommand));
2836    r.register(Arc::new(ReloadCommand));
2837    r
2838}
2839
2840fn register_extension_commands(
2841    registry: &mut CommandRegistry,
2842    session: crate::session::ExtensionSessionCell,
2843) {
2844    let commands = session
2845        .lock()
2846        .ok()
2847        .and_then(|s| s.snapshot_arc())
2848        .map(|snap| snap.commands().to_vec())
2849        .unwrap_or_default();
2850    for command in commands {
2851        let name = if command.name.starts_with('/') {
2852            command.name.clone()
2853        } else {
2854            format!("/{}", command.name)
2855        };
2856        if registry.find(&name).is_some() {
2857            continue;
2858        }
2859        registry.register(Arc::new(ExtensionCommand {
2860            name,
2861            description: command.description,
2862            session: session.clone(),
2863        }));
2864    }
2865}
2866
2867fn register_js_extension_commands(
2868    registry: &mut CommandRegistry,
2869    session: Option<crate::js_extensions::JsExtensionSession>,
2870) {
2871    let Some(session) = session else {
2872        return;
2873    };
2874    for command in &session.commands {
2875        let name = if command.starts_with('/') {
2876            command.clone()
2877        } else {
2878            format!("/{command}")
2879        };
2880        if registry.find(&name).is_none() {
2881            registry.register(Arc::new(JsExtensionCommand {
2882                name,
2883                session: session.clone(),
2884            }));
2885        }
2886    }
2887}
2888
2889// ===========================================================================
2890// Channel + helpers
2891// ===========================================================================
2892
2893/// Message type for communication between the key/callback threads and the
2894/// main async loop.
2895enum TuiMessage {
2896    UserInput(String),
2897    OpenTree,
2898    NavigateTree(String),
2899    Exit,
2900    /// Clear the transcript (from `/clear`).
2901    ClearChat,
2902    /// Compact the conversation (from `/compact`).
2903    Compact,
2904    /// Copy the last assistant reply to the clipboard (from `/copy`).
2905    Copy,
2906    /// Hot-switch to another saved session (from the `/session` selector):
2907    /// the payload is the session id the selector's item value carried.
2908    SwitchSession(String),
2909    /// Export the current session to a markdown file (from `/export`).
2910    ExportSession,
2911    /// Export session with specific format
2912    ExportSessionWithFormat(crate::export::ExportFormat),
2913    /// Show live usage/context stats (from `/usage` and `/context`).
2914    ShowUsage,
2915    /// Fork the current session into a new one and switch to it (from `/fork`).
2916    ForkSession,
2917    /// Rename the current session (from `/name <name>`).
2918    SetSessionName(String),
2919    /// Import a JSONL session file into the session dir and switch to it
2920    /// (from `/import <path>`).
2921    ImportSession(String),
2922    /// Share the current session (`/share`): `gh gist create` when the gh CLI
2923    /// is available, otherwise copy the transcript to the clipboard.
2924    ShareSession,
2925    /// `/reload` — re-run extension + resource discovery into the live harness
2926    /// (B5d). The command (and a plugin's `runtime_action(Reload)` via the
2927    /// mailbox) signal the main loop, which awaits
2928    /// `reload_extension_resources` on the async runtime.
2929    ReloadExtensions,
2930    /// Result returned after Ctrl+G edits a temporary file in an external
2931    /// editor. Handling it on the async loop keeps editor mutation single-
2932    /// threaded with the rest of the TUI state.
2933    ExternalEditorResult(Result<String, String>),
2934}
2935
2936/// Extract the concatenated text content from an assistant message (mirrors
2937/// the TS `contentText` projection — drops thinking/tool-call/image blocks).
2938fn assistant_text(msg: &AssistantMessage) -> String {
2939    msg.content
2940        .iter()
2941        .filter_map(|c| match c {
2942            Content::Text(t) => Some(t.text.clone()),
2943            _ => None,
2944        })
2945        .collect()
2946}
2947
2948/// The user message's text (Text content or the text blocks of a Blocks
2949/// payload — images are skipped, consistent with the v1 text-only prompt path).
2950fn tool_result_message_text(msg: &rpi_ai::types::ToolResultMessage) -> String {
2951    msg.content
2952        .iter()
2953        .filter_map(|c| match c {
2954            Content::Text(t) => Some(t.text.clone()),
2955            _ => None,
2956        })
2957        .collect::<Vec<_>>()
2958        .join("\n")
2959}
2960
2961fn user_message_text(msg: &rpi_ai::types::UserMessage) -> String {
2962    match &msg.content {
2963        rpi_ai::types::UserContent::Text(s) => s.clone(),
2964        rpi_ai::types::UserContent::Blocks(blocks) => blocks
2965            .iter()
2966            .filter_map(|c| match c {
2967                Content::Text(t) => Some(t.text.clone()),
2968                _ => None,
2969            })
2970            .collect(),
2971    }
2972}
2973
2974/// Render the `/settings` panel: the saved settings.json values the session
2975/// honors, plus pointers to the commands that edit them (theme via `/theme`,
2976/// defaults via flags, cycle scope via `/scoped-models`). Kept for the
2977/// read-only summary; the interactive menu is [`open_settings_selector`].
2978fn show_settings_panel(chat: &Arc<Container>) {
2979    let s = crate::settings::load_settings().unwrap_or_default();
2980    let mut lines: Vec<String> = Vec::new();
2981    lines.push("⚙️  Saved settings:".into());
2982    lines.push(format!(
2983        "  Theme: {} (edit with /theme)",
2984        s.theme.as_deref().unwrap_or("(default)")
2985    ));
2986    lines.push(format!(
2987        "  Default model: {} (set at launch with --model)",
2988        s.default_model.as_deref().unwrap_or("(none)")
2989    ));
2990    lines.push(format!(
2991        "  Default thinking: {} (set at launch with --thinking)",
2992        s.default_thinking_level.as_deref().unwrap_or("(default)")
2993    ));
2994    match &s.scoped_models {
2995        Some(list) if !list.is_empty() => lines.push(format!(
2996            "  Ctrl+M cycle scope: {} (edit with /scoped-models)",
2997            list.join(", ")
2998        )),
2999        _ => lines.push("  Ctrl+M cycle scope: all models (edit with /scoped-models)".into()),
3000    }
3001    let body = lines.join("\n");
3002    container_note_block(chat, &body);
3003}
3004
3005/// The catalog allowed in the Ctrl+M cycle: the `/scoped-models` set from
3006/// settings.json when present, otherwise every model. The current model is
3007/// always included (fallback) so cycling can never strand the user off-scope.
3008fn scoped_catalog(catalog: &[rpi_ai::Model], current_id: &str) -> Vec<rpi_ai::Model> {
3009    let scoped = crate::settings::load_settings()
3010        .ok()
3011        .and_then(|s| s.scoped_models)
3012        .unwrap_or_default();
3013    if scoped.is_empty() {
3014        return catalog.to_vec();
3015    }
3016    let mut out: Vec<rpi_ai::Model> = catalog
3017        .iter()
3018        .filter(|m| scoped.iter().any(|s| s.eq_ignore_ascii_case(&m.id)))
3019        .cloned()
3020        .collect();
3021    // Never strand the user: if the current model isn't in scope, keep it.
3022    if !out.iter().any(|m| m.id.eq_ignore_ascii_case(current_id)) {
3023        if let Some(cur) = catalog
3024            .iter()
3025            .find(|m| m.id.eq_ignore_ascii_case(current_id))
3026        {
3027            out.push(cur.clone());
3028        }
3029    }
3030    out
3031}
3032
3033/// Interactive `/settings` menu: a top-level selector over the editable
3034/// settings, each opening a sub-selector that applies the choice AND persists
3035/// it to settings.json (theme / default model / default thinking / cycle
3036/// scope). Selecting a menu item swaps the current selector for the
3037/// sub-selector (the `active_selector` slot is single, so each open replaces
3038/// the previous list); the sub-selector's cancel restores the editor.
3039fn open_settings_selector(
3040    state: &Arc<TuiState>,
3041    editor_container: &Arc<Container>,
3042    editor: &Arc<Editor>,
3043    tui: &Arc<TuiAltScreen>,
3044    lane: &Arc<dyn AgentLane>,
3045    catalog: &[rpi_ai::Model],
3046    lane_model_id: &str,
3047    chat: &Arc<Container>,
3048    cwd: &std::path::Path,
3049    package_resources: &Arc<crate::packages::PackageResources>,
3050) {
3051    let settings = crate::settings::load_settings().unwrap_or_default();
3052    let mut items: Vec<SelectItem> = Vec::new();
3053    items.push(
3054        SelectItem::new("theme", "Theme")
3055            .with_description(&settings.theme.clone().unwrap_or_else(|| "(default)".into())),
3056    );
3057    items.push(
3058        SelectItem::new("model", "Default model").with_description(
3059            &settings
3060                .default_model
3061                .clone()
3062                .unwrap_or_else(|| "(none)".into()),
3063        ),
3064    );
3065    items.push(
3066        SelectItem::new("thinking", "Default thinking").with_description(
3067            &settings
3068                .default_thinking_level
3069                .clone()
3070                .unwrap_or_else(|| "(default)".into()),
3071        ),
3072    );
3073    let scope_desc = match &settings.scoped_models {
3074        Some(list) if !list.is_empty() => format!("{}", list.join(", ")),
3075        _ => "all models".to_string(),
3076    };
3077    items
3078        .push(SelectItem::new("scoped-models", "Ctrl+M cycle scope").with_description(&scope_desc));
3079    let list = Arc::new(SelectList::new(items, 10));
3080
3081    let state_sel = state.clone();
3082    let ec_sel = editor_container.clone();
3083    let editor_sel = editor.clone();
3084    let tui_sel = tui.clone();
3085    let lane_sel = lane.clone();
3086    let chat_sel = chat.clone();
3087    let catalog_sel = catalog.to_vec();
3088    let lane_model_sel = lane_model_id.to_string();
3089    let cwd_sel = cwd.to_path_buf();
3090    let package_resources_sel = package_resources.clone();
3091    list.on_select(Arc::new(move |item| {
3092        // Swap this menu for the sub-selector; each sub-selector saves its
3093        // choice to settings.json on select.
3094        match item.value.as_str() {
3095            "theme" => open_settings_theme_selector(
3096                &state_sel,
3097                &ec_sel,
3098                &editor_sel,
3099                &tui_sel,
3100                &chat_sel,
3101                &cwd_sel,
3102                &package_resources_sel,
3103            ),
3104            "model" => open_settings_model_selector(
3105                &state_sel,
3106                &ec_sel,
3107                &editor_sel,
3108                &tui_sel,
3109                &lane_sel,
3110                &catalog_sel,
3111                &lane_model_sel,
3112                &chat_sel,
3113            ),
3114            "thinking" => open_settings_thinking_selector(
3115                &state_sel,
3116                &ec_sel,
3117                &editor_sel,
3118                &tui_sel,
3119                &lane_sel,
3120                &catalog_sel,
3121                &lane_model_sel,
3122                &chat_sel,
3123            ),
3124            "scoped-models" => open_scoped_models_selector(
3125                &state_sel,
3126                &ec_sel,
3127                &editor_sel,
3128                &tui_sel,
3129                &catalog_sel,
3130                &chat_sel,
3131            ),
3132            _ => close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel),
3133        }
3134    }));
3135    let state_cancel = state.clone();
3136    let ec_cancel = editor_container.clone();
3137    let editor_cancel = editor.clone();
3138    let tui_cancel = tui.clone();
3139    list.on_cancel(Arc::new(move || {
3140        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3141    }));
3142
3143    open_selector(
3144        state,
3145        editor_container,
3146        editor,
3147        tui,
3148        list,
3149        SelectorKind::Settings,
3150    );
3151}
3152
3153/// Apply a theme choice AND persist it to settings.json (`/settings` → Theme).
3154fn open_settings_theme_selector(
3155    state: &Arc<TuiState>,
3156    editor_container: &Arc<Container>,
3157    editor: &Arc<Editor>,
3158    tui: &Arc<TuiAltScreen>,
3159    chat: &Arc<Container>,
3160    cwd: &std::path::Path,
3161    package_resources: &Arc<crate::packages::PackageResources>,
3162) {
3163    let mut items = vec![
3164        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
3165        SelectItem::new("light", "Light").with_description("Light background"),
3166        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
3167    ];
3168    if state.themes_enabled {
3169        for path in package_resources.theme_files() {
3170            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
3171                items.push(SelectItem::new(name, name).with_description("Package theme"));
3172            }
3173        }
3174    }
3175    let list = Arc::new(SelectList::new(items, 10));
3176
3177    let state_sel = state.clone();
3178    let ec_sel = editor_container.clone();
3179    let editor_sel = editor.clone();
3180    let tui_sel = tui.clone();
3181    let chat_sel = chat.clone();
3182    let cwd_sel = cwd.to_path_buf();
3183    let package_resources_sel = package_resources.clone();
3184    list.on_select(Arc::new(move |item| {
3185        let preset = match item.value.as_str() {
3186            "light" => Some(ThemePreset::Light),
3187            "monochrome" => Some(ThemePreset::Monochrome),
3188            "dark" => Some(ThemePreset::Dark),
3189            name => {
3190                if state_sel.themes_enabled {
3191                    if let Ok(custom) = crate::packages::load_theme_with_resources(
3192                        &cwd_sel,
3193                        name,
3194                        &package_resources_sel,
3195                    ) {
3196                        rpi_tui::global_theme_manager().set(custom.clone());
3197                        state_sel.theme_manager.set(custom);
3198                    }
3199                }
3200                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
3201                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3202                tui_sel.render_now(true);
3203                return;
3204            }
3205        };
3206        let Some(preset) = preset else { return };
3207        apply_theme_preset(preset);
3208        state_sel.theme_manager.apply_preset(preset);
3209        let mut settings = crate::settings::load_settings().unwrap_or_default();
3210        settings.theme = Some(item.value.clone());
3211        let saved = crate::settings::save_settings(&settings);
3212        add_note_message(
3213            &chat_sel,
3214            &format!(
3215                "Theme set to {} (saved{})",
3216                item.label,
3217                if saved.is_ok() { "" } else { ", not saved" },
3218            ),
3219        );
3220        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3221        tui_sel.render_now(true);
3222    }));
3223    let state_cancel = state.clone();
3224    let ec_cancel = editor_container.clone();
3225    let editor_cancel = editor.clone();
3226    let tui_cancel = tui.clone();
3227    list.on_cancel(Arc::new(move || {
3228        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3229    }));
3230
3231    open_selector(
3232        state,
3233        editor_container,
3234        editor,
3235        tui,
3236        list,
3237        SelectorKind::Settings,
3238    );
3239}
3240
3241/// Choose the default model AND persist it (`/settings` → Default model):
3242/// applies live via `lane.set_model` and saves `defaultModel` to settings.json
3243/// (which `provider::resolve` honors as pi's `findInitialModel` step 3).
3244fn open_settings_model_selector(
3245    state: &Arc<TuiState>,
3246    editor_container: &Arc<Container>,
3247    editor: &Arc<Editor>,
3248    tui: &Arc<TuiAltScreen>,
3249    lane: &Arc<dyn AgentLane>,
3250    catalog: &[rpi_ai::Model],
3251    lane_model_id: &str,
3252    chat: &Arc<Container>,
3253) {
3254    let items = model_selector_items(catalog, lane_model_id);
3255    if items.is_empty() {
3256        add_note_message(chat, "No models in the catalog.");
3257        tui.request_render(false);
3258        return;
3259    }
3260    let list = Arc::new(SelectList::new(items, 10));
3261
3262    let catalog_arc = catalog.to_vec();
3263    let state_sel = state.clone();
3264    let ec_sel = editor_container.clone();
3265    let editor_sel = editor.clone();
3266    let tui_sel = tui.clone();
3267    let chat_sel = chat.clone();
3268    let lane_sel = lane.clone();
3269    list.on_select(Arc::new(move |item| {
3270        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
3271            add_note_message(&chat_sel, &format!("Model {} not found.", item.label));
3272            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3273            return;
3274        };
3275        state_sel.set_current_model(&model);
3276        let lane = lane_sel.clone();
3277        tokio::spawn(async move {
3278            let _ = lane.set_model(model).await;
3279        });
3280        let mut settings = crate::settings::load_settings().unwrap_or_default();
3281        settings.default_model = Some(item.value.clone());
3282        let saved = crate::settings::save_settings(&settings);
3283        add_note_message(
3284            &chat_sel,
3285            &format!(
3286                "Default model set to {} (saved{}",
3287                short_model_name(&item.value),
3288                if saved.is_ok() { ")" } else { ", not saved)" },
3289            ),
3290        );
3291        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3292    }));
3293    let state_cancel = state.clone();
3294    let ec_cancel = editor_container.clone();
3295    let editor_cancel = editor.clone();
3296    let tui_cancel = tui.clone();
3297    list.on_cancel(Arc::new(move || {
3298        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3299    }));
3300
3301    open_selector(
3302        state,
3303        editor_container,
3304        editor,
3305        tui,
3306        list,
3307        SelectorKind::Settings,
3308    );
3309}
3310
3311/// Convert the authenticated runtime catalog into selector rows. Keep the
3312/// model id as the value so `/model <id>` and the selection callback share one
3313/// lookup path, while making the provider visible for OpenAI-compatible
3314/// gateways where the same model id may exist at multiple endpoints.
3315fn model_selector_items(catalog: &[rpi_ai::Model], lane_model_id: &str) -> Vec<SelectItem> {
3316    let mut seen = std::collections::HashSet::new();
3317    catalog
3318        .iter()
3319        .filter(|m| {
3320            seen.insert((
3321                m.api.clone(),
3322                m.provider.to_ascii_lowercase(),
3323                m.id.to_ascii_lowercase(),
3324            ))
3325        })
3326        .map(|m| {
3327            let label = if m.name.is_empty() {
3328                short_model_name(&m.id)
3329            } else {
3330                m.name.clone()
3331            };
3332            let identity = if matches!(m.api, rpi_ai::Api::AnthropicMessages)
3333                && m.provider.eq_ignore_ascii_case("anthropic")
3334            {
3335                m.id.clone()
3336            } else {
3337                format!("{}/{}", m.provider, m.id)
3338            };
3339            let marker = if m.id.eq_ignore_ascii_case(lane_model_id) {
3340                " (current)"
3341            } else {
3342                ""
3343            };
3344            SelectItem::new(&m.id, &label).with_description(&format!("{identity}{marker}"))
3345        })
3346        .collect()
3347}
3348
3349/// Resolve a selector input by either bare model id or the qualified
3350/// `provider/model` identity shown for gateway models. This keeps manual
3351/// `/model ...` input consistent with the rows rendered by the selector.
3352fn find_model_selector_match(catalog: &[rpi_ai::Model], input: &str) -> Option<rpi_ai::Model> {
3353    let (provider, id) = input
3354        .split_once('/')
3355        .filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
3356        .map_or((None, input), |(provider, id)| (Some(provider), id));
3357    catalog
3358        .iter()
3359        .find(|model| {
3360            model.id.eq_ignore_ascii_case(id)
3361                && provider.map_or(true, |provider| {
3362                    model.provider.eq_ignore_ascii_case(provider)
3363                        || (provider.eq_ignore_ascii_case("anthropic")
3364                            && matches!(model.api, rpi_ai::Api::AnthropicMessages))
3365                })
3366        })
3367        .cloned()
3368}
3369
3370/// Choose the default thinking level AND persist it (`/settings` → Default
3371/// thinking): applies live via `lane.set_thinking_level` and saves
3372/// `defaultThinkingLevel` to settings.json.
3373fn open_settings_thinking_selector(
3374    state: &Arc<TuiState>,
3375    editor_container: &Arc<Container>,
3376    editor: &Arc<Editor>,
3377    tui: &Arc<TuiAltScreen>,
3378    lane: &Arc<dyn AgentLane>,
3379    catalog: &[rpi_ai::Model],
3380    lane_model_id: &str,
3381    chat: &Arc<Container>,
3382) {
3383    let model = catalog
3384        .iter()
3385        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
3386    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
3387        .map(|m| m.supported_thinking_levels())
3388        .unwrap_or_else(|| {
3389            use rpi_ai::types::ThinkingLevel::*;
3390            vec![Off, Minimal, Low, Medium, High]
3391        });
3392    let mut items: Vec<SelectItem> = Vec::new();
3393    for lvl in &levels {
3394        let name = thinking_level_name(*lvl);
3395        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
3396    }
3397    if items.is_empty() {
3398        add_note_message(chat, "This model has no supported thinking levels.");
3399        tui.request_render(false);
3400        return;
3401    }
3402    let list = Arc::new(SelectList::new(items, 10));
3403
3404    let state_sel = state.clone();
3405    let ec_sel = editor_container.clone();
3406    let editor_sel = editor.clone();
3407    let tui_sel = tui.clone();
3408    let chat_sel = chat.clone();
3409    let lane_sel = lane.clone();
3410    list.on_select(Arc::new(move |item| {
3411        let Some(level) = thinking_level_from_name(&item.value) else {
3412            add_note_message(
3413                &chat_sel,
3414                &format!("Unknown thinking level: {}.", item.label),
3415            );
3416            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3417            return;
3418        };
3419        let lane = lane_sel.clone();
3420        let footer_sel = state_sel.footer.clone();
3421        tokio::spawn(async move {
3422            let _ = lane.set_thinking_level(level).await;
3423        });
3424        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
3425        let mut settings = crate::settings::load_settings().unwrap_or_default();
3426        settings.default_thinking_level = Some(item.value.clone());
3427        let saved = crate::settings::save_settings(&settings);
3428        add_note_message(
3429            &chat_sel,
3430            &format!(
3431                "Default thinking set to {} (saved{}",
3432                item.label,
3433                if saved.is_ok() { ")" } else { ", not saved)" },
3434            ),
3435        );
3436        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
3437    }));
3438    let state_cancel = state.clone();
3439    let ec_cancel = editor_container.clone();
3440    let editor_cancel = editor.clone();
3441    let tui_cancel = tui.clone();
3442    list.on_cancel(Arc::new(move || {
3443        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3444    }));
3445
3446    open_selector(
3447        state,
3448        editor_container,
3449        editor,
3450        tui,
3451        list,
3452        SelectorKind::Settings,
3453    );
3454}
3455
3456/// `/scoped-models`: a multi-toggle selector over the catalog. Selecting an
3457/// item toggles it in the in-progress set (the selector stays open); Esc saves
3458/// the set to settings.json and closes. The active scoped set is echoed after
3459/// each toggle so the user sees the current selection.
3460fn open_scoped_models_selector(
3461    state: &Arc<TuiState>,
3462    editor_container: &Arc<Container>,
3463    editor: &Arc<Editor>,
3464    tui: &Arc<TuiAltScreen>,
3465    catalog: &[rpi_ai::Model],
3466    chat: &Arc<Container>,
3467) {
3468    if catalog.is_empty() {
3469        add_note_message(chat, "No models in the catalog.");
3470        tui.request_render(false);
3471        return;
3472    }
3473    // Seed the edit set from the saved scoped models.
3474    let seed: Vec<String> = crate::settings::load_settings()
3475        .ok()
3476        .and_then(|s| s.scoped_models)
3477        .unwrap_or_default();
3478    *state.scoped_edit.lock().unwrap() = Some(seed);
3479
3480    let mut items: Vec<SelectItem> = Vec::new();
3481    for m in catalog {
3482        items.push(SelectItem::new(&m.id, &m.id));
3483    }
3484    let list = Arc::new(SelectList::new(items, 10));
3485
3486    let state_sel = state.clone();
3487    let chat_sel = chat.clone();
3488    let tui_sel = tui.clone();
3489    list.on_select(Arc::new(move |item| {
3490        // Toggle the model in the in-progress set; the selector stays open.
3491        let mut set = state_sel.scoped_edit.lock().unwrap();
3492        let set = set.get_or_insert_with(Vec::new);
3493        if let Some(pos) = set.iter().position(|m| m.eq_ignore_ascii_case(&item.value)) {
3494            set.remove(pos);
3495            add_note_message(&chat_sel, &format!("{} removed — Esc to save", item.label));
3496        } else {
3497            set.push(item.value.clone());
3498            add_note_message(&chat_sel, &format!("{} added — Esc to save", item.label));
3499        }
3500        tui_sel.request_render(false);
3501    }));
3502    let state_cancel = state.clone();
3503    let ec_cancel = editor_container.clone();
3504    let editor_cancel = editor.clone();
3505    let tui_cancel = tui.clone();
3506    let chat_cancel = chat.clone();
3507    list.on_cancel(Arc::new(move || {
3508        // Save the edited set to settings.json and close.
3509        let set = state_cancel
3510            .scoped_edit
3511            .lock()
3512            .unwrap()
3513            .take()
3514            .unwrap_or_default();
3515        let mut settings = crate::settings::load_settings().unwrap_or_default();
3516        settings.scoped_models = if set.is_empty() {
3517            None
3518        } else {
3519            Some(set.clone())
3520        };
3521        match crate::settings::save_settings(&settings) {
3522            Ok(()) => {
3523                if set.is_empty() {
3524                    add_note_message(&chat_cancel, "Ctrl+M cycles all models (scope cleared).");
3525                } else {
3526                    add_note_message(
3527                        &chat_cancel,
3528                        &format!("Ctrl+M cycle scope: {}", set.join(", ")),
3529                    );
3530                }
3531            }
3532            Err(e) => add_error_message(&chat_cancel, &format!("Could not save settings: {e}")),
3533        }
3534        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
3535    }));
3536
3537    open_selector(
3538        state,
3539        editor_container,
3540        editor,
3541        tui,
3542        list,
3543        SelectorKind::ScopedModels,
3544    );
3545}
3546
3547/// `/share`: mirror the TS intent (share the session). With the `gh` CLI on
3548/// PATH, create a gist of the exported markdown; otherwise fall back to the
3549/// clipboard (best-effort) and note the local path.
3550async fn share_session(harness: &AgentHarness, chat: &Arc<Container>) {
3551    use std::process::Stdio;
3552
3553    // Reuse the export builder for the transcript text.
3554    let tree = harness.session().view("main");
3555    let entries = match tree
3556        .find_entries(&EntryQuery {
3557            entry_type: None,
3558            custom_type: None,
3559            // Exports append entries top-to-bottom, so use chronological order
3560            // instead of the session query default (newest-first).
3561            order: Some(EntryOrder::OldestFirst),
3562            limit: None,
3563            cursor: None,
3564        })
3565        .await
3566    {
3567        Ok(e) => e,
3568        Err(e) => {
3569            add_error_message(chat, &format!("Could not read session: {e}"));
3570            return;
3571        }
3572    };
3573    let mut md = String::from("# Session\n\n");
3574    for e in entries {
3575        let Entry::Message(me) = e else { continue };
3576        match &me.message {
3577            AgentMessage::User(u) => {
3578                md.push_str(&format!("## User\n\n{}\n\n", user_message_text(u)));
3579            }
3580            AgentMessage::Assistant(a) => {
3581                let text = assistant_text(a);
3582                if !text.is_empty() {
3583                    md.push_str(&format!("## Assistant\n\n{}\n\n", text));
3584                }
3585            }
3586            _ => {}
3587        }
3588    }
3589
3590    // `gh gist create` — stdin-piped, best-effort; only when gh exists.
3591    let gh = std::process::Command::new("gh")
3592        .arg("gist")
3593        .arg("create")
3594        .arg("--filename")
3595        .arg("session.md")
3596        .arg("-")
3597        .stdin(Stdio::piped())
3598        .stdout(Stdio::piped())
3599        .stderr(Stdio::null())
3600        .spawn();
3601    if let Ok(mut child) = gh {
3602        use std::io::Write;
3603        if let Some(mut stdin) = child.stdin.take() {
3604            let _ = stdin.write_all(md.as_bytes());
3605            let _ = stdin.flush();
3606        }
3607        let out = child.wait_with_output().ok();
3608        if let Some(out) = out {
3609            if out.status.success() {
3610                let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
3611                add_note_message(chat, &format!("Shared session: {url}"));
3612                return;
3613            }
3614        }
3615        add_note_message(chat, "gh gist failed — falling back to the clipboard.");
3616    } else {
3617        add_note_message(chat, "gh CLI not found — falling back to the clipboard.");
3618    }
3619    // Clipboard fallback (or transcript echo when the clipboard feature is off).
3620    if copy_to_clipboard(&md) {
3621        add_note_message(chat, "Session transcript copied to the clipboard.");
3622    } else {
3623        add_note_message(
3624            chat,
3625            "Clipboard unavailable — use /export to write the transcript to a file.",
3626        );
3627    }
3628}
3629
3630// Session export is driven through the product-layer `AgentSession`
3631// (`crate::agent_session`), which owns format selection + default filenames —
3632// the TUI loop calls `AgentSession::export*` directly, so there is no separate
3633// helper here.
3634
3635/// Fork the current session into a new JSONL session and switch to it (TS
3636/// `/fork` — a copy of the transcript in a fresh file; the fork is a new
3637/// session the user continues in). Uses the repo's `fork_typed`, then swaps
3638/// the harness backing and renders the (empty-ish) fork transcript.
3639/// Hot-switch the harness to another saved session: abort any in-flight run,
3640/// open the target session file, swap the durable backing, and re-render the
3641/// transcript from the new history (mirrors pi's `/session` resume-in-place).
3642/// Shared by the `/session` selector, `/import`, and `/fork`. The current
3643/// model/footer stay put (v1 doesn't replay the session's ModelChange entries).
3644async fn switch_to_session(
3645    harness: &AgentHarness,
3646    lane: &Arc<dyn AgentLane>,
3647    id: &str,
3648    cwd: &std::path::Path,
3649    chat: &Arc<Container>,
3650    state: &Arc<TuiState>,
3651) -> bool {
3652    if *state.status.lock().unwrap() == RunStatus::Working {
3653        state.set_status(RunStatus::Aborting);
3654        let _ = lane.abort().await;
3655    }
3656    let cwd_str = cwd.to_string_lossy().to_string();
3657    match crate::session::open_session_by_id(id, &cwd_str).await {
3658        Ok(new_session) => {
3659            let _ = harness.set_session(new_session).await;
3660            chat.clear();
3661            add_welcome_message(chat);
3662            render_session_history(
3663                harness,
3664                chat,
3665                state.markdown_transformer(),
3666                Some(state.extension_session.clone()),
3667            )
3668            .await;
3669            state.set_status(RunStatus::Idle);
3670            add_note_message(chat, &format!("Switched to session {id}."));
3671            true
3672        }
3673        Err(e) => {
3674            state.set_status(RunStatus::Idle);
3675            add_error_message(chat, &format!("Could not open session {id}: {e}"));
3676            false
3677        }
3678    }
3679}
3680
3681/// `/import <path>`: copy a JSONL session file into the default session dir,
3682/// then hot-switch to it (the file name becomes its id — matching the
3683/// selector/`open_session_by_id` containment rules).
3684async fn import_session(
3685    harness: &AgentHarness,
3686    lane: &Arc<dyn AgentLane>,
3687    path: &str,
3688    cwd: &std::path::Path,
3689    chat: &Arc<Container>,
3690    state: &Arc<TuiState>,
3691) {
3692    use std::path::Path as FsPath;
3693
3694    let src = FsPath::new(path);
3695    if !src.is_file() {
3696        add_error_message(chat, &format!("Import source not found: {path}"));
3697        return;
3698    }
3699    let Some(fname) = src.file_name().and_then(|f| f.to_str()) else {
3700        add_error_message(chat, "Import source has no file name.");
3701        return;
3702    };
3703    if !fname.ends_with(".jsonl") {
3704        add_error_message(chat, "Import source must be a .jsonl session file.");
3705        return;
3706    }
3707    let dir = crate::session::default_session_dir(cwd);
3708    if let Err(e) = std::fs::create_dir_all(&dir) {
3709        add_error_message(chat, &format!("Could not create session dir: {e}"));
3710        return;
3711    }
3712    let dest = dir.join(fname);
3713    match std::fs::copy(src, &dest) {
3714        Ok(_) => {
3715            let id = fname.strip_suffix(".jsonl").unwrap_or(fname).to_string();
3716            if switch_to_session(harness, lane, &id, cwd, chat, state).await {
3717                add_note_message(chat, &format!("Imported session from {path}"));
3718            }
3719        }
3720        Err(e) => add_error_message(chat, &format!("Could not copy import: {e}")),
3721    }
3722}
3723
3724async fn fork_session(
3725    harness: &AgentHarness,
3726    cwd: &std::path::Path,
3727    chat: &Arc<Container>,
3728    state: &Arc<TuiState>,
3729) {
3730    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3731    use rpi_tools::FileSystem;
3732
3733    let cwd_str = cwd.to_string_lossy().to_string();
3734    let dir = crate::session::default_session_dir(cwd);
3735    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(cwd.to_path_buf()));
3736    let fs: Arc<dyn FileSystem> = env.clone();
3737    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3738        fs,
3739        sessions_root: dir.to_string_lossy().into_owned(),
3740        clock: Arc::new(rpi_harness::session::memory::SystemClock),
3741        ids: Arc::new(rpi_harness::session::session::DefaultIdGenerator::new()),
3742    });
3743    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
3744    // it from the session list by the current session's id.
3745    let id = harness.session().storage().metadata().id.clone();
3746    let metas = match crate::session::list_session_metadata(&cwd_str).await {
3747        Ok(m) => m,
3748        Err(e) => {
3749            add_error_message(chat, &format!("Could not list sessions: {e}"));
3750            return;
3751        }
3752    };
3753    let Some(source) = metas.iter().find(|m| m.id == id) else {
3754        add_error_message(chat, &format!("Current session {id} not found on disk."));
3755        return;
3756    };
3757    let fork_storage = match repo
3758        .fork_typed(
3759            source,
3760            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3761                id: None,
3762                parent_session_id: Some(source.id.clone()),
3763                cwd: cwd_str.clone(),
3764                metadata: None,
3765            },
3766            &rpi_harness::session::types::ForkOptions::default(),
3767        )
3768        .await
3769    {
3770        Ok(s) => s,
3771        Err(e) => {
3772            add_error_message(chat, &format!("Could not fork session: {e}"));
3773            return;
3774        }
3775    };
3776    let new_session = rpi_harness::session::session::Session::new(Arc::new(fork_storage), None);
3777    let _ = harness.set_session(new_session).await;
3778    chat.clear();
3779    add_welcome_message(chat);
3780    render_session_history(
3781        harness,
3782        chat,
3783        state.markdown_transformer(),
3784        Some(state.extension_session.clone()),
3785    )
3786    .await;
3787    state.set_status(RunStatus::Idle);
3788    add_note_message(chat, "Forked into a new session.");
3789}
3790
3791/// Render the restored session's prior transcript (user + assistant messages)
3792/// into the chat container. Called at TUI startup for `--continue`/`--resume`/
3793/// `--session` launches; a no-op for fresh sessions (no entries). Best-effort:
3794/// any session read failure just starts with an empty transcript.
3795///
3796/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
3797/// the identity path. Each restored assistant component installs it so replayed
3798/// history renders through the same `register_markdown_transformer` handlers
3799/// the live stream does.
3800async fn render_session_history(
3801    harness: &AgentHarness,
3802    chat: &Arc<Container>,
3803    transformer: Option<MarkdownTransformer>,
3804    extension_session: Option<crate::session::ExtensionSessionCell>,
3805) {
3806    let tree = harness.session().view("main");
3807    let entries = match tree
3808        .find_entries(&EntryQuery {
3809            entry_type: None,
3810            custom_type: None,
3811            // Session queries default to newest-first for selectors and
3812            // pagination. The transcript appends children top-to-bottom, so
3813            // restored history must explicitly be chronological.
3814            order: Some(EntryOrder::OldestFirst),
3815            limit: None,
3816            cursor: None,
3817        })
3818        .await
3819    {
3820        Ok(e) => e,
3821        Err(_) => return,
3822    };
3823    let mut rendered_any = false;
3824    for e in entries {
3825        match e {
3826            Entry::Message(me) => match &me.message {
3827                AgentMessage::User(u) => {
3828                    add_user_message(chat, &user_message_text(u));
3829                    rendered_any = true;
3830                }
3831                AgentMessage::Assistant(a) => {
3832                    let comp = Arc::new(AssistantMessageComponent::new(
3833                        AssistantMessageOptions::default(),
3834                    ));
3835                    if let Some(t) = &transformer {
3836                        comp.set_markdown_transformer(Some(t.clone()));
3837                    }
3838                    comp.update_blocks(&assistant_blocks(a));
3839                    chat.add_child(comp);
3840                    // Single trailing spacer: the next transcript entry (user or
3841                    // assistant) follows one blank line below.
3842                    chat.add_child(Arc::new(Spacer::new(1)));
3843                    if let Some(text) = extension_usage_text(extension_session.as_ref(), &a.usage) {
3844                        add_note_message(chat, &text);
3845                    }
3846                    if let Some(error) = assistant_error_text(a) {
3847                        add_error_message(chat, &error);
3848                    }
3849                    rendered_any = true;
3850                }
3851                AgentMessage::ToolResult(result) => {
3852                    // Tool results are persisted as separate message entries,
3853                    // not as part of the assistant text. Restore them as
3854                    // completed tool panels so resumed sessions show the
3855                    // command output as well as the user's prompts.
3856                    let comp = Arc::new(ToolExecutionComponent::new(
3857                        &result.tool_name,
3858                        "",
3859                    ));
3860                    comp.set_result(&tool_result_message_text(result), result.is_error);
3861                    chat.add_child(comp);
3862                    chat.add_child(Arc::new(Spacer::new(1)));
3863                    rendered_any = true;
3864                }
3865                AgentMessage::Custom(custom) => {
3866                    if let Some(session) = &extension_session {
3867                        if let Some(component) = extension_message_component(
3868                            session,
3869                            &custom.role,
3870                            &serde_json::json!({
3871                                "customType": custom.role,
3872                                "content": custom.content,
3873                                "details": custom.data,
3874                            }),
3875                            transformer.clone(),
3876                        ) {
3877                            chat.add_child(component);
3878                            chat.add_child(Arc::new(Spacer::new(1)));
3879                            rendered_any = true;
3880                            continue;
3881                        }
3882                    }
3883                    add_note_message(chat, &custom_message_fallback(&custom));
3884                    rendered_any = true;
3885                }
3886            },
3887            Entry::Compaction(compaction) => {
3888                add_note_message(
3889                    chat,
3890                    &format!(
3891                        "Compacted {} tokens: {}",
3892                        compaction.tokens_before, compaction.summary
3893                    ),
3894                );
3895                rendered_any = true;
3896            }
3897            Entry::BranchSummary(summary) => {
3898                add_note_message(chat, &format!("Branch summary: {}", summary.summary));
3899                rendered_any = true;
3900            }
3901            Entry::Custom(custom) => {
3902                let rendered = extension_session.as_ref().and_then(|session| {
3903                    extension_entry_component(session, &custom.custom_type, custom.data.clone())
3904                });
3905                if let Some(component) = rendered {
3906                    chat.add_child(component);
3907                    chat.add_child(Arc::new(Spacer::new(1)));
3908                    rendered_any = true;
3909                } else if let Some(text) =
3910                    custom_entry_display_text(&custom.custom_type, custom.data.as_ref())
3911                {
3912                    add_note_message(chat, &text);
3913                    rendered_any = true;
3914                }
3915            }
3916            Entry::ModelChange(change) => {
3917                add_note_message(
3918                    chat,
3919                    &format!("Model changed to {}:{}", change.provider, change.model_id),
3920                );
3921                rendered_any = true;
3922            }
3923            Entry::ThinkingLevel(change) => {
3924                add_note_message(
3925                    chat,
3926                    &format!("Thinking level: {:?}", change.thinking_level),
3927                );
3928                rendered_any = true;
3929            }
3930            Entry::ActiveTools(change) => {
3931                add_note_message(
3932                    chat,
3933                    &format!("Active tools: {}", change.active_tool_names.join(", ")),
3934                );
3935                rendered_any = true;
3936            }
3937        }
3938    }
3939    if rendered_any {
3940        // No trailing spacer here — each entry already adds its own trailing
3941        // Spacer(1), so an extra would double the bottom gap.
3942    }
3943}
3944
3945fn invoke_extension_renderer(
3946    session: &crate::session::ExtensionSessionCell,
3947    kind: rpi_extensions::RegisteredRendererKind,
3948    payload: &serde_json::Value,
3949) -> Option<serde_json::Value> {
3950    let snapshot = session.lock().ok()?.snapshot_arc()?;
3951    let input = serde_json::to_string(payload).ok()?;
3952    for renderer in snapshot.renderers_of(kind) {
3953        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3954            let mut out = rpi_plugin_sdk::StbString::empty();
3955            let rc = (renderer.render_fn)(
3956                rpi_plugin_sdk::StbStringRef::from_str(&input),
3957                &mut out as *mut rpi_plugin_sdk::StbString,
3958                renderer.user_data,
3959            );
3960            let text = if rc == 0 {
3961                Some(out.to_string_lossy())
3962            } else {
3963                None
3964            };
3965            out.free_with(Some(renderer.plugin_free_string));
3966            text
3967        }))
3968        .ok()
3969        .flatten();
3970        let Some(text) = outcome else { continue };
3971        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
3972            return Some(value);
3973        }
3974    }
3975    None
3976}
3977
3978fn extension_text_component(value: &serde_json::Value) -> Option<Arc<dyn rpi_tui::Component>> {
3979    if let Some(lines) = value.get("lines").and_then(|v| v.as_array()) {
3980        let text = lines
3981            .iter()
3982            .filter_map(|line| line.as_str())
3983            .collect::<Vec<_>>()
3984            .join("\n");
3985        return Some(Arc::new(Text::new(text, 0, 0)));
3986    }
3987    let text = value.get("text").and_then(|v| v.as_str())?;
3988    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
3989        let component = Arc::new(AssistantMessageComponent::new(
3990            AssistantMessageOptions::default(),
3991        ));
3992        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
3993        Some(component)
3994    } else {
3995        Some(Arc::new(Text::new(text, 0, 0)))
3996    }
3997}
3998
3999fn extension_message_component(
4000    session: &crate::session::ExtensionSessionCell,
4001    custom_type: &str,
4002    payload: &serde_json::Value,
4003    transformer: Option<MarkdownTransformer>,
4004) -> Option<Arc<dyn rpi_tui::Component>> {
4005    let value = invoke_extension_renderer(
4006        session,
4007        rpi_extensions::RegisteredRendererKind::Message,
4008        payload,
4009    )?;
4010    if value.get("markdown").and_then(|v| v.as_bool()) == Some(true) {
4011        let text = value.get("text").and_then(|v| v.as_str())?;
4012        let component = Arc::new(AssistantMessageComponent::new(
4013            AssistantMessageOptions::default(),
4014        ));
4015        if let Some(transformer) = transformer {
4016            component.set_markdown_transformer(Some(transformer));
4017        }
4018        component.update_blocks(&[AssistantBlock::Text(text.to_string())]);
4019        return Some(component);
4020    }
4021    extension_text_component(&value)
4022        .or_else(|| Some(Arc::new(Text::new(format!("[{custom_type}]"), 0, 0))))
4023}
4024
4025/// Render usage from a completed assistant message through the registered
4026/// message renderers. Hosts without a token-usage renderer return `None`.
4027fn extension_usage_text(
4028    session: Option<&crate::session::ExtensionSessionCell>,
4029    usage: &rpi_ai::types::Usage,
4030) -> Option<String> {
4031    let session = session?;
4032    let payload = serde_json::json!({
4033        "customType": "token-usage",
4034        "usage": usage,
4035    });
4036    let value = invoke_extension_renderer(
4037        session,
4038        rpi_extensions::RegisteredRendererKind::Message,
4039        &payload,
4040    )?;
4041    value
4042        .get("text")
4043        .and_then(|value| value.as_str())
4044        .filter(|text| !text.trim().is_empty())
4045        .map(ToOwned::to_owned)
4046}
4047
4048fn extension_entry_component(
4049    session: &crate::session::ExtensionSessionCell,
4050    custom_type: &str,
4051    data: Option<serde_json::Value>,
4052) -> Option<Arc<dyn rpi_tui::Component>> {
4053    let payload = serde_json::json!({
4054        "customType": custom_type,
4055        "data": data,
4056    });
4057    let value = invoke_extension_renderer(
4058        session,
4059        rpi_extensions::RegisteredRendererKind::Entry,
4060        &payload,
4061    )?;
4062    extension_text_component(&value)
4063}
4064
4065/// Project an assistant message's content into the provider-free
4066/// [`AssistantBlock`] list (text, thinking, and decoded image blocks, in
4067/// document order) the `AssistantMessageComponent` renders. Tool-call blocks
4068/// are rendered by their own components in the transcript.
4069/// Whether startup intentionally opened a session that already has history.
4070fn launch_restores_history(args: &Args) -> bool {
4071    args.continue_session
4072        || args.resume
4073        || args.session.is_some()
4074        || args.session_id.is_some()
4075        || args.fork.is_some()
4076}
4077
4078fn assistant_blocks(msg: &AssistantMessage) -> Vec<AssistantBlock> {
4079    msg.content
4080        .iter()
4081        .filter_map(|c| match c {
4082            Content::Text(t) => Some(AssistantBlock::Text(t.text.clone())),
4083            Content::Thinking(t) => Some(AssistantBlock::Thinking(t.thinking.clone())),
4084            Content::Image(image) => base64::engine::general_purpose::STANDARD
4085                .decode(&image.data)
4086                .ok()
4087                .filter(|data| !data.is_empty())
4088                .map(AssistantBlock::Image),
4089            _ => None,
4090        })
4091        .collect()
4092}
4093
4094fn custom_message_fallback(custom: &rpi_agent::CustomMessage) -> String {
4095    let content = custom
4096        .content
4097        .iter()
4098        .filter_map(|item| match item {
4099            Content::Text(text) => Some(text.text.as_str()),
4100            _ => None,
4101        })
4102        .collect::<Vec<_>>()
4103        .join("\n");
4104    if content.is_empty() {
4105        format!("{}: {}", custom.role, custom.data)
4106    } else {
4107        format!("{}: {}", custom.role, content)
4108    }
4109}
4110
4111/// The name displayed for a model id (last path segment / after the final
4112/// `:`), to keep the footer compact.
4113fn short_model_name(id: &str) -> String {
4114    id.rsplit([':', '/'])
4115        .next()
4116        .filter(|s| !s.is_empty())
4117        .unwrap_or(id)
4118        .to_string()
4119}
4120
4121// ===========================================================================
4122// Streaming run status
4123// ===========================================================================
4124
4125/// The live status of the agent run, fed to the footer + status slot.
4126#[derive(Clone, Copy, PartialEq, Eq)]
4127enum RunStatus {
4128    Idle,
4129    Working,
4130    Aborting,
4131}
4132
4133/// Which selector overlay (if any) is currently swapped into the editor slot.
4134#[derive(Clone, Copy, PartialEq, Eq)]
4135enum SelectorKind {
4136    /// `/model` — available models (live switch via `lane.set_model`).
4137    Model,
4138    /// `/thinking` — supported thinking levels (live via `lane.set_thinking_level`).
4139    Thinking,
4140    /// `/tools` — toggle builtin tools on/off.
4141    Tools,
4142    /// `/images` — toggle inline image rendering.
4143    Images,
4144    /// `/session` — browse and switch saved JSONL sessions.
4145    Session,
4146    /// `/theme` — dark / light / monochrome presets applied live.
4147    Theme,
4148    /// `/scoped-models` — multi-toggle Ctrl+M cycle scope.
4149    ScopedModels,
4150    /// `/settings` — interactive settings menu (and its sub-selectors).
4151    Settings,
4152    /// `/tree` — navigate to an existing entry in the current session.
4153    Tree,
4154    /// Extension-provided selector; uses the same keyboard contract.
4155    Extension,
4156}
4157
4158/// Shared mutable TUI state, `Arc`-cloned into the drain task, the key loop,
4159/// and the render-tick task.
4160struct TuiState {
4161    /// The in-flight streaming assistant message (cleared on finalize).
4162    current_assistant: std::sync::Mutex<Option<Arc<AssistantMessageComponent>>>,
4163    /// Tool-execution components keyed by `tool_call_id`.
4164    tool_components: std::sync::Mutex<HashMap<String, Arc<ToolExecutionComponent>>>,
4165    /// Bash-execution components keyed by `tool_call_id` (kept separate from the
4166    /// generic tool map so bash output streams into a `BashExecutionComponent`
4167    /// rather than a plain `ToolExecutionComponent`). Phase 5 routing.
4168    bash_components: std::sync::Mutex<HashMap<String, Arc<BashExecutionComponent>>>,
4169    /// Whether package/custom themes may be selected in this session.
4170    themes_enabled: bool,
4171    /// Persisted display preference toggled by Ctrl+T.
4172    hide_thinking: std::sync::Mutex<bool>,
4173    /// Global tool-output expansion preference toggled by Ctrl+O.
4174    tool_outputs_expanded: std::sync::Mutex<bool>,
4175    /// Whether the native-style terminal progress indicator is enabled.
4176    show_terminal_progress: bool,
4177    /// Run status for the status indicator + interrupt routing.
4178    status: std::sync::Mutex<RunStatus>,
4179    /// Cancellation signal for the short phase that starts the persistent JS
4180    /// host and runs `before_agent_start`. The key thread can trigger this
4181    /// directly while the async message loop is awaiting the blocking worker.
4182    js_preparation_cancel: std::sync::Mutex<Option<CancellationToken>>,
4183    /// The footer, updated live by the drain task.
4184    footer: Arc<FooterComponent>,
4185    /// The status-container (status slot in the dock) — cleared/filled with a
4186    /// loader while a run is active.
4187    status_container: Arc<Container>,
4188    /// The chat transcript container.
4189    chat_container: Arc<Container>,
4190    /// The active loader shown while `Working`.
4191    loader: Arc<Loader>,
4192    /// The last finalized assistant text (for `/copy`). Updated by the drain
4193    /// task on `MessageEnd` / `AgentEnd`.
4194    last_assistant_text: std::sync::Mutex<String>,
4195    /// The active selector overlay, swapped into the editor slot. `Some` while
4196    /// a selector is open; the key loop routes to it first and restores the
4197    /// editor on done/cancel.
4198    active_selector: std::sync::Mutex<Option<(Arc<SelectList>, SelectorKind)>>,
4199    /// Extension-provided editor currently occupying the input slot.
4200    active_extension_editor: std::sync::Mutex<Option<Arc<Editor>>>,
4201    /// Single-line input currently occupying the input slot for an extension.
4202    active_extension_input: std::sync::Mutex<Option<Arc<Input>>>,
4203    /// Callback used to resolve an extension dialog with a cancellation action.
4204    active_extension_cancel: std::sync::Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
4205    /// The autocomplete manager (slash + @file providers) consulted on every
4206    /// editor keystroke.
4207    autocomplete: AutocompleteManager,
4208    /// The container rendered above the editor holding the live autocomplete
4209    /// suggestion list (cleared when there are no suggestions).
4210    autocomplete_container: Arc<Container>,
4211    /// Maximum number of autocomplete rows rendered above the editor.
4212    autocomplete_max_visible: usize,
4213    /// Images queued from clipboard paste and attached to the next prompt.
4214    pending_images: std::sync::Mutex<Vec<rpi_ai::types::ImageContent>>,
4215    /// The owned theme manager — `/theme` applies presets here. The global
4216    /// `theme()` is read-only after OnceLock init, so per-instance state is the
4217    /// only way to apply a preset at runtime.
4218    theme_manager: Arc<ThemeManager>,
4219    /// The alt-screen handle, held so `set_status` can reflect run state in the
4220    /// terminal window title ("rpi — working" / "rpi"). `None` in unit tests
4221    /// that never call `set_status` with a title.
4222    tui: Option<Arc<TuiAltScreen>>,
4223    /// The model id currently shown in the footer + used as the Ctrl+M
4224    /// cycle anchor. Sync-tracked (updated on every `/model`/Ctrl+M switch) so
4225    /// the blocking key loop can cycle without awaiting `lane.get_model()`.
4226    current_model_id: std::sync::Mutex<String>,
4227    /// Whether inline image rendering is enabled (`/images` toggle). Stored
4228    /// even though image wiring is minimal this pass — the flag is consulted
4229    /// where images would be shown and echoed back by `/images`.
4230    show_images: std::sync::Mutex<bool>,
4231    /// Submitted-message history for ↑/↓ recall, most recent first (mirrors
4232    /// the TS editor `history` array). Bounded at [`HISTORY_LIMIT`].
4233    history: std::sync::Mutex<Vec<String>>,
4234    /// Browse index while recalling history: -1 = not browsing, 0 = most
4235    /// recent, 1 = older, … Reset to -1 on every submit.
4236    history_index: std::sync::Mutex<isize>,
4237    /// The editor text captured when entering browse mode, restored when the
4238    /// user navigates back past the newest entry (TS `historyDraft`).
4239    history_draft: std::sync::Mutex<Option<String>>,
4240    /// The previous turn's input token count, used by the cache-miss notice:
4241    /// a large input that reads nothing from cache after an established prefix
4242    /// means the prefix was re-billed (simplified `maybeShowCacheMissNotice`).
4243    last_input_tokens: std::sync::Mutex<i64>,
4244    /// The in-progress scoped-models selection while the `/scoped-models`
4245    /// selector is open (toggle per item, Esc saves). `None` when not editing.
4246    scoped_edit: std::sync::Mutex<Option<Vec<String>>>,
4247    /// B5e: the live assistant-markdown transformer, built from the current
4248    /// `RegistrySnapshot`'s `register_markdown_transformer` handlers. `None`
4249    /// when no markdown transformers are registered (identity render path).
4250    /// Swapped on `/reload` (a fresh snapshot ⇒ a fresh closure; the old
4251    /// closure no-ops once its snapshot's `active` flag flips false) and
4252    /// re-installed on the in-flight `current_assistant` so a reloaded plugin's
4253    /// transform takes effect on the visible streaming message immediately.
4254    /// New assistant components pick up whatever closure is current at
4255    /// construction time via [`install_markdown_transformer`].
4256    markdown_transformer: std::sync::Mutex<Option<MarkdownTransformer>>,
4257    /// Live extension registry used by message/entry renderer dispatch.
4258    extension_session: crate::session::ExtensionSessionCell,
4259    /// Transcript search handler (Ctrl+Shift+F).
4260    search: Arc<AltScreenSearch>,
4261    /// Search bar component shown when search is active.
4262    search_bar: Arc<SearchBar>,
4263    /// Fullscreen selection tracking for auto-copy.
4264    selection_start: std::sync::Mutex<Option<(u16, u16)>>,
4265    selection_end: std::sync::Mutex<Option<(u16, u16)>>,
4266}
4267
4268/// How many submitted messages are kept for ↑ recall (mirrors the TS
4269/// editor's 100-entry cap).
4270const HISTORY_LIMIT: usize = 100;
4271
4272/// A turn with at least this many input tokens is worth a cache-miss notice
4273/// when nothing was read from cache (matches the TS 20k threshold).
4274const CACHE_MISS_MIN_INPUT_TOKENS: i64 = 20_000;
4275
4276/// Keep a few rows of overlap so page scrolling preserves visual context,
4277/// matching the upstream fullscreen viewport behavior.
4278const PAGE_SCROLL_OVERLAP: usize = 4;
4279
4280/// Native pi scrolls a small chunk for each wheel notch rather than moving the
4281/// transcript one physical row at a time. Three lines stays precise while
4282/// avoiding the sluggish feel of the previous implementation.
4283const MOUSE_WHEEL_SCROLL_LINES: i32 = 3;
4284
4285/// Parse the compact key notation used by native Pi settings (for example
4286/// `ctrl+g`, `shift+tab`, or `escape`) into crossterm's representation.
4287fn parse_configured_key(value: &str) -> Option<rpi_tui::KeyCombo> {
4288    let mut modifiers = KeyModifiers::NONE;
4289    let mut key = None;
4290    for part in value.trim().to_ascii_lowercase().split('+') {
4291        match part {
4292            "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
4293            "shift" => modifiers |= KeyModifiers::SHIFT,
4294            "alt" | "option" => modifiers |= KeyModifiers::ALT,
4295            "super" | "cmd" | "command" | "meta" => modifiers |= KeyModifiers::SUPER,
4296            part if !part.is_empty() => key = Some(part.to_string()),
4297            _ => {}
4298        }
4299    }
4300    let key = key?;
4301    let code = match key.as_str() {
4302        "esc" | "escape" => KeyCode::Esc,
4303        "enter" | "return" => KeyCode::Enter,
4304        "tab" => {
4305            if modifiers.contains(KeyModifiers::SHIFT) {
4306                return Some(rpi_tui::KeyCombo::new(
4307                    KeyCode::BackTab,
4308                    modifiers & !KeyModifiers::SHIFT,
4309                ));
4310            }
4311            KeyCode::Tab
4312        }
4313        "backspace" | "back" => KeyCode::Backspace,
4314        "delete" | "del" => KeyCode::Delete,
4315        "up" | "arrowup" => KeyCode::Up,
4316        "down" | "arrowdown" => KeyCode::Down,
4317        "left" | "arrowleft" => KeyCode::Left,
4318        "right" | "arrowright" => KeyCode::Right,
4319        "home" => KeyCode::Home,
4320        "end" => KeyCode::End,
4321        "pageup" | "page-up" => KeyCode::PageUp,
4322        "pagedown" | "page-down" => KeyCode::PageDown,
4323        "space" => KeyCode::Char(' '),
4324        "f1" => KeyCode::F(1),
4325        "f2" => KeyCode::F(2),
4326        "f3" => KeyCode::F(3),
4327        "f4" => KeyCode::F(4),
4328        "f5" => KeyCode::F(5),
4329        "f6" => KeyCode::F(6),
4330        "f7" => KeyCode::F(7),
4331        "f8" => KeyCode::F(8),
4332        "f9" => KeyCode::F(9),
4333        "f10" => KeyCode::F(10),
4334        "f11" => KeyCode::F(11),
4335        "f12" => KeyCode::F(12),
4336        value if value.chars().count() == 1 => KeyCode::Char(value.chars().next().unwrap()),
4337        _ => return None,
4338    };
4339    Some(rpi_tui::KeyCombo::new(code, modifiers))
4340}
4341
4342fn configured_keybindings() -> Arc<rpi_tui::Keybindings> {
4343    let mut bindings = rpi_tui::Keybindings::new();
4344    let settings = crate::settings::load_settings().unwrap_or_default();
4345    let Some(overrides) = settings.keybindings else {
4346        rpi_tui::set_keybindings(bindings.clone());
4347        return Arc::new(bindings);
4348    };
4349    let known: &[(&str, rpi_tui::KeybindingId)] = &[
4350        ("app.interrupt", rpi_tui::keybindings::keys::INTERRUPT),
4351        ("app.clear", rpi_tui::keybindings::keys::CLEAR),
4352        ("app.exit", rpi_tui::keybindings::keys::EXIT),
4353        ("app.model.select", rpi_tui::keybindings::keys::MODEL_SELECT),
4354        (
4355            "app.model.cycleForward",
4356            rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
4357        ),
4358        ("app.tools.expand", rpi_tui::keybindings::keys::TOOLS_EXPAND),
4359        (
4360            "app.thinking.toggle",
4361            rpi_tui::keybindings::keys::THINKING_TOGGLE,
4362        ),
4363        (
4364            "app.editor.external",
4365            rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
4366        ),
4367        (
4368            "app.thinking.cycle",
4369            rpi_tui::keybindings::keys::THINKING_CYCLE,
4370        ),
4371        (
4372            "app.clipboard.pasteImage",
4373            rpi_tui::keybindings::keys::PASTE_IMAGE,
4374        ),
4375    ];
4376    for (name, id) in known {
4377        let Some(value) = overrides.get(*name) else {
4378            continue;
4379        };
4380        let values: Vec<String> = match value {
4381            serde_json::Value::String(value) => vec![value.clone()],
4382            serde_json::Value::Array(values) => values
4383                .iter()
4384                .filter_map(|v| v.as_str().map(str::to_string))
4385                .collect(),
4386            serde_json::Value::Null => Vec::new(),
4387            _ => continue,
4388        };
4389        let combos: Vec<_> = values
4390            .iter()
4391            .filter_map(|value| parse_configured_key(value))
4392            .collect();
4393        if values.is_empty() || !combos.is_empty() {
4394            bindings.set(id, combos);
4395        }
4396    }
4397    rpi_tui::set_keybindings(bindings.clone());
4398    Arc::new(bindings)
4399}
4400
4401fn keybinding_matches(
4402    bindings: &rpi_tui::Keybindings,
4403    event: &crossterm::event::KeyEvent,
4404    id: rpi_tui::KeybindingId,
4405) -> bool {
4406    if bindings.matches(event, id) {
4407        return true;
4408    }
4409    // crossterm reports Shift+Tab as BackTab on some terminals and as Tab
4410    // plus Shift on others. Treat both forms as the same configured action.
4411    if event.code == KeyCode::BackTab {
4412        let normalized =
4413            crossterm::event::KeyEvent::new(KeyCode::Tab, event.modifiers | KeyModifiers::SHIFT);
4414        bindings.matches(&normalized, id)
4415    } else {
4416        false
4417    }
4418}
4419
4420fn double_escape_trigger(last: Option<std::time::Instant>, now: std::time::Instant) -> bool {
4421    last.is_some_and(|previous| {
4422        now.duration_since(previous) <= std::time::Duration::from_millis(500)
4423    })
4424}
4425
4426/// How long to wait for queued console input before treating a bare Enter as a
4427/// real submit. Pasted input is already in the queue, so this only needs to
4428/// cover scheduler latency — small enough that a human Enter feels instant.
4429const PASTE_PROBE: std::time::Duration = std::time::Duration::from_millis(4);
4430
4431/// Maximum gap between a pasted character and the next pasted key. A human
4432/// cannot deliver two keypresses this fast, so anything tighter is paste.
4433const PASTE_BURST_GAP: std::time::Duration = std::time::Duration::from_millis(20);
4434
4435/// Whether a bare Enter is pasted content (insert a newline) rather than a
4436/// submit.
4437///
4438/// `crossterm` 0.27 only implements bracketed paste on Unix; on Windows the
4439/// console event source never produces `Event::Paste`, so a pasted block
4440/// arrives as plain key events with a bare Enter per line. Two timing signals
4441/// separate a pasted Enter from a typed one:
4442///
4443/// - `more_queued`: the paste's remaining characters are already sitting in the
4444///   console input queue, so a non-blocking poll finds them immediately.
4445/// - `last_text_key_at`: the Enter lands within [`PASTE_BURST_GAP`] of the
4446///   preceding pasted character, which also catches a paste that *ends* with a
4447///   newline (nothing queued behind it, but it was not typed by hand).
4448fn enter_is_paste_burst(
4449    last_text_key_at: Option<std::time::Instant>,
4450    now: std::time::Instant,
4451    more_queued: bool,
4452) -> bool {
4453    if more_queued {
4454        return true;
4455    }
4456    last_text_key_at.is_some_and(|previous| now.duration_since(previous) < PASTE_BURST_GAP)
4457}
4458
4459fn transcript_page_size(viewport_height: usize) -> i32 {
4460    viewport_height
4461        .saturating_sub(PAGE_SCROLL_OVERLAP)
4462        .max(1)
4463        .min(i32::MAX as usize) as i32
4464}
4465
4466fn should_dispatch_key(kind: KeyEventKind) -> bool {
4467    kind != KeyEventKind::Release
4468}
4469
4470/// Compact token count for the cache-miss notice: 1.2M / 34.5K / 900.
4471fn format_tokens(n: i64) -> String {
4472    if n >= 1_000_000 {
4473        format!("{:.1}M", n as f64 / 1_000_000.0)
4474    } else if n >= 1_000 {
4475        format!("{:.1}K", n as f64 / 1_000.0)
4476    } else {
4477        n.to_string()
4478    }
4479}
4480
4481/// Record a submitted message for ↑ recall (mirrors TS `addToHistory`):
4482/// trims, skips empty + consecutive duplicates, caps at [`HISTORY_LIMIT`], and
4483/// resets the browse state so a fresh prompt never resumes mid-history.
4484fn push_history(state: &Arc<TuiState>, text: &str) {
4485    let trimmed = text.trim().to_string();
4486    if trimmed.is_empty() {
4487        return;
4488    }
4489    let mut history = state.history.lock().unwrap();
4490    if history.first() == Some(&trimmed) {
4491        return;
4492    }
4493    history.insert(0, trimmed);
4494    history.truncate(HISTORY_LIMIT);
4495    *state.history_index.lock().unwrap() = -1;
4496    *state.history_draft.lock().unwrap() = None;
4497}
4498
4499/// Navigate message history. `direction` is -1 (↑, older) or 1 (↓, newer).
4500/// Mirrors TS `navigateHistory`: the first entry into browse mode stashes the
4501/// current editor text as the draft; navigating back past the newest entry
4502/// restores that draft.
4503fn navigate_history(state: &Arc<TuiState>, editor: &Arc<Editor>, direction: i32) {
4504    let history = state.history.lock().unwrap();
4505    if history.is_empty() {
4506        return;
4507    }
4508    let mut index = state.history_index.lock().unwrap();
4509    let new_index = *index - direction as isize;
4510    if new_index < -1 || new_index >= history.len() as isize {
4511        return;
4512    }
4513    if *index == -1 && new_index >= 0 {
4514        // Entering browse mode: stash the current input.
4515        *state.history_draft.lock().unwrap() = Some(editor.get_text());
4516    }
4517    *index = new_index;
4518    if new_index == -1 {
4519        // Exited browse mode: restore the draft (or clear if there was none).
4520        let draft = state.history_draft.lock().unwrap().take();
4521        match draft {
4522            Some(d) => {
4523                let len = d.len();
4524                editor.set_text(&d);
4525                editor.set_cursor(0, len);
4526            }
4527            None => editor.set_text(""),
4528        }
4529    } else {
4530        let text = history[new_index as usize].clone();
4531        let len = text.len();
4532        editor.set_text(&text);
4533        editor.set_cursor(0, len);
4534    }
4535}
4536
4537impl TuiState {
4538    fn begin_js_preparation(&self) -> CancellationToken {
4539        let cancellation = CancellationToken::new();
4540        if let Some(previous) = self
4541            .js_preparation_cancel
4542            .lock()
4543            .unwrap()
4544            .replace(cancellation.clone())
4545        {
4546            previous.cancel();
4547        }
4548        cancellation
4549    }
4550
4551    fn finish_js_preparation(&self) {
4552        self.js_preparation_cancel.lock().unwrap().take();
4553    }
4554
4555    fn cancel_js_preparation(&self) -> bool {
4556        let cancellation = self.js_preparation_cancel.lock().unwrap().take();
4557        if let Some(cancellation) = cancellation {
4558            cancellation.cancel();
4559            true
4560        } else {
4561            false
4562        }
4563    }
4564
4565    fn set_status(&self, status: RunStatus) {
4566        *self.status.lock().unwrap() = status;
4567        self.apply_status(status);
4568    }
4569
4570    /// Atomically reserve the single interactive run slot. The editor callback
4571    /// runs on a different thread from the async prompt loop, so checking and
4572    /// setting in separate steps would allow rapid Enter presses to queue more
4573    /// than one operation.
4574    fn try_start_working(&self) -> bool {
4575        let mut status = self.status.lock().unwrap();
4576        if *status != RunStatus::Idle {
4577            return false;
4578        }
4579        *status = RunStatus::Working;
4580        drop(status);
4581        self.apply_status(RunStatus::Working);
4582        true
4583    }
4584
4585    fn apply_status(&self, status: RunStatus) {
4586        match status {
4587            RunStatus::Working => {
4588                // The status dock already shows the active loader. Keep the
4589                // footer focused on the model and shortcuts instead of
4590                // repeating a second `Working…` at the bottom of the TUI.
4591                self.footer.set_status("");
4592                // Reflect the in-flight turn in the terminal window/tab title
4593                // (OSC 2). No-op when `tui` is absent (unit tests).
4594                if let Some(tui) = &self.tui {
4595                    tui.set_title("rpi — working");
4596                }
4597                self.status_container.clear();
4598                if self.show_terminal_progress {
4599                    self.loader.start();
4600                    self.status_container.add_child(self.loader.clone());
4601                }
4602            }
4603            RunStatus::Aborting => {
4604                self.footer.set_status("Aborting…");
4605                // Do not leave a frozen "Working" spinner on screen after the
4606                // render tick intentionally stops advancing in this state.
4607                self.loader.stop();
4608                self.status_container.clear();
4609            }
4610            RunStatus::Idle => {
4611                self.footer.set_status("");
4612                if let Some(tui) = &self.tui {
4613                    tui.set_title("rpi");
4614                }
4615                self.loader.stop();
4616                self.status_container.clear();
4617            }
4618        }
4619    }
4620
4621    /// The bash panel has its own `Running...` spinner. Keep the global
4622    /// `Working...` loader out of the status slot while any bash tool is active
4623    /// so the same operation is not presented as two simultaneous loaders.
4624    fn sync_working_loader_with_bash(&self) {
4625        if *self.status.lock().unwrap() != RunStatus::Working {
4626            return;
4627        }
4628
4629        self.status_container.clear();
4630        if self.show_terminal_progress && self.bash_components.lock().unwrap().is_empty() {
4631            self.status_container.add_child(self.loader.clone());
4632        }
4633    }
4634
4635    fn show_retry(&self, attempt: u32, max_retries: u32, delay_ms: u64) {
4636        *self.status.lock().unwrap() = RunStatus::Working;
4637        self.footer.set_status("");
4638        if let Some(tui) = &self.tui {
4639            tui.set_title("rpi — retrying");
4640        }
4641        self.loader.stop();
4642        self.status_container.clear();
4643        self.status_container
4644            .add_child(Arc::new(StatusIndicator::retry(
4645                attempt,
4646                max_retries,
4647                std::time::Duration::from_millis(delay_ms),
4648            )));
4649    }
4650
4651    /// Whether a selector overlay is currently open (routes keys to it first).
4652    fn selector_open(&self) -> bool {
4653        self.active_selector.lock().unwrap().is_some()
4654    }
4655
4656    fn extension_editor_open(&self) -> bool {
4657        self.active_extension_editor.lock().unwrap().is_some()
4658    }
4659
4660    fn extension_input_open(&self) -> bool {
4661        self.active_extension_input.lock().unwrap().is_some()
4662    }
4663
4664    fn extension_dialog_open(&self) -> bool {
4665        self.extension_editor_open() || self.extension_input_open()
4666    }
4667
4668    fn set_hide_thinking(&self, hide: bool) {
4669        *self.hide_thinking.lock().unwrap() = hide;
4670        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4671            comp.set_hide_thinking(hide);
4672        }
4673    }
4674
4675    fn hide_thinking(&self) -> bool {
4676        *self.hide_thinking.lock().unwrap()
4677    }
4678
4679    fn toggle_thinking(&self) -> bool {
4680        let next = !self.hide_thinking();
4681        self.set_hide_thinking(next);
4682        next
4683    }
4684
4685    fn toggle_tool_outputs(&self) -> bool {
4686        let next = !*self.tool_outputs_expanded.lock().unwrap();
4687        *self.tool_outputs_expanded.lock().unwrap() = next;
4688        for comp in self.tool_components.lock().unwrap().values() {
4689            comp.set_expanded(next);
4690        }
4691        for comp in self.bash_components.lock().unwrap().values() {
4692            comp.set_expanded(next);
4693        }
4694        next
4695    }
4696
4697    /// The model id currently tracked as active (footer + Ctrl+M anchor).
4698    fn current_model_id(&self) -> String {
4699        self.current_model_id.lock().unwrap().clone()
4700    }
4701
4702    /// Update the tracked model id + footer label after a switch (live or
4703    /// cycle). Called from the `/model` on_select and the Ctrl+M handler.
4704    fn set_current_model(&self, model: &rpi_ai::Model) {
4705        *self.current_model_id.lock().unwrap() = model.id.clone();
4706        self.footer.set_model(&short_model_name(&model.id));
4707    }
4708
4709    /// B5e: read a clone of the current assistant-markdown transformer (if any).
4710    /// New assistant components call this at construction so they render with
4711    /// whatever plugin `register_markdown_transformer` handlers are live.
4712    fn markdown_transformer(&self) -> Option<MarkdownTransformer> {
4713        self.markdown_transformer.lock().unwrap().clone()
4714    }
4715
4716    /// B5e: swap the live transformer. Used at startup (install the first
4717    /// closure built from the initial `RegistrySnapshot`) and on `/reload`
4718    /// (rebuild from the fresh snapshot). On a reload the reloaded plugin's
4719    /// transform should take effect on the VISIBLE streaming message too, so
4720    /// this re-installs on the in-flight `current_assistant` component — its
4721    /// `set_markdown_transformer` rebuilds the last blocks immediately. A
4722    /// `None` clears the transform (identity), e.g. a reload that unregisters
4723    /// every markdown transformer.
4724    fn set_markdown_transformer_with_reinstall(&self, transformer: Option<MarkdownTransformer>) {
4725        *self.markdown_transformer.lock().unwrap() = transformer.clone();
4726        if let Some(comp) = self.current_assistant.lock().unwrap().as_ref() {
4727            comp.set_markdown_transformer(transformer);
4728        }
4729    }
4730
4731    fn queue_image(&self, image: rpi_ai::types::ImageContent) {
4732        self.pending_images.lock().unwrap().push(image);
4733    }
4734
4735    fn take_pending_images(&self) -> Vec<rpi_ai::types::ImageContent> {
4736        std::mem::take(&mut *self.pending_images.lock().unwrap())
4737    }
4738}
4739
4740// ===========================================================================
4741// interactive_tui — the entry point
4742// ===========================================================================
4743
4744#[derive(Debug, PartialEq)]
4745struct TuiStartupSettings {
4746    editor_padding_x: usize,
4747    autocomplete_max_visible: usize,
4748    hide_thinking: bool,
4749    quiet_startup: bool,
4750    show_terminal_progress: bool,
4751}
4752
4753fn preferred_project_setting<T>(
4754    project_settings: &[crate::settings::Settings],
4755    field: impl Fn(&crate::settings::Settings) -> Option<T>,
4756) -> Option<T> {
4757    project_settings.iter().find_map(field)
4758}
4759
4760fn should_show_startup_listing(verbose: bool, quiet_startup: bool) -> bool {
4761    verbose || !quiet_startup
4762}
4763
4764fn git_only_update_resources(
4765    mut resources: crate::packages::PackageResources,
4766) -> crate::packages::PackageResources {
4767    resources
4768        .packages
4769        .retain(|package| matches!(package.source, crate::packages::PackageSource::Git));
4770    resources
4771}
4772
4773/// Resolve TUI-only settings with native Pi's project-over-global precedence.
4774/// `project_settings` must be ordered `.rpi` then `.pi`; an explicit `Some`
4775/// wins even when the value is `false` or zero.
4776fn resolve_tui_startup_settings(
4777    global: &crate::settings::Settings,
4778    project_settings: &[crate::settings::Settings],
4779    project_trusted: bool,
4780) -> TuiStartupSettings {
4781    let project_settings = if project_trusted {
4782        project_settings
4783    } else {
4784        &[]
4785    };
4786    let editor_padding_x =
4787        preferred_project_setting(project_settings, |settings| settings.editor_padding_x)
4788            .or(global.editor_padding_x)
4789            .unwrap_or(1)
4790            .min(16);
4791    let autocomplete_max_visible = preferred_project_setting(project_settings, |settings| {
4792        settings.autocomplete_max_visible
4793    })
4794    .or(global.autocomplete_max_visible)
4795    .unwrap_or(5)
4796    .clamp(1, 20);
4797    let hide_thinking =
4798        preferred_project_setting(project_settings, |settings| settings.hide_thinking_block)
4799            .or(global.hide_thinking_block)
4800            .unwrap_or(false);
4801    let quiet_startup =
4802        preferred_project_setting(project_settings, |settings| settings.quiet_startup)
4803            .or(global.quiet_startup)
4804            .unwrap_or(false);
4805    let show_terminal_progress =
4806        preferred_project_setting(project_settings, |settings| settings.show_terminal_progress)
4807            .or(global.show_terminal_progress)
4808            .unwrap_or(true);
4809
4810    TuiStartupSettings {
4811        editor_padding_x,
4812        autocomplete_max_visible,
4813        hide_thinking,
4814        quiet_startup,
4815        show_terminal_progress,
4816    }
4817}
4818
4819/// TUI-based interactive mode.
4820///
4821/// `event_rx` carries the live `AgentEvent` stream (installed by
4822/// [`crate::session::build`]); when `None` (e.g. a non-TUI caller reuses this
4823/// fn), it falls back to a blocking, await-final-text path.
4824///
4825/// `model_catalog` is the read-only catalog the `/model` selector displays.
4826///
4827/// This implementation mirrors the TypeScript `InteractiveMode` class:
4828/// build the layout root once, drain `AgentEvent`s into UI mutations that
4829/// mirror `handleEvent`, and dispatch keys from a `spawn_blocking` crossterm
4830/// loop (the `TuiAltScreen` start() handler is a stub). Selectors and
4831/// autocomplete are layered on via the editor-container swap pattern.
4832pub async fn interactive_tui(
4833    harness: &AgentHarness,
4834    event_rx: Option<broadcast::Receiver<AgentEvent>>,
4835    args: &Args,
4836    model_catalog: Vec<rpi_ai::Model>,
4837    initial: Option<String>,
4838    extra_messages: &[String],
4839    initial_images: Vec<rpi_ai::types::ImageContent>,
4840    theme: Option<&str>,
4841    no_themes: bool,
4842    reload_context: &crate::session::ReloadContext,
4843) -> i32 {
4844    let lane: Arc<dyn AgentLane> = harness.lane("main");
4845    // Reuse the startup snapshot captured before provider/session setup. An
4846    // extension may mutate the process cwd while loading; that must not change
4847    // which project settings or package update roots this TUI observes.
4848    let cwd = reload_context.cwd.clone();
4849    let saved_settings = crate::settings::load_settings().unwrap_or_default();
4850    let project_trusted = reload_context.project_trusted;
4851    let project_settings = if project_trusted {
4852        crate::settings::load_project_settings(&cwd)
4853    } else {
4854        Vec::new()
4855    };
4856    let tui_settings =
4857        resolve_tui_startup_settings(&saved_settings, &project_settings, project_trusted);
4858    let editor_padding_x = tui_settings.editor_padding_x;
4859    let autocomplete_max_visible = tui_settings.autocomplete_max_visible;
4860    let mut hide_thinking = tui_settings.hide_thinking;
4861    let show_terminal_progress = tui_settings.show_terminal_progress;
4862    let quiet_startup = tui_settings.quiet_startup;
4863    let update_checks_enabled =
4864        !args.offline && std::env::var_os("RPI_DISABLE_UPDATE_CHECK").is_none();
4865
4866    // Session display preferences (durable, latest-wins) override the
4867    // settings-file defaults. These are per-session facts stored as custom
4868    // entries (see `rpi_harness::session::values`), so resuming a session with
4869    // `-c`/`-r` restores the thinking/tool-output display the user last chose
4870    // there — independent of global settings. Absent keys leave the settings
4871    // value untouched.
4872    let session_values = rpi_harness::session::SessionValues::load(harness.session())
4873        .await
4874        .unwrap_or_default();
4875    if let Some(persisted) = session_values.get_as::<bool>("display.hide_thinking") {
4876        hide_thinking = persisted;
4877    }
4878
4879    // Resolve the active model once, up front. The full id feeds the TuiState
4880    // tracking field + the selectors/key loop (which run on a blocking thread
4881    // and can't await `lane.get_model()`); the short name feeds the footer.
4882    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
4883    let model_name = short_model_name(&lane_model_id);
4884
4885    // Snapshot startup capabilities for the welcome screen. Both accessors
4886    // return defensive clones, so rendering this summary does not retain a
4887    // harness lock or trigger a second resource scan.
4888    let mut active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4889    // AgentHarness uses an empty active-name list as the default "all tools"
4890    // state. Do not expose that implementation sentinel as `Tools (0) none`
4891    // in the welcome banner (it is especially visible on the first prompt).
4892    if active_tool_names.is_empty() {
4893        active_tool_names = harness
4894            .get_tools()
4895            .await
4896            .unwrap_or_default()
4897            .into_iter()
4898            .map(|tool| tool.tool.schema().name.clone())
4899            .collect();
4900    }
4901    let resources_snapshot = harness.get_resources().await.unwrap_or_default();
4902    let skill_names: Vec<String> = resources_snapshot
4903        .skills
4904        .as_deref()
4905        .unwrap_or(&[])
4906        .iter()
4907        .map(|skill| skill.name.clone())
4908        .collect();
4909
4910    // Resolve package resources for @file autocomplete + session discovery.
4911    let package_resources = reload_context.package_resources.clone();
4912
4913    // Channel between the key/callback threads and the main async loop.
4914    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMessage>();
4915
4916    // Apply the saved theme before constructing transcript components. Some
4917    // components keep styled text, so doing this after the welcome banner left
4918    // the first screen in the dark palette until it was rebuilt.
4919    let theme_manager = Arc::new(ThemeManager::new());
4920    if let Some(preset) = match theme {
4921        Some("light") => Some(ThemePreset::Light),
4922        Some("monochrome") => Some(ThemePreset::Monochrome),
4923        Some("dark") => Some(ThemePreset::Dark),
4924        _ => None,
4925    } {
4926        apply_theme_preset(preset);
4927        theme_manager.apply_preset(preset);
4928    } else if !no_themes {
4929        if let Some(name) = theme {
4930            match crate::packages::load_theme_with_resources(&cwd, name, &package_resources) {
4931                Ok(custom) => {
4932                    rpi_tui::global_theme_manager().set(custom.clone());
4933                    theme_manager.set(custom);
4934                }
4935                Err(error) => {
4936                    eprintln!("warning: could not load package theme `{name}`: {error}");
4937                }
4938            }
4939        }
4940    }
4941
4942    // ---- TUI + containers ----
4943    let terminal = Box::new(ProcessTerminal::new());
4944    let tui = Arc::new(TuiAltScreen::new(terminal, true, None));
4945    let js_dialog_bridge = Arc::new(JsDialogBridge::default());
4946    // Plugin `ask_user` prompts (runtime action 17). Attaching marks this
4947    // session as interactive so a detached/headless host fails loudly instead
4948    // of returning a fabricated answer. The mailbox is session-scoped (held in
4949    // `ReloadContext`) so in-flight prompts survive a `/reload`.
4950    let ask_user_bridge = AskUserBridge::new(reload_context.ui_dialog.clone());
4951    ask_user_bridge.attach();
4952    tui.set_main_screen_mode(matches!(args.tui_mode, crate::args::TuiMode::Regular));
4953
4954    if let Some(js) = &reload_context.js_extension_session {
4955        if let Err(error) = js.install_ui_runtime(tui.clone()) {
4956            eprintln!("warning: could not enable JS custom UI bridge: {error}");
4957        } else if let Some(js_active) = js.active_tools() {
4958            // Apply the discovery-time JS subset while preserving Rust
4959            // built-ins already active in the harness lane. The real TUI
4960            // lifecycle reconciliation runs after the key worker starts below.
4961            let js_names = js.tool_names();
4962            let mut active = lane.get_active_tools().await.unwrap_or_default();
4963            active.retain(|name| {
4964                crate::session::tool_name_allowed(name, args)
4965                    && !js_names.iter().any(|js_name| js_name == name)
4966            });
4967            active.extend(js_active.into_iter().filter(|name| {
4968                js_names.iter().any(|js_name| js_name == name)
4969                    && crate::session::tool_name_allowed(name, args)
4970            }));
4971            active = crate::session::filter_active_tool_names(active, args);
4972            let _ = lane.set_active_tools(active).await;
4973            active_tool_names = lane.get_active_tools().await.unwrap_or_default();
4974        }
4975    }
4976
4977    let chat_container = Arc::new(Container::new());
4978    if let Some(js) = &reload_context.js_extension_session {
4979        // Tool contexts do not have a command-result envelope. Route
4980        // `ctx.ui.notify()` through the live transcript so notifications from
4981        // tools such as ask_user_question are visible immediately.
4982        let chat_notify = chat_container.clone();
4983        let tui_notify = tui.clone();
4984        if let Err(error) = js.add_runtime_handler(Arc::new(move |action, args| {
4985            if action != "ui.notify" {
4986                return Err(format!("unsupported capability: {action}"));
4987            }
4988            let message = args
4989                .get("message")
4990                .and_then(serde_json::Value::as_str)
4991                .unwrap_or_default();
4992            if !message.is_empty() {
4993                if args.get("level").and_then(serde_json::Value::as_str) == Some("error") {
4994                    add_error_message(&chat_notify, message);
4995                } else {
4996                    add_note_message(&chat_notify, message);
4997                }
4998                tui_notify.request_render(false);
4999            }
5000            Ok(serde_json::json!(true))
5001        })) {
5002            if args.verbose {
5003                eprintln!("warning: could not enable JS notification bridge: {error}");
5004            }
5005        }
5006    }
5007    if should_show_startup_listing(args.verbose, quiet_startup) {
5008        add_welcome_message_with_capabilities(&chat_container, &active_tool_names, &skill_names);
5009    }
5010
5011    // First-launch gate: if `~/.rpi/.setup_done` is absent, show the welcome
5012    // banner + the earendil announcement once, then write the sentinel. The TS
5013    // original is a multi-step dialog (theme picker + analytics opt-in); this
5014    // v1 simplifies to a one-shot banner (theme still pickable via `/theme`,
5015    // analytics deferred — no telemetry wiring). See `extras.rs`.
5016    crate::extras::maybe_first_time_setup(&chat_container);
5017
5018    // A --continue/--resume/--session launch opens on an existing JSONL
5019    // session — render its prior user/assistant transcript so the user sees
5020    // where they left off (tool executions are skipped: their live display
5021    // belongs to the current run, and replaying old results would be noise).
5022    let initial_transformer = build_markdown_transformer(
5023        reload_context
5024            .extension_session
5025            .lock()
5026            .unwrap()
5027            .snapshot_arc(),
5028    );
5029    // A normal launch creates a fresh session and must not replay records from
5030    // another/project harness. Only explicit restore/fork modes render prior
5031    // conversation history. This fixes stale prompts appearing every startup.
5032    if launch_restores_history(args) {
5033        render_session_history(
5034            &harness,
5035            &chat_container,
5036            initial_transformer.clone(),
5037            Some(reload_context.extension_session.clone()),
5038        )
5039        .await;
5040    }
5041
5042    // `document_container` wraps the welcome header + chat so the scrollview
5043    // follows the whole transcript (mirrors TS `documentContainer`).
5044    let document_container = Arc::new(Container::new());
5045    document_container.add_child(chat_container.clone());
5046
5047    let scroll_view = Arc::new(ScrollView::new(
5048        document_container.clone(),
5049        ScrollViewOptions {
5050            follow: FollowMode::End,
5051            primary: true,
5052            overscroll: OverscrollMode::Chain,
5053            // Native pi keeps transcript chrome out of the way. Our Auto mode
5054            // has no hide timer yet and therefore became effectively permanent
5055            // after the first wheel event, unlike the upstream experience.
5056            scrollbar: ScrollbarMode::Hidden,
5057            ..Default::default()
5058        },
5059    ));
5060
5061    // ---- Editor ----
5062    // Bordered box matching native pi: no `> ` prompt, no placeholder — the
5063    // editor renders full-width `─` top/bottom borders with padding-only lines
5064    // (see Editor::render). padding_x:1 gives a 1-col inset inside the box.
5065    let keybindings = configured_keybindings();
5066    let editor = Arc::new(Editor::new(
5067        EditorOptions {
5068            padding_x: editor_padding_x,
5069            autocomplete_max_visible,
5070            ..Default::default()
5071        },
5072        EditorStyle::default(),
5073        keybindings.clone(),
5074    ));
5075
5076    // ---- Footer + status ----
5077    let footer = Arc::new(FooterComponent::new());
5078    footer.set_model(&model_name);
5079    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");
5080
5081    let status_container = Arc::new(Container::new());
5082    let loader = Arc::new(Loader::with_text("Working…"));
5083
5084    // ---- Autocomplete (slash commands + @file paths, rooted at cwd) ----
5085    // Prompt templates discovered at session build (Part A2) are surfaced as
5086    // `/`-prefixed entries alongside the built-in slash commands: typing
5087    // `/<name>` in the editor expands the template (mirrors pi
5088    // `expandPromptTemplate`, `agent-session.ts:1124`). The description carries
5089    // the template's frontmatter description (or a fallback) so the autocomplete
5090    // popover shows what each template does.
5091    //
5092    // We snapshot the full resources once (skills + prompt-templates): the
5093    // autocomplete builder consumes the templates, and the `/context` command
5094    // (fired from the blocking submit handler, which can't `.await`) reads the
5095    // snapshot to render the discovered-resources panel without touching the
5096    // harness async accessor.
5097    let template_slash_commands: Vec<SlashCommandEntry> = resources_snapshot
5098        .prompt_templates
5099        .clone()
5100        .unwrap_or_default()
5101        .iter()
5102        .map(|t| SlashCommandEntry {
5103            name: format!("/{}", t.name),
5104            description: t
5105                .description
5106                .clone()
5107                .unwrap_or_else(|| "Expand prompt template".to_string()),
5108        })
5109        .collect();
5110    let resources_arc: Arc<rpi_harness::types::AgentHarnessResources> =
5111        Arc::new(resources_snapshot);
5112    // Build the built-in command registry once — the single source of truth for
5113    // both dispatch and the built-in autocomplete entries. The discovered
5114    // prompt-template commands are merged into the autocomplete list separately
5115    // (they dispatch via template expansion, not the registry); built-ins come
5116    // first so they win on a fuzzy tie.
5117    let mut command_registry = build_builtin_registry();
5118    register_extension_commands(
5119        &mut command_registry,
5120        reload_context.extension_session.clone(),
5121    );
5122    register_js_extension_commands(
5123        &mut command_registry,
5124        reload_context.js_extension_session.clone(),
5125    );
5126    let registry = Arc::new(command_registry);
5127    let mut all_slash_commands = registry.visible_entries();
5128    all_slash_commands.extend(template_slash_commands);
5129    let autocomplete = AutocompleteManager::new();
5130    {
5131        let mut combined = CombinedAutocompleteProvider::new();
5132        combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
5133            all_slash_commands,
5134        )));
5135        combined.add_provider(Arc::new(FilePathAutocompleteProvider::with_root(
5136            cwd.clone(),
5137        )));
5138        autocomplete.set_provider(Arc::new(combined));
5139    }
5140    let autocomplete_container = Arc::new(Container::new());
5141
5142    let tool_outputs_expanded = session_values
5143        .get_as::<bool>("display.tool_outputs_expanded")
5144        .unwrap_or(false);
5145    let state = Arc::new(TuiState {
5146        current_assistant: std::sync::Mutex::new(None),
5147        tool_components: std::sync::Mutex::new(HashMap::new()),
5148        bash_components: std::sync::Mutex::new(HashMap::new()),
5149        themes_enabled: !no_themes,
5150        hide_thinking: std::sync::Mutex::new(hide_thinking),
5151        tool_outputs_expanded: std::sync::Mutex::new(tool_outputs_expanded),
5152        show_terminal_progress,
5153        status: std::sync::Mutex::new(RunStatus::Idle),
5154        js_preparation_cancel: std::sync::Mutex::new(None),
5155        footer: footer.clone(),
5156        status_container: status_container.clone(),
5157        chat_container: chat_container.clone(),
5158        loader: loader.clone(),
5159        last_assistant_text: std::sync::Mutex::new(String::new()),
5160        active_selector: std::sync::Mutex::new(None),
5161        active_extension_editor: std::sync::Mutex::new(None),
5162        active_extension_input: std::sync::Mutex::new(None),
5163        active_extension_cancel: std::sync::Mutex::new(None),
5164        autocomplete,
5165        autocomplete_container: autocomplete_container.clone(),
5166        autocomplete_max_visible,
5167        pending_images: std::sync::Mutex::new(Vec::new()),
5168        theme_manager,
5169        tui: Some(tui.clone()),
5170        current_model_id: std::sync::Mutex::new(lane_model_id.clone()),
5171        show_images: std::sync::Mutex::new(true),
5172        history: std::sync::Mutex::new(Vec::new()),
5173        history_index: std::sync::Mutex::new(-1),
5174        history_draft: std::sync::Mutex::new(None),
5175        last_input_tokens: std::sync::Mutex::new(0),
5176        scoped_edit: std::sync::Mutex::new(None),
5177        markdown_transformer: std::sync::Mutex::new(initial_transformer),
5178        extension_session: reload_context.extension_session.clone(),
5179        search: Arc::new(AltScreenSearch::new()),
5180        search_bar: Arc::new(SearchBar::new()),
5181        selection_start: std::sync::Mutex::new(None),
5182        selection_end: std::sync::Mutex::new(None),
5183    });
5184
5185    // Capture the model catalog + cwd for the selector builders + the key loop
5186    // (the callbacks fire on blocking threads and need owned data).
5187    let model_catalog_arc = Arc::new(model_catalog.clone());
5188    let lane_model_id = lane.get_model().await.map(|m| m.id).unwrap_or_default();
5189
5190    // ---- Layout root (built ONCE; mirrors TS fullscreenLayoutRoot) ----
5191    // root = VStack[ scrollview(basis:0 grow:1 shrink:1 min:1), dock(shrink:1) ]
5192    // dock  = VStack[ status(auto), autocomplete(auto), editor_container(shrink:0 min:3), footer(auto) ]
5193    //
5194    // The scrollview gets `basis(0)` so the constrained stack allocator starts
5195    // it at zero height and grows it to fill the space the dock does not need
5196    // — this keeps the dock (editor borders + footer) pinned to the bottom and
5197    // never shrinks it below the editor's 3 rows (top + content + bottom). The
5198    // editor_container is `shrink(0).min_size(3)` so a tall transcript can
5199    // never clip the input panel below its minimum.
5200    let editor_container = Arc::new(Container::new());
5201    editor_container.add_child(editor.clone());
5202
5203    let dock = Arc::new(VStack::from_children(vec![
5204        StackChild::Entry(StackEntry::new(status_container.clone())),
5205        StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
5206        StackChild::Entry(
5207            StackEntry::new(editor_container.clone())
5208                .shrink(0)
5209                .min_size(3),
5210        ),
5211        StackChild::Entry(StackEntry::new(state.search_bar.clone())),
5212        StackChild::Entry(StackEntry::new(footer.clone())),
5213    ]));
5214
5215    let root = VStack::from_children(vec![
5216        StackChild::Entry(
5217            StackEntry::new(scroll_view.clone())
5218                .basis(0)
5219                .grow(1)
5220                .shrink(1)
5221                .min_size(1),
5222        ),
5223        StackChild::Entry(StackEntry::new(dock).shrink(1)),
5224    ]);
5225
5226    tui.set_layout_root(Some(Arc::new(root)));
5227    tui.set_focus(Some(editor.clone()));
5228    editor.set_focused(true);
5229
5230    // ---- Submit handler (fires on the blocking key thread; must stay sync) ----
5231    //
5232    // The handler captures one `CommandContext` (the set of `*_for_cb` clones
5233    // the old version made individually) + the registry, then routes `/`-text
5234    // through `dispatch_slash` and sends plain text directly. Each command's
5235    // `execute` owns its own effects (selector open, `tx.send`, `tokio::spawn`,
5236    // chat mutation) — the handler itself stays a thin router.
5237    //
5238    // One `CommandContext` is built and cloned for both the submit handler and
5239    // the key loop (Ctrl+L routes `/model` through the same registry); all
5240    // fields are `Arc`/cheap, so the clones are free.
5241    //
5242    // The product-layer `AgentSession` wraps a clone of the harness (the
5243    // harness handle is cheap and shares the session/bus), so `/usage`,
5244    // `/export`, and future product commands go through one surface.
5245    let scoped_models = args
5246        .model
5247        .as_deref()
5248        .map(crate::agent_session::ScopedModel::parse)
5249        .into_iter()
5250        .collect();
5251    let agent_session = Arc::new(crate::agent_session::AgentSession::new(
5252        harness.clone(),
5253        model_catalog.clone(),
5254        scoped_models,
5255        cwd.clone(),
5256    ));
5257    let ctx = CommandContext {
5258        chat: chat_container.clone(),
5259        tui: tui.clone(),
5260        tx: tx.clone(),
5261        state: state.clone(),
5262        editor: editor.clone(),
5263        editor_container: editor_container.clone(),
5264        lane: lane.clone(),
5265        model_catalog: model_catalog_arc.clone(),
5266        lane_model_id: lane_model_id.clone(),
5267        cwd: cwd.clone(),
5268        package_resources: package_resources.clone(),
5269        resources: resources_arc.clone(),
5270        reload_context: Arc::new(reload_context.clone()),
5271        session: agent_session.clone(),
5272    };
5273
5274    if let Some(js) = &reload_context.js_extension_session {
5275        let bridge = js_dialog_bridge.clone();
5276        if let Err(error) = js.install_ui_dialog_runtime(Arc::new(move |action, args| {
5277            bridge.handle_runtime_request(action, args)
5278        })) {
5279            eprintln!("warning: could not enable JS dialog UI bridge: {error}");
5280        }
5281    }
5282
5283    let ctx_for_cb = ctx.clone();
5284    let registry_for_cb = registry.clone();
5285    editor.on_submit(Arc::new(move |text: &str| {
5286        let text = text.trim();
5287        if text.is_empty() {
5288            return;
5289        }
5290
5291        if text.starts_with('/') {
5292            dispatch_slash(text, &ctx_for_cb, &registry_for_cb);
5293            return;
5294        }
5295
5296        let run_status = *ctx_for_cb.state.status.lock().unwrap();
5297        if run_status != RunStatus::Idle {
5298            let message = AgentMessage::User(UserMessage::new(text.to_string(), 0));
5299            let lane = ctx_for_cb.lane.clone();
5300            let chat = ctx_for_cb.chat.clone();
5301            let tui = ctx_for_cb.tui.clone();
5302            tokio::spawn(async move {
5303                // Queue immediately while the agent loop is still running.
5304                // Routing this through the TUI's main channel delayed it until
5305                // `prompt_text()` returned, after the loop's drain points had
5306                // passed, so the queued message appeared to disappear.
5307                //
5308                // Native pi's `steer()` enqueues unconditionally, even after
5309                // the run ends, and the message stays in the queue for the
5310                // next run. This matches the TS design and avoids the race
5311                // between `activeRun` clearing and the status check.
5312                if let Err(error) = lane.steer(message).await {
5313                    add_error_message(&chat, &format!("Could not queue message: {error}"));
5314                    tui.render_now(false);
5315                }
5316            });
5317            // A queued prompt is still the user's message. Render it through
5318            // the same transcript path as an immediately sent prompt rather
5319            // than as a system-note row, so its bubble/markdown presentation
5320            // stays consistent while the agent is busy.
5321            add_user_message(&ctx_for_cb.chat, text);
5322            ctx_for_cb.tui.request_render(false);
5323            return;
5324        }
5325
5326        if !ctx_for_cb.state.try_start_working() {
5327            return;
5328        }
5329
5330        add_user_message(&ctx_for_cb.chat, text);
5331        // A new prompt starts a fresh interaction at the tail even when the
5332        // user had scrolled up to inspect older output.
5333        if let Some(scroll) = ctx_for_cb.tui.get_primary_scroll_view() {
5334            scroll.scroll_to_end();
5335        }
5336        ctx_for_cb.tui.request_render(false);
5337        // Remember the message for ↑ recall (slash commands are not part of
5338        // the replayable message history).
5339        push_history(&ctx_for_cb.state, text);
5340        if ctx_for_cb
5341            .tx
5342            .send(TuiMessage::UserInput(text.to_string()))
5343            .is_err()
5344        {
5345            ctx_for_cb.state.set_status(RunStatus::Idle);
5346        }
5347    }));
5348
5349    // Turn on the native-pi frame throttle before the first frame: from here on
5350    // `request_render(false)` parks a frame and the scheduler thread paints at
5351    // most one per `MIN_RENDER_INTERVAL_MS` (16 ms). A provider that streams one
5352    // `MessageUpdate` per delta would otherwise force a full transcript
5353    // re-render + full-viewport terminal write per token, which is what made
5354    // long output saturate the terminal (and its GPU compositor). Immediate
5355    // renders (selectors, the first frame, `render_now(..)`) are unaffected.
5356    tui.start_render_scheduler();
5357    tui.start_readerless();
5358
5359    // Consume local self-update results even when network checks are disabled.
5360    // Keep package discovery separate so package managers and Git remotes
5361    // cannot delay an rpi update notice or a prior helper failure.
5362    let mut update_check_handles = Vec::new();
5363    let rpi_chat = chat_container.clone();
5364    let rpi_tui = tui.clone();
5365    update_check_handles.push(tokio::spawn(async move {
5366        let report = crate::updates::check_rpi_startup().await;
5367        if !report.is_empty() {
5368            add_update_notices(&rpi_chat, &report);
5369        }
5370        rpi_tui.request_render(false);
5371    }));
5372
5373    if update_checks_enabled {
5374        let update_args = args.clone();
5375        let update_cwd = cwd.clone();
5376        let update_project_trusted = project_trusted;
5377        let chat = chat_container.clone();
5378        let tui = tui.clone();
5379        update_check_handles.push(tokio::spawn(async move {
5380            let resources = crate::session::package_resources_for_update_check(
5381                &update_args,
5382                &update_cwd,
5383                update_project_trusted,
5384            );
5385            let report = match crate::npm::NpmCommand::resolve(&update_cwd, update_project_trusted)
5386            {
5387                Ok(npm_command) => {
5388                    crate::updates::check_package_startup_with_resources_and_npm_command_in_cwd(
5389                        Some(&resources),
5390                        &npm_command,
5391                        update_project_trusted.then_some(update_cwd.as_path()),
5392                    )
5393                    .await
5394                }
5395                Err(error) => {
5396                    add_error_message(
5397                        &chat,
5398                        &format!("npm package update checks disabled: {error}"),
5399                    );
5400                    let git_resources = git_only_update_resources(resources);
5401                    crate::updates::check_package_startup_with_resources(Some(&git_resources)).await
5402                }
5403            };
5404            if !report.is_empty() {
5405                add_update_notices(&chat, &report);
5406            }
5407            tui.request_render(false);
5408        }));
5409    }
5410
5411    // ---- Streaming drain task ----
5412    let drain_handle = if let Some(rx) = event_rx {
5413        let tui_drain = tui.clone();
5414        let state_drain = state.clone();
5415        let chat_drain = chat_container.clone();
5416        Some(tokio::spawn(async move {
5417            drain_agent_events(rx, tui_drain, state_drain, chat_drain).await;
5418        }))
5419    } else {
5420        None
5421    };
5422
5423    // ---- B5d: plugin→TUI reload bridge ----
5424    // A plugin's `runtime_action(Reload)` can't drive the reload synchronously
5425    // (its cdylib would be unmapped while the call frame is still on the stack).
5426    // Instead the `ActionBridge`'s reload callback signals `reload_context.mailbox`
5427    // (an `UnboundedSender<()>`); this task drains those signals and forwards
5428    // `TuiMessage::ReloadExtensions` into the main loop, which runs the shared
5429    // `reload_extension_resources` routine asynchronously. The mailbox is the
5430    // cycle-free seam: rpi-extensions carries only `()` (no `TuiMessage` type —
5431    // leaf DAG preserved); the TUI owns the receiver + the reload routine.
5432    let (reload_sig_tx, mut reload_sig_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
5433    reload_context.mailbox.install(reload_sig_tx);
5434    let reload_tx = tx.clone();
5435    let reload_bridge_handle = tokio::spawn(async move {
5436        while reload_sig_rx.recv().await.is_some() {
5437            if reload_tx.send(TuiMessage::ReloadExtensions).is_err() {
5438                break; // main loop gone — stop forwarding
5439            }
5440        }
5441    });
5442
5443    // ---- Render-tick task (advances the loader spinner while Working) ----
5444    //
5445    // The `Loader` only advances its frame on render; without a periodic
5446    // `request_render` the spinner visibly freezes between events.
5447    let tui_tick = tui.clone();
5448    let state_tick = state.clone();
5449    let tick_handle = tokio::spawn(async move {
5450        // 80ms — pi's loader DEFAULT_INTERVAL_MS (the spinner would visibly
5451        // stutter at the old 120ms).
5452        let mut interval = tokio::time::interval(std::time::Duration::from_millis(80));
5453        interval.tick().await; // discard immediate
5454        loop {
5455            interval.tick().await;
5456            let working = *state_tick.status.lock().unwrap() == RunStatus::Working;
5457            if working {
5458                if state_tick.bash_components.lock().unwrap().is_empty() {
5459                    // Only the dock loader animates. Keep the already-rendered
5460                    // transcript instead of rebuilding a long history at 12.5
5461                    // frames per second.
5462                    tui_tick.request_render_reusing_scroll_content();
5463                } else {
5464                    // A running bash panel owns a loader inside the transcript.
5465                    tui_tick.request_render(false);
5466                }
5467            }
5468        }
5469    });
5470
5471    // ---- Key dispatch loop (spawn_blocking crossterm read) ----
5472    let running = Arc::new(std::sync::Mutex::new(true));
5473    let running_key = running.clone();
5474    let tx_for_key = tx.clone();
5475    let tui_for_key = tui.clone();
5476    let editor_for_key = editor.clone();
5477    let scroll_for_key = scroll_view.clone();
5478    let lane_for_key = lane.clone();
5479    let state_for_key = state.clone();
5480    let model_catalog_for_key = model_catalog_arc.clone();
5481    let js_for_key = reload_context.js_extension_session.clone();
5482    let js_dialog_for_key = js_dialog_bridge.clone();
5483    let ask_user_for_key = ask_user_bridge.clone();
5484    // Ctrl+L routes through the same registry as `/model` (one path, not two),
5485    // so the key loop needs the same `CommandContext` + registry the submit
5486    // handler uses. All fields are `Arc`/cheap, so this clone is free.
5487    let ctx_for_key = ctx.clone();
5488    let registry_for_key = registry.clone();
5489    let keybindings_for_key = keybindings.clone();
5490    let double_escape_action = crate::settings::load_settings()
5491        .ok()
5492        .and_then(|settings| settings.double_escape_action)
5493        .unwrap_or_else(|| "tree".to_string())
5494        .to_ascii_lowercase();
5495
5496    let key_handle = tokio::task::spawn_blocking(move || {
5497        let mut last_escape_time = None;
5498        // Paste-burst tracking (see the bare-Enter guard below): the instant of
5499        // the most recent key event that could have been pasted text.
5500        let mut last_text_key_at: Option<std::time::Instant> = None;
5501        loop {
5502            if !*running_key.lock().unwrap() {
5503                break;
5504            }
5505            if !state_for_key.selector_open()
5506                && !state_for_key.extension_dialog_open()
5507                && js_for_key.as_ref().map_or(true, |js| !js.custom_active())
5508            {
5509                if let Some(request) = js_dialog_for_key.take_pending() {
5510                    open_js_dialog(&ctx_for_key, js_dialog_for_key.clone(), request);
5511                } else if let Some(request) = ask_user_for_key.take_pending() {
5512                    open_ask_user_dialog(&ctx_for_key, ask_user_for_key.clone(), request);
5513                }
5514            }
5515            cancel_js_dialog_ui(&ctx_for_key, &js_dialog_for_key);
5516            // `event::read()` blocks indefinitely. Poll first so shutdown can
5517            // stop and join this worker even when no further key arrives.
5518            match crossterm::event::poll(std::time::Duration::from_millis(50)) {
5519                Ok(true) => {}
5520                Ok(false) => continue,
5521                Err(_) => {
5522                    state_for_key.cancel_js_preparation();
5523                    let _ = tx_for_key.send(TuiMessage::Exit);
5524                    break;
5525                }
5526            }
5527            let Ok(ev) = crossterm::event::read() else {
5528                state_for_key.cancel_js_preparation();
5529                let _ = tx_for_key.send(TuiMessage::Exit);
5530                break;
5531            };
5532            // `Event::Resize` is delivered as its own event (not a Key). With
5533            // `start_readerless` there is no competing terminal-reader thread to
5534            // handle it, so refresh the cached terminal size here and force a
5535            // full redraw so the constrained layout re-fits the new dimensions.
5536            if let Event::Resize(_cols, _rows) = ev {
5537                tui_for_key.refresh_size();
5538                if let Some(js) = &js_for_key {
5539                    if js.custom_active() {
5540                        let _ = js.send_custom_resize(_cols as usize, _rows as usize);
5541                        tui_for_key.request_render(false);
5542                    }
5543                }
5544                continue;
5545            }
5546            // Mouse wheel scrolls the transcript (pi supports wheel
5547            // scrolling). Previously every non-Key event was dropped, so a
5548            // wheel had zero effect — "滚动还是不行".
5549            if let Event::Mouse(m) = ev {
5550                use crossterm::event::MouseEventKind;
5551                match m.kind {
5552                    MouseEventKind::ScrollUp => {
5553                        let delta = -MOUSE_WHEEL_SCROLL_LINES;
5554                        if scroll_for_key.scroll_by(delta) != delta {
5555                            tui_for_key.request_render_reusing_scroll_content();
5556                        }
5557                    }
5558                    MouseEventKind::ScrollDown => {
5559                        let delta = MOUSE_WHEEL_SCROLL_LINES;
5560                        if scroll_for_key.scroll_by(delta) != delta {
5561                            tui_for_key.request_render_reusing_scroll_content();
5562                        }
5563                    }
5564                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
5565                        // Start selection tracking
5566                        *state_for_key.selection_start.lock().unwrap() = Some((m.column, m.row));
5567                        *state_for_key.selection_end.lock().unwrap() = None;
5568                    }
5569                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
5570                        // End selection and auto-copy if enabled
5571                        if let Some(start) = *state_for_key.selection_start.lock().unwrap() {
5572                            *state_for_key.selection_end.lock().unwrap() = Some((m.column, m.row));
5573                            
5574                            // Check if auto-copy is enabled
5575                            let auto_copy = crate::settings::load_settings()
5576                                .ok()
5577                                .and_then(|s| s.fullscreen_copy_on_select)
5578                                .unwrap_or(true);
5579                            
5580                            if auto_copy {
5581                                // Try to extract selected text from chat container
5582                                if let Some(selected_text) = extract_selected_text(
5583                                    &state_for_key.chat_container,
5584                                    start,
5585                                    (m.column, m.row),
5586                                ) {
5587                                    if !selected_text.trim().is_empty() {
5588                                        let _ = copy_to_clipboard(&selected_text);
5589                                    }
5590                                }
5591                            }
5592                        }
5593                        *state_for_key.selection_start.lock().unwrap() = None;
5594                        *state_for_key.selection_end.lock().unwrap() = None;
5595                    }
5596                    _ => {}
5597                }
5598                continue;
5599            }
5600            let Event::Key(key) = ev else {
5601                if let Event::Paste(text) = ev {
5602                    let candidate = text.trim().trim_matches(['\"', '\'']);
5603                    let path = std::path::PathBuf::from(candidate);
5604                    if !candidate.chars().any(|c| c == '\n' || c == '\r') && path.is_file() {
5605                        if let Ok(Some(image)) = crate::app::image_content_from_path(&path) {
5606                            add_image_preview(&state_for_key.chat_container, &image);
5607                            state_for_key.queue_image(image);
5608                            add_note_message(
5609                                &state_for_key.chat_container,
5610                                "Dropped image attached to the next prompt.",
5611                            );
5612                            tui_for_key.request_render(false);
5613                            continue;
5614                        }
5615                    }
5616                    editor_for_key.insert(&text);
5617                    refresh_autocomplete(&state_for_key, &editor_for_key);
5618                    tui_for_key.request_render_reusing_scroll_content();
5619                }
5620                continue;
5621            };
5622            // Drop releases but preserve Repeat so holding arrows, Backspace,
5623            // PageUp, etc. behaves naturally. Windows emits Press + Release
5624            // for a tap; terminals with keyboard enhancement may additionally
5625            // emit Repeat while a key is held.
5626            if !should_dispatch_key(key.kind) {
5627                continue;
5628            }
5629
5630            // Prompt preparation runs on a blocking worker before the agent
5631            // lane owns the turn. Cancel it directly: an abort queued only to
5632            // the lane cannot wake a JS factory or lifecycle hook that never
5633            // resolves. This check precedes custom/dialog routing because
5634            // those components may themselves have been opened by the hook.
5635            let prompt_abort = (key.modifiers == KeyModifiers::CONTROL
5636                && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')))
5637                || (key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Esc);
5638            if prompt_abort && state_for_key.cancel_js_preparation() {
5639                state_for_key.set_status(RunStatus::Aborting);
5640                if !run_extension_cancel(&state_for_key) && state_for_key.extension_dialog_open() {
5641                    close_extension_editor(
5642                        &state_for_key,
5643                        &ctx_for_key.editor_container,
5644                        &editor_for_key,
5645                        &tui_for_key,
5646                    );
5647                }
5648                js_dialog_for_key.cancel_open_requests();
5649                tui_for_key.set_render_suspended(false);
5650                tui_for_key.request_render(false);
5651                continue;
5652            }
5653
5654            if let Some(js) = &js_for_key {
5655                if js.custom_active() {
5656                    let visible = js.custom_accepts_input();
5657                    let data = key_event_to_input(key);
5658                    if !data.is_empty() {
5659                        // A visible custom owns the whole key stream, so its
5660                        // acknowledgement is unnecessary and would add a
5661                        // synchronous round-trip to every keystroke. Hidden
5662                        // overlays need the consume result to decide whether the
5663                        // outer editor should see the key.
5664                        if visible {
5665                            let _ = js.send_custom_input(&data);
5666                            continue;
5667                        }
5668                        let consumed = js.send_custom_input_with_consumed(&data).unwrap_or(false);
5669                        // A hidden component only keeps raw listeners alive (for
5670                        // example ask_user_question's reopen shortcut); an
5671                        // unconsumed key continues through the outer editor.
5672                        if consumed {
5673                            tui_for_key.request_render_reusing_scroll_content();
5674                            continue;
5675                        }
5676                    }
5677                }
5678            }
5679
5680            // Ctrl+C cancels an open selector before it reaches the global
5681            // abort/exit handler. Route through Esc so selector callbacks run.
5682            if key.modifiers == KeyModifiers::CONTROL
5683                && key.code == KeyCode::Char('c')
5684                && state_for_key.selector_open()
5685            {
5686                let selector = state_for_key
5687                    .active_selector
5688                    .lock()
5689                    .unwrap()
5690                    .clone()
5691                    .expect("selector_open guaranteed Some")
5692                    .0;
5693                selector.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
5694                tui_for_key.request_render_reusing_scroll_content();
5695                continue;
5696            }
5697
5698            // Extension dialogs own the input slot while awaiting a result.
5699            // Esc and Ctrl+C both resolve the pending command with cancel;
5700            // all other keys go to the active native editor/input widget.
5701            if state_for_key.extension_dialog_open() {
5702                let cancel = key.code == KeyCode::Esc
5703                    || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'));
5704                if cancel {
5705                    if !run_extension_cancel(&state_for_key) {
5706                        close_extension_editor(
5707                            &state_for_key,
5708                            &ctx_for_key.editor_container,
5709                            &editor_for_key,
5710                            &tui_for_key,
5711                        );
5712                    }
5713                } else if let Some(extension_editor) = state_for_key
5714                    .active_extension_editor
5715                    .lock()
5716                    .unwrap()
5717                    .clone()
5718                {
5719                    extension_editor.handle_key(key);
5720                } else if let Some(extension_input) =
5721                    state_for_key.active_extension_input.lock().unwrap().clone()
5722                {
5723                    extension_input.handle_key(key);
5724                }
5725                tui_for_key.request_render_reusing_scroll_content();
5726                continue;
5727            }
5728
5729            // 0. Ctrl+C: copy the selection when the editor has one (pi
5730            //    `tui.input.copy`); otherwise abort an active run, or exit
5731            //    when idle. Open selectors and extension dialogs are handled
5732            //    above so their cancellation callbacks get first chance.
5733            if keybinding_matches(
5734                &keybindings_for_key,
5735                &key,
5736                rpi_tui::keybindings::keys::CLEAR,
5737            ) {
5738                if !state_for_key.selector_open() && editor_for_key.has_selection() {
5739                    editor_for_key.copy_selection();
5740                    continue;
5741                }
5742                let status = *state_for_key.status.lock().unwrap();
5743                match status {
5744                    RunStatus::Working => {
5745                        state_for_key.set_status(RunStatus::Aborting);
5746                        // Drop any outstanding ask_user prompt from this turn.
5747                        ask_user_for_key.cancel_all();
5748                        // Restore the editor if an extension dialog was occupying it.
5749                        if !run_extension_cancel(&state_for_key)
5750                            && state_for_key.extension_dialog_open()
5751                        {
5752                            close_extension_editor(
5753                                &state_for_key,
5754                                &ctx_for_key.editor_container,
5755                                &editor_for_key,
5756                                &tui_for_key,
5757                            );
5758                        }
5759                        let lane = lane_for_key.clone();
5760                        tokio::spawn(async move {
5761                            let _ = lane.abort().await;
5762                        });
5763                    }
5764                    // A held Ctrl+C can emit Repeat immediately after Press.
5765                    // Keep waiting for the in-flight cancellation instead of
5766                    // treating that repeat as a request to exit the process.
5767                    RunStatus::Aborting => {}
5768                    RunStatus::Idle => {
5769                        let _ = tx_for_key.send(TuiMessage::Exit);
5770                    }
5771                }
5772                continue;
5773            }
5774
5775            // 1. A selector overlay is open → route to it first. Only Esc
5776            //    (cancel) and Enter/Up/Down/Ctrl-K/J/P/N (navigate/select)
5777            //    escape to the selector; on done/cancel the selector callbacks
5778            //    restore the editor and clear `active_selector`.
5779            if state_for_key.selector_open() {
5780                // Esc always cancels the selector (even with modifiers off).
5781                // Route through `SelectList::handle_key(Esc)` so the list's
5782                // `on_cancel` fires (the `/scoped-models` toggle selector saves
5783                // its edits there) — the old shortcut called `close_selector`
5784                // directly and skipped the callback.
5785                if key.code == KeyCode::Esc {
5786                    let (selector, _kind) = state_for_key
5787                        .active_selector
5788                        .lock()
5789                        .unwrap()
5790                        .clone()
5791                        .expect("selector_open guaranteed Some");
5792                    selector.handle_key(key);
5793                    continue;
5794                }
5795                let (selector, _kind) = state_for_key
5796                    .active_selector
5797                    .lock()
5798                    .unwrap()
5799                    .clone()
5800                    .expect("selector_open guaranteed Some");
5801                selector.handle_key(key);
5802                tui_for_key.request_render_reusing_scroll_content();
5803                continue;
5804            }
5805
5806            // 2a. Ctrl+D: pi's deleteCharForward inside the editor (mirrors
5807            //     `tui.editor.deleteCharForward`), and EOF-quit on an empty
5808            //     editor. With a run active, abort it first (same as Ctrl+C)
5809            //     so the key is never a no-op while a stuck command runs.
5810            if keybinding_matches(&keybindings_for_key, &key, rpi_tui::keybindings::keys::EXIT) {
5811                let status = *state_for_key.status.lock().unwrap();
5812                match status {
5813                    RunStatus::Working => {
5814                        state_for_key.set_status(RunStatus::Aborting);
5815                        ask_user_for_key.cancel_all();
5816                        if !run_extension_cancel(&state_for_key)
5817                            && state_for_key.extension_dialog_open()
5818                        {
5819                            close_extension_editor(
5820                                &state_for_key,
5821                                &ctx_for_key.editor_container,
5822                                &editor_for_key,
5823                                &tui_for_key,
5824                            );
5825                        }
5826                        let lane = lane_for_key.clone();
5827                        tokio::spawn(async move {
5828                            let _ = lane.abort().await;
5829                        });
5830                        continue;
5831                    }
5832                    RunStatus::Aborting => continue,
5833                    RunStatus::Idle => {}
5834                }
5835                if !state_for_key.selector_open() && !editor_for_key.get_text().is_empty() {
5836                    // Editor holds text — delete the char forward (pi parity).
5837                    editor_for_key.handle_key(key);
5838                    refresh_autocomplete(&state_for_key, &editor_for_key);
5839                    tui_for_key.request_render_reusing_scroll_content();
5840                    continue;
5841                }
5842                let _ = tx_for_key.send(TuiMessage::Exit);
5843                continue;
5844            }
5845
5846            // 2b. Esc: interrupt an active run (mirrors Ctrl+C abort). When a
5847            //     selector is open Esc already cancelled it above; when idle,
5848            //     Esc falls through to the editor (no-op-ish). Only fire while
5849            //     Working so an idle Esc doesn't abort a non-existent run.
5850            if keybinding_matches(
5851                &keybindings_for_key,
5852                &key,
5853                rpi_tui::keybindings::keys::INTERRUPT,
5854            ) {
5855                let status = *state_for_key.status.lock().unwrap();
5856                if status == RunStatus::Working {
5857                    state_for_key.set_status(RunStatus::Aborting);
5858                    ask_user_for_key.cancel_all();
5859                    if !run_extension_cancel(&state_for_key)
5860                        && state_for_key.extension_dialog_open()
5861                    {
5862                        close_extension_editor(
5863                            &state_for_key,
5864                            &ctx_for_key.editor_container,
5865                            &editor_for_key,
5866                            &tui_for_key,
5867                        );
5868                    }
5869                    let lane = lane_for_key.clone();
5870                    tokio::spawn(async move {
5871                        let _ = lane.abort().await;
5872                    });
5873                    continue;
5874                }
5875                if status == RunStatus::Idle
5876                    && editor_for_key.get_text().trim().is_empty()
5877                    && double_escape_action != "none"
5878                {
5879                    let now = std::time::Instant::now();
5880                    if double_escape_trigger(last_escape_time, now) {
5881                        last_escape_time = None;
5882                        match double_escape_action.as_str() {
5883                            "tree" => {
5884                                let _ = tx_for_key.send(TuiMessage::OpenTree);
5885                            }
5886                            "fork" => {
5887                                let _ = tx_for_key.send(TuiMessage::ForkSession);
5888                            }
5889                            _ => {}
5890                        }
5891                    } else {
5892                        last_escape_time = Some(now);
5893                    }
5894                }
5895                continue;
5896            }
5897
5898            // 2c. Ctrl+G: edit the current draft in the user's external
5899            // editor, matching native Pi's VISUAL/EDITOR fallback chain.
5900            if keybinding_matches(
5901                &keybindings_for_key,
5902                &key,
5903                rpi_tui::keybindings::keys::EXTERNAL_EDITOR,
5904            ) {
5905                launch_external_editor(editor_for_key.get_text(), tx_for_key.clone());
5906                continue;
5907            }
5908
5909            // 2d. Ctrl+O: toggle all tool output panels between compact and
5910            // expanded rendering (native Pi's global output toggle).
5911            if keybinding_matches(
5912                &keybindings_for_key,
5913                &key,
5914                rpi_tui::keybindings::keys::TOOLS_EXPAND,
5915            ) {
5916                let expanded = state_for_key.toggle_tool_outputs();
5917                // Persist per-session so a later `-c`/`-r` restores it.
5918                let session = ctx_for_key.session.clone();
5919                tokio::spawn(async move {
5920                    let _ = session
5921                        .set_value(
5922                            "display.tool_outputs_expanded",
5923                            serde_json::json!(expanded),
5924                        )
5925                        .await;
5926                });
5927                tui_for_key.request_render(false);
5928                continue;
5929            }
5930
5931            // 2e. Ctrl+T: toggle visibility of reasoning/thinking blocks.
5932            if keybinding_matches(
5933                &keybindings_for_key,
5934                &key,
5935                rpi_tui::keybindings::keys::THINKING_TOGGLE,
5936            ) {
5937                let hide = state_for_key.toggle_thinking();
5938                // Persist per-session so a later `-c`/`-r` restores it.
5939                let session = ctx_for_key.session.clone();
5940                tokio::spawn(async move {
5941                    let _ = session
5942                        .set_value("display.hide_thinking", serde_json::json!(hide))
5943                        .await;
5944                });
5945                tui_for_key.request_render(false);
5946                continue;
5947            }
5948
5949            // 2f. Ctrl+M: cycle to the next model in the catalog after the one
5950            //     currently tracked in `current_model_id`, apply it live via
5951            //     `lane.set_model` (takes effect on the next user message — the
5952            //     in-flight run's config is already snapshotted), and update the
5953            //     footer. `set_model` is async so it runs on a spawned task.
5954            if keybinding_matches(
5955                &keybindings_for_key,
5956                &key,
5957                rpi_tui::keybindings::keys::MODEL_CYCLE_FORWARD,
5958            ) {
5959                let current = state_for_key.current_model_id();
5960                // Cycle within the `/scoped-models` set (settings.json) when
5961                // configured; otherwise the full catalog.
5962                let scope = scoped_catalog(&ctx_for_key.model_catalog, &current);
5963                if let Some(next) = cycle_next_model(&scope, &current) {
5964                    state_for_key.set_current_model(&next);
5965                    let lane = lane_for_key.clone();
5966                    tokio::spawn(async move {
5967                        let _ = lane.set_model(next).await;
5968                    });
5969                    tui_for_key.request_render_reusing_scroll_content();
5970                }
5971                continue;
5972            }
5973
5974            // 2f. Shift+Tab / BackTab: cycle the current model's supported
5975            // thinking levels, matching native Pi's thinking-level shortcut.
5976            if keybinding_matches(
5977                &keybindings_for_key,
5978                &key,
5979                rpi_tui::keybindings::keys::THINKING_CYCLE,
5980            ) {
5981                let lane = lane_for_key.clone();
5982                let catalog = model_catalog_for_key.clone();
5983                let state = state_for_key.clone();
5984                tokio::spawn(async move {
5985                    let Ok(current_model) = lane.get_model().await else {
5986                        return;
5987                    };
5988                    let levels = catalog
5989                        .iter()
5990                        .find(|model| {
5991                            model.provider == current_model.provider && model.id == current_model.id
5992                        })
5993                        .map(|model| model.supported_thinking_levels())
5994                        .unwrap_or_else(|| vec![rpi_ai::types::ThinkingLevel::Medium]);
5995                    if levels.is_empty() {
5996                        return;
5997                    }
5998                    let current = lane
5999                        .get_thinking_level()
6000                        .await
6001                        .unwrap_or(rpi_ai::types::ThinkingLevel::Medium);
6002                    let next = levels
6003                        .iter()
6004                        .position(|level| *level == current)
6005                        .map(|index| levels[(index + 1) % levels.len()])
6006                        .unwrap_or(levels[0]);
6007                    if lane.set_thinking_level(next).await.is_ok() {
6008                        state
6009                            .footer
6010                            .set_thinking_level(Some(thinking_level_name(next)));
6011                        state.tui.as_ref().map(|tui| tui.request_render(false));
6012                    }
6013                });
6014                continue;
6015            }
6016
6017            // Ctrl+V (or a configured paste-image key) keeps normal text yank
6018            // behavior when the clipboard has no bitmap, but queues an image
6019            // for the next prompt when one is available.
6020            if keybinding_matches(
6021                &keybindings_for_key,
6022                &key,
6023                rpi_tui::keybindings::keys::PASTE_IMAGE,
6024            ) {
6025                match read_clipboard_image() {
6026                    Ok(Some(image)) => {
6027                        add_image_preview(&state_for_key.chat_container, &image);
6028                        state_for_key.queue_image(image);
6029                        add_note_message(
6030                            &state_for_key.chat_container,
6031                            "Clipboard image attached to the next prompt.",
6032                        );
6033                        tui_for_key.request_render(false);
6034                        continue;
6035                    }
6036                    Ok(None) | Err(_) => {}
6037                }
6038            }
6039
6040            // 3. Ctrl+Shift+F: open transcript search
6041            if key.modifiers.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
6042                && key.code == KeyCode::Char('F')
6043            {
6044                state_for_key.search.activate();
6045                state_for_key.search_bar.set_visible(true);
6046                state_for_key.search_bar.set_query("");
6047                state_for_key.search_bar.set_match_info(0, 0);
6048                tui_for_key.request_render(false);
6049                continue;
6050            }
6051
6052            // Handle search mode input
6053            if state_for_key.search.is_active() {
6054                let search_bar = state_for_key.search_bar.clone();
6055                match key.code {
6056                    KeyCode::Esc => {
6057                        state_for_key.search.deactivate();
6058                        search_bar.set_visible(false);
6059                        tui_for_key.request_render(false);
6060                        continue;
6061                    }
6062                    KeyCode::Enter => {
6063                        if key.modifiers.contains(KeyModifiers::SHIFT) {
6064                            state_for_key.search.previous_match();
6065                        } else {
6066                            state_for_key.search.next_match();
6067                        }
6068                        // Update search bar with current match info
6069                        let match_index = state_for_key.search.get_match_index();
6070                        let match_count = state_for_key.search.get_match_count();
6071                        search_bar.set_match_info(match_index, match_count);
6072                        tui_for_key.request_render(false);
6073                        continue;
6074                    }
6075                    KeyCode::Backspace => {
6076                        state_for_key.search.backspace();
6077                        // Re-search with updated query
6078                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6079                        state_for_key.search.find_matches(&lines);
6080                        // Update search bar
6081                        let query = state_for_key.search.get_query();
6082                        let match_count = state_for_key.search.get_match_count();
6083                        search_bar.set_query(&query);
6084                        search_bar.set_match_info(0, match_count);
6085                        tui_for_key.request_render(false);
6086                        continue;
6087                    }
6088                    KeyCode::Char(c) => {
6089                        state_for_key.search.append_char(c);
6090                        // Re-search with updated query
6091                        let lines = collect_transcript_lines(&state_for_key.chat_container);
6092                        state_for_key.search.find_matches(&lines);
6093                        // Update search bar
6094                        let query = state_for_key.search.get_query();
6095                        let match_count = state_for_key.search.get_match_count();
6096                        search_bar.set_query(&query);
6097                        search_bar.set_match_info(0, match_count);
6098                        tui_for_key.request_render(false);
6099                        continue;
6100                    }
6101                    _ => continue,
6102                }
6103            }
6104
6105            // 3. Ctrl+L: open the model selector. Routed through the `/model`
6106            //    command so the hotkey and the slash command share one path
6107            //    (TS binds Ctrl+L to model-select).
6108            if keybinding_matches(
6109                &keybindings_for_key,
6110                &key,
6111                rpi_tui::keybindings::keys::MODEL_SELECT,
6112            ) {
6113                if let Some(cmd) = registry_for_key.find("/model") {
6114                    cmd.execute(&ctx_for_key, "");
6115                }
6116                continue;
6117            }
6118
6119            // 4. Tab: accept the top autocomplete suggestion (if any).
6120            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Tab {
6121                if accept_top_suggestion(&state_for_key, &editor_for_key) {
6122                    tui_for_key.request_render_reusing_scroll_content();
6123                }
6124                continue;
6125            }
6126
6127            // 5. Global transcript scroll. PageUp/PageDown use the actual
6128            // viewport height with four rows of overlap (upstream behavior),
6129            // while Home/End jump to the transcript boundaries.
6130            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageUp {
6131                let delta = -transcript_page_size(scroll_for_key.viewport_height());
6132                if scroll_for_key.scroll_by(delta) != delta {
6133                    tui_for_key.request_render_reusing_scroll_content();
6134                }
6135                continue;
6136            }
6137            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::PageDown {
6138                let delta = transcript_page_size(scroll_for_key.viewport_height());
6139                if scroll_for_key.scroll_by(delta) != delta {
6140                    tui_for_key.request_render_reusing_scroll_content();
6141                }
6142                continue;
6143            }
6144            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Home {
6145                scroll_for_key.scroll_to_start();
6146                tui_for_key.request_render_reusing_scroll_content();
6147                continue;
6148            }
6149            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::End {
6150                scroll_for_key.scroll_to_end();
6151                tui_for_key.request_render_reusing_scroll_content();
6152                continue;
6153            }
6154
6155            // 5b. ↑/↓ browse submitted-message history when the editor is
6156            //     EMPTY (a fresh prompt) — mirrors TS historyPrevious/Next
6157            //     without the surprise of replacing typed text. When the
6158            //     editor holds content, ↑/↓ fall through to cursor movement
6159            //     (typing "hello", pressing ↑ at the start, must never swap
6160            //     the draft for a history entry — reported as "text
6161            //     disappeared"). Once browsing, ↓ walks back and restores the
6162            //     draft.
6163            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Up {
6164                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6165                if editor_for_key.get_text().is_empty() || browsing {
6166                    navigate_history(&state_for_key, &editor_for_key, -1);
6167                    tui_for_key.request_render_reusing_scroll_content();
6168                    continue;
6169                }
6170            }
6171            if key.modifiers == KeyModifiers::NONE && key.code == KeyCode::Down {
6172                let browsing = *state_for_key.history_index.lock().unwrap() != -1;
6173                if editor_for_key.get_text().is_empty() || browsing {
6174                    navigate_history(&state_for_key, &editor_for_key, 1);
6175                    tui_for_key.request_render_reusing_scroll_content();
6176                    continue;
6177                }
6178            }
6179
6180            // Alt+Enter queues a follow-up while a run is active. It is
6181            // handled here because Editor treats only a bare Enter as submit;
6182            // idle Alt+Enter keeps the normal prompt behavior.
6183            if key.modifiers.contains(KeyModifiers::ALT) && key.code == KeyCode::Enter {
6184                let prompt = editor_for_key.get_text().trim().to_string();
6185                if prompt.is_empty() {
6186                    continue;
6187                }
6188                editor_for_key.clear();
6189                let status = *state_for_key.status.lock().unwrap();
6190                if status == RunStatus::Idle {
6191                    if state_for_key.try_start_working() {
6192                        add_user_message(&state_for_key.chat_container, &prompt);
6193                        push_history(&state_for_key, &prompt);
6194                        let _ = tx_for_key.send(TuiMessage::UserInput(prompt));
6195                    }
6196                } else {
6197                    // Follow-ups are user prompts too; retain the normal
6198                    // submitted-message rendering while they wait in queue.
6199                    add_user_message(&state_for_key.chat_container, &prompt);
6200                    let message = AgentMessage::User(UserMessage::new(prompt, 0));
6201                    let lane = lane_for_key.clone();
6202                    let chat = state_for_key.chat_container.clone();
6203                    let tui = tui_for_key.clone();
6204                    tokio::spawn(async move {
6205                        if let Err(error) = lane.follow_up(message).await {
6206                            add_error_message(&chat, &format!("Could not queue message: {error}"));
6207                            tui.request_render(false);
6208                        }
6209                    });
6210                }
6211                tui_for_key.request_render(false);
6212                continue;
6213            }
6214
6215            // ---- Paste-burst coalescing --------------------------------------
6216            // `crossterm` 0.27 implements bracketed paste ONLY on Unix: its
6217            // Windows console event source (`ReadConsoleInputW`) never emits
6218            // `Event::Paste`, so `?2004h` buys nothing there and a pasted block
6219            // arrives as ordinary key events with a bare Enter per line. The
6220            // editor treats a bare Enter as "submit", so one paste became N
6221            // messages. See [`enter_is_paste_burst`] for the discriminator.
6222            if key.code == KeyCode::Enter && key.modifiers.is_empty() {
6223                let more_queued = crossterm::event::poll(PASTE_PROBE).unwrap_or(false);
6224                if enter_is_paste_burst(
6225                    last_text_key_at,
6226                    std::time::Instant::now(),
6227                    more_queued,
6228                ) {
6229                    // Pasted newline: insert it and keep the remaining queued
6230                    // events flowing through this same path.
6231                    editor_for_key.insert("\n");
6232                    last_text_key_at = Some(std::time::Instant::now());
6233                    refresh_autocomplete(&state_for_key, &editor_for_key);
6234                    tui_for_key.request_render_reusing_scroll_content();
6235                    continue;
6236                }
6237                // A real submit ends the burst so a follow-up Enter is not
6238                // mistaken for paste continuation.
6239                last_text_key_at = None;
6240            } else if matches!(
6241                key.code,
6242                KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Tab
6243            ) && !key
6244                .modifiers
6245                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
6246            {
6247                // Unmodified text keys extend the burst. Ctrl/Alt chords
6248                // (Ctrl+C, Alt+Enter, …) are commands, not paste content.
6249                last_text_key_at = Some(std::time::Instant::now());
6250            } else {
6251                // Arrows, Esc, Ctrl/Alt chords, PageUp/Down, … — not paste
6252                // content; break the burst.
6253                last_text_key_at = None;
6254            }
6255
6256            // 6. Otherwise forward to the editor + refresh autocomplete.
6257            editor_for_key.handle_key(key);
6258            refresh_autocomplete(&state_for_key, &editor_for_key);
6259            tui_for_key.request_render_reusing_scroll_content();
6260        }
6261    });
6262
6263    // ---- Session lifecycle (P1): SessionStart ----
6264    // The TUI + key worker are up and the session is ready to accept input.
6265    // Notify subscribed extensions now so they can initialize session-scoped
6266    // state. A veto here is **advisory** (the session is already up) — warn and
6267    // continue. No-op when no extension subscribes.
6268    if let Some(reason) = crate::session::dispatch_session_event_async(
6269        reload_context,
6270        rpi_plugin_sdk::EventTag::SessionStart,
6271    )
6272    .await
6273    {
6274        tracing::warn!("[rpi] extension vetoed SessionStart (advisory): {reason}");
6275    }
6276
6277    // ---- Initial prompts (run before reading from the channel) ----
6278    let mut prompts: Vec<String> = Vec::new();
6279    if let Some(init) = initial {
6280        prompts.push(init);
6281    }
6282    for m in extra_messages {
6283        prompts.push(m.clone());
6284    }
6285    let mut images = initial_images;
6286    for prompt in prompts {
6287        if !*running.lock().unwrap() {
6288            break;
6289        }
6290        add_user_message(&chat_container, &prompt);
6291        tui.request_render(false);
6292        run_prompt_streaming(
6293            &lane,
6294            &prompt,
6295            &tui,
6296            &state,
6297            drain_handle.is_some(),
6298            reload_context.js_extension_session.as_ref(),
6299            &js_dialog_bridge,
6300            args,
6301            std::mem::take(&mut images),
6302        )
6303        .await;
6304    }
6305
6306    // ---- Main loop: process submitted input + lifecycle messages ----
6307    loop {
6308        if !*running.lock().unwrap() {
6309            break;
6310        }
6311        match rx.recv().await {
6312            Some(TuiMessage::UserInput(prompt)) => {
6313                // Clear the editor so the next prompt starts fresh (the submit
6314                // handler runs on the blocking key thread and can't mutate the
6315                // editor state safely there; clearing here, on the async loop,
6316                // keeps it on one thread).
6317                editor.clear();
6318                let prompt_images = state.take_pending_images();
6319                if !prompt_images.is_empty() {
6320                    add_note_message(
6321                        &chat_container,
6322                        &format!("Attached {} image(s) to this prompt.", prompt_images.len()),
6323                    );
6324                }
6325                run_prompt_streaming(
6326                    &lane,
6327                    &prompt,
6328                    &tui,
6329                    &state,
6330                    drain_handle.is_some(),
6331                    reload_context.js_extension_session.as_ref(),
6332                    &js_dialog_bridge,
6333                    args,
6334                    prompt_images,
6335                )
6336                .await;
6337            }
6338            Some(TuiMessage::ExternalEditorResult(result)) => {
6339                match result {
6340                    Ok(text) => {
6341                        let cursor = text.chars().count();
6342                        editor.set_text(&text);
6343                        editor.set_cursor(0, cursor);
6344                        add_note_message(&chat_container, "Draft updated from external editor.");
6345                    }
6346                    Err(error) => add_error_message(&chat_container, &error),
6347                }
6348                tui.request_render(false);
6349            }
6350            Some(TuiMessage::OpenTree) => {
6351                if *state.status.lock().unwrap() != RunStatus::Idle {
6352                    add_note_message(
6353                        &chat_container,
6354                        "Wait for the current run to finish before opening the tree.",
6355                    );
6356                    tui.request_render(false);
6357                } else {
6358                    open_tree_selector(
6359                        &harness,
6360                        &state,
6361                        &editor_container,
6362                        &editor,
6363                        &tui,
6364                        &chat_container,
6365                        &tx,
6366                    )
6367                    .await;
6368                }
6369            }
6370            Some(TuiMessage::NavigateTree(entry_id)) => {
6371                match lane.navigate_tree(Some(&entry_id), false, None, None).await {
6372                    Ok(result) => match result.outcome {
6373                        rpi_harness::agent_harness::NavigationOutcome::Completed { .. } => {
6374                            chat_container.clear();
6375                            add_welcome_message(&chat_container);
6376                            render_session_history(
6377                                &harness,
6378                                &chat_container,
6379                                state.markdown_transformer(),
6380                                Some(state.extension_session.clone()),
6381                            )
6382                            .await;
6383                            add_note_message(
6384                                &chat_container,
6385                                "Moved to the selected session entry.",
6386                            );
6387                        }
6388                        rpi_harness::agent_harness::NavigationOutcome::Failed { error, .. } => {
6389                            add_error_message(&chat_container, &error.message);
6390                        }
6391                        _ => add_note_message(
6392                            &chat_container,
6393                            "The selected entry could not be opened.",
6394                        ),
6395                    },
6396                    Err(error) => add_error_message(
6397                        &chat_container,
6398                        &format!("Could not navigate session tree: {error}"),
6399                    ),
6400                }
6401                tui.request_render(false);
6402            }
6403            Some(TuiMessage::ClearChat) => {
6404                chat_container.clear();
6405                add_welcome_message(&chat_container);
6406                tui.request_render(false);
6407            }
6408            Some(TuiMessage::Compact) => {
6409                run_compact(&lane, &tui, &state).await;
6410            }
6411            Some(TuiMessage::Copy) => {
6412                copy_last_assistant(&state, &chat_container);
6413                tui.request_render(false);
6414            }
6415            Some(TuiMessage::Exit) => {
6416                *running.lock().unwrap() = false;
6417                break;
6418            }
6419            Some(TuiMessage::SwitchSession(id)) => {
6420                switch_to_session(&harness, &lane, &id, &cwd, &chat_container, &state).await;
6421                tui.request_render(false);
6422            }
6423            Some(TuiMessage::ImportSession(path)) => {
6424                import_session(&harness, &lane, &path, &cwd, &chat_container, &state).await;
6425                tui.request_render(false);
6426            }
6427            Some(TuiMessage::ShareSession) => {
6428                share_session(&harness, &chat_container).await;
6429                tui.request_render(false);
6430            }
6431            Some(TuiMessage::SetSessionName(name)) => {
6432                let outcome = harness.session().set_name(Some(&name)).await;
6433                match outcome {
6434                    Ok(_) => add_note_message(
6435                        &chat_container,
6436                        &format!("Session renamed to \"{name}\"."),
6437                    ),
6438                    Err(e) => add_error_message(
6439                        &chat_container,
6440                        &format!("Could not rename session: {e}"),
6441                    ),
6442                }
6443                tui.request_render(false);
6444            }
6445            Some(TuiMessage::ExportSession) => {
6446                let file_name = agent_session
6447                    .default_export_file_name(crate::export::ExportFormat::Markdown)
6448                    .await;
6449                let path = cwd.join(&file_name);
6450                match agent_session.export_to_markdown(&path).await {
6451                    Ok(_) => add_note_message(
6452                        &chat_container,
6453                        &format!("Exported session to {}", path.display()),
6454                    ),
6455                    Err(e) => add_error_message(
6456                        &chat_container,
6457                        &format!("Could not write export: {e}"),
6458                    ),
6459                }
6460                tui.request_render(false);
6461            }
6462            Some(TuiMessage::ExportSessionWithFormat(format)) => {
6463                let file_name = agent_session.default_export_file_name(format.clone()).await;
6464                let path = cwd.join(&file_name);
6465                match agent_session.export(format, &path).await {
6466                    Ok(_) => add_note_message(
6467                        &chat_container,
6468                        &format!("Exported session to {}", path.display()),
6469                    ),
6470                    Err(e) => add_error_message(
6471                        &chat_container,
6472                        &format!("Could not write export: {e}"),
6473                    ),
6474                }
6475                tui.request_render(false);
6476            }
6477            Some(TuiMessage::ShowUsage) => {
6478                let stats = agent_session.usage_stats().await.ok();
6479                let total = agent_session.total_usage().await.ok();
6480                let snapshot = agent_session.snapshot().await.ok();
6481                match stats {
6482                    Some(stats) => {
6483                        let mut lines = vec![
6484                            "## Usage".to_string(),
6485                            String::new(),
6486                            format!("- messages: {}", stats.message_count),
6487                            format!("- total tokens: {}", stats.total_tokens),
6488                            format!("- cached tokens: {}", stats.cached_tokens),
6489                            format!("- uncached tokens: {}", stats.uncached_tokens),
6490                            format!("- cost: ${:.4}", stats.cost_total),
6491                        ];
6492                        match stats.cache_hit_ratio() {
6493                            Some(ratio) => lines
6494                                .push(format!("- cache hit ratio: {:.1}%", ratio * 100.0)),
6495                            None => lines.push("- cache hit ratio: n/a (no input yet)".to_string()),
6496                        }
6497                        if let Some(total) = total {
6498                            lines.push(String::new());
6499                            lines.push(format!(
6500                                "- last cumulative input/output: {} / {}",
6501                                total.input, total.output
6502                            ));
6503                        }
6504                        if let Some(snapshot) = snapshot {
6505                            lines.push(String::new());
6506                            lines.push(format!("- lane: `{}`", snapshot.lane));
6507                            lines.push(format!(
6508                                "- leaf: `{}`",
6509                                snapshot.leaf_id.clone().unwrap_or_else(|| "(empty)".into())
6510                            ));
6511                            if let Some(active) = &snapshot.active {
6512                                lines.push(format!(
6513                                    "- active operation: {} (`{}`)",
6514                                    active.kind.as_str(),
6515                                    active.run_id
6516                                ));
6517                            }
6518                        }
6519                        add_note_message(&chat_container, &lines.join("\n"));
6520                    }
6521                    None => add_error_message(
6522                        &chat_container,
6523                        "Could not read session usage stats.",
6524                    ),
6525                }
6526                tui.request_render(false);
6527            }
6528            Some(TuiMessage::ForkSession) => {
6529                fork_session(&harness, &cwd, &chat_container, &state).await;
6530                tui.request_render(false);
6531            }
6532            Some(TuiMessage::ReloadExtensions) => {
6533                // B5d: drive the shared reload routine on the async runtime,
6534                // then surface the outcome. `reload_context` was passed into
6535                // `interactive_tui` and is the same `Arc<ReloadContext>` the
6536                // `ReloadCommand` + the plugin mailbox both route through —
6537                // clone the `Arc` out so the borrow of `harness` (the main
6538                // loop's `&AgentHarness`) lives across the await.
6539                let reload_ctx = ctx.reload_context.clone();
6540                add_note_message(&chat_container, "Reloading extensions + resources…");
6541                tui.request_render(false);
6542                let outcome =
6543                    crate::session::reload_extension_resources(&harness, &reload_ctx).await;
6544                // B5e: the reload swapped a fresh `ExtensionSession` into the
6545                // context's cell. Rebuild the markdown transformer from that
6546                // fresh snapshot and install it on the in-flight streaming
6547                // component (so a reloaded plugin's transformer takes effect on
6548                // the visible message immediately) + future components (they
6549                // read `state.markdown_transformer()` at construction). The old
6550                // closure no-ops once its snapshot's `active` flag flips false
6551                // (reload already did that before the swap).
6552                let fresh_transformer = build_markdown_transformer(
6553                    reload_ctx.extension_session.lock().unwrap().snapshot_arc(),
6554                );
6555                state.set_markdown_transformer_with_reinstall(fresh_transformer);
6556                if outcome.had_errors {
6557                    // Hard failure: the summary itself carries the error detail
6558                    // (e.g. "Settings reload failed: …"), so render it as an
6559                    // error without an extra stderr pointer.
6560                    add_error_message(&chat_container, &outcome.summary);
6561                } else if outcome.had_warnings {
6562                    // Reload succeeded but produced soft diagnostics (skill
6563                    // shadowing, name validation, …). The details are printed
6564                    // to stderr; keep this as an informational note so a
6565                    // successful reload isn't shown as a red ✗ error.
6566                    add_note_message(
6567                        &chat_container,
6568                        &format!(
6569                            "{} (with warnings — see stderr for details).",
6570                            outcome.summary
6571                        ),
6572                    );
6573                } else {
6574                    add_note_message(&chat_container, &outcome.summary);
6575                }
6576                tui.request_render(false);
6577            }
6578            None => break,
6579        }
6580    }
6581
6582    // ---- Shutdown ----
6583    // Wake any Node `ctx.ui.*` request that is still waiting on the dialog
6584    // bridge before joining the key worker and restoring the terminal.
6585    js_dialog_bridge.cancel_all();
6586    ask_user_bridge.shutdown();
6587    *running.lock().unwrap() = false;
6588    for handle in update_check_handles {
6589        handle.abort();
6590        let _ = handle.await;
6591    }
6592    // A hidden custom input listener can leave the key worker blocked on a
6593    // synchronous Node response. Stop the host first so transport shutdown
6594    // wakes that request before we join the worker.
6595    if let Some(js) = &reload_context.js_extension_session {
6596        js.shutdown();
6597    }
6598    // The input worker checks `running` at least every 50ms. Join it before
6599    // restoring cooked mode so no late event read races terminal cleanup.
6600    let _ = key_handle.await;
6601    tick_handle.abort();
6602    if let Some(handle) = drain_handle {
6603        handle.abort();
6604    }
6605    // Drop the reload bridge: clearing the mailbox closes the signal channel,
6606    // the drain task's `recv` returns `None`, and the task exits. (Aborting is
6607    // redundant — the recv terminates — but cheap + makes shutdown explicit.)
6608    reload_context.mailbox.clear();
6609    reload_bridge_handle.abort();
6610    tui.stop(Default::default());
6611    println!("\nGoodbye!");
6612    let _ = args;
6613
6614    0
6615}
6616
6617// ===========================================================================
6618// Run a single prompt (streaming or blocking)
6619// ===========================================================================
6620
6621/// Prepare the session's persistent Node host immediately before a real prompt
6622/// enters the agent loop. The first call starts the lazy host; later calls run
6623/// `before_agent_start` again on that host so each prompt sees current state.
6624/// Keeping startup here leaves an idle TUI free of a Node child while still
6625/// giving the lifecycle hook the fully installed UI bridge.
6626async fn ensure_js_runtime_before_prompt(
6627    js: Option<&crate::js_extensions::JsExtensionSession>,
6628    lane: &Arc<dyn AgentLane>,
6629    state: &Arc<TuiState>,
6630    dialog_bridge: &JsDialogBridge,
6631    args: &Args,
6632) -> bool {
6633    let Some(js) = js else {
6634        return true;
6635    };
6636    let cancellation = state.begin_js_preparation();
6637    let worker_cancellation = cancellation.clone();
6638    let js_for_start = js.clone();
6639    let mut worker = tokio::task::spawn_blocking(move || {
6640        js_for_start.prepare_for_prompt_with_cancellation(&worker_cancellation)
6641    });
6642    let result = tokio::select! {
6643        result = &mut worker => result,
6644        _ = cancellation.cancelled() => {
6645            dialog_bridge.cancel_open_requests();
6646            let js_for_cancel = js.clone();
6647            let _ = tokio::task::spawn_blocking(move || {
6648                js_for_cancel.cancel_prompt_preparation();
6649            }).await;
6650            worker.await
6651        }
6652    };
6653    let was_cancelled = cancellation.is_cancelled();
6654    if was_cancelled {
6655        dialog_bridge.cancel_open_requests();
6656        let js_for_cancel = js.clone();
6657        let _ = tokio::task::spawn_blocking(move || {
6658            js_for_cancel.cancel_prompt_preparation();
6659        })
6660        .await;
6661        state.finish_js_preparation();
6662        dialog_bridge.reopen();
6663        return false;
6664    }
6665    state.finish_js_preparation();
6666    match result {
6667        Ok(Ok(())) => {
6668            // The lifecycle hook can change the JS-only active tool set once
6669            // it sees the real TUI context. Merge that subset with the Rust
6670            // built-ins while applying the command-line tool policy.
6671            if let Some(js_active) = js.active_tools() {
6672                let js_names = js.tool_names();
6673                let mut active = lane.get_active_tools().await.unwrap_or_default();
6674                active.retain(|name| {
6675                    crate::session::tool_name_allowed(name, args)
6676                        && !js_names.iter().any(|js_name| js_name == name)
6677                });
6678                active.extend(js_active.into_iter().filter(|name| {
6679                    js_names.iter().any(|js_name| js_name == name)
6680                        && crate::session::tool_name_allowed(name, args)
6681                }));
6682                active = crate::session::filter_active_tool_names(active, args);
6683                let _ = lane.set_active_tools(active).await;
6684            }
6685        }
6686        Ok(Err(error)) => {
6687            if args.verbose {
6688                eprintln!("warning: could not start JS extension runtime: {error}");
6689            }
6690        }
6691        Err(error) => {
6692            if args.verbose {
6693                eprintln!("warning: JS extension runtime worker failed: {error}");
6694            }
6695        }
6696    }
6697    true
6698}
6699
6700fn launch_external_editor(draft: String, tx: mpsc::UnboundedSender<TuiMessage>) {
6701    std::thread::spawn(move || {
6702        let file = match tempfile::Builder::new()
6703            .prefix("rpi-draft-")
6704            .suffix(".md")
6705            .tempfile()
6706        {
6707            Ok(file) => file,
6708            Err(error) => {
6709                let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6710                    "Could not create editor file: {error}"
6711                ))));
6712                return;
6713            }
6714        };
6715        if let Err(error) = std::fs::write(file.path(), draft.as_bytes()) {
6716            let _ = tx.send(TuiMessage::ExternalEditorResult(Err(format!(
6717                "Could not write editor file: {error}"
6718            ))));
6719            return;
6720        }
6721        let editor = std::env::var("RPI_EXTERNAL_EDITOR")
6722            .ok()
6723            .filter(|value| !value.trim().is_empty())
6724            .or_else(|| std::env::var("VISUAL").ok())
6725            .or_else(|| std::env::var("EDITOR").ok())
6726            .unwrap_or_else(|| {
6727                if cfg!(windows) {
6728                    "notepad".to_string()
6729                } else {
6730                    "nano".to_string()
6731                }
6732            });
6733        let status = std::process::Command::new(&editor)
6734            .arg(file.path())
6735            .status();
6736        let result = match status {
6737            Ok(status) if status.success() => std::fs::read_to_string(file.path())
6738                .map_err(|error| format!("Could not read editor file: {error}")),
6739            Ok(status) => Err(format!("External editor exited with {status}")),
6740            Err(error) => Err(format!(
6741                "Could not launch external editor `{editor}`: {error}"
6742            )),
6743        };
6744        let _ = tx.send(TuiMessage::ExternalEditorResult(result));
6745    });
6746}
6747
6748/// Apply the authoritative run result when it wins the race with the async
6749/// event drain, then detach the live component from further partial updates.
6750fn reconcile_streamed_assistant_completion(
6751    current_assistant: &Mutex<Option<Arc<AssistantMessageComponent>>>,
6752    last_assistant_text: &Mutex<String>,
6753    final_message: Option<&AssistantMessage>,
6754) {
6755    let final_blocks = final_message.map(assistant_blocks);
6756    let final_text = final_message.map(assistant_text);
6757    let component = current_assistant.lock().unwrap().take();
6758
6759    if let Some(component) = component {
6760        if let Some(blocks) = final_blocks.as_deref() {
6761            component.update_blocks(blocks);
6762        }
6763        component.set_streaming(false);
6764    }
6765
6766    if let Some(text) = final_text.filter(|text| !text.is_empty()) {
6767        *last_assistant_text.lock().unwrap() = text;
6768    }
6769}
6770
6771/// Drive a single prompt through the lane. When `streaming` is true, the
6772/// `AgentEvent` drain task renders the response live and the completed outcome
6773/// reconciles its final snapshot. When false (no `event_rx`), this falls back
6774/// to the blocking await-final-text path.
6775async fn run_prompt_streaming(
6776    lane: &Arc<dyn AgentLane>,
6777    prompt: &str,
6778    tui: &Arc<TuiAltScreen>,
6779    state: &Arc<TuiState>,
6780    streaming: bool,
6781    js: Option<&crate::js_extensions::JsExtensionSession>,
6782    dialog_bridge: &JsDialogBridge,
6783    args: &Args,
6784    images: Vec<rpi_ai::types::ImageContent>,
6785) {
6786    // The persistent Node host is intentionally started at the first real
6787    // prompt. By this point the TUI key worker and all UI/runtime handlers are
6788    // live, so a `before_agent_start` hook may safely open a native dialog. A
6789    // session with no prompt never starts Node merely to render its welcome
6790    // screen; JS commands/tools still trigger the same lazy ensure path.
6791    // Preparation is part of the active turn. Mark it working before Node can
6792    // block so Ctrl+C, Ctrl+D, and Esc all retain their documented abort
6793    // semantics for initial argv prompts as well as editor submissions.
6794    state.set_status(RunStatus::Working);
6795    tui.request_render(false);
6796    if !ensure_js_runtime_before_prompt(js, lane, state, dialog_bridge, args).await {
6797        state.set_status(RunStatus::Idle);
6798        tui.request_render(false);
6799        return;
6800    }
6801
6802    let outcome = lane.prompt_text(prompt, images).await;
6803
6804    if streaming {
6805        // Broadcast delivery is asynchronous: the harness result can resolve
6806        // before the drain task processes MessageEnd. Reconcile from the
6807        // authoritative outcome before detaching the component so the final
6808        // streamed tail cannot be left at an earlier partial snapshot.
6809        let final_message = match &outcome {
6810            Ok(result) => match &result.outcome {
6811                HarnessRunOutcome::Completed { final_message, .. }
6812                | HarnessRunOutcome::Aborted { final_message, .. } => Some(final_message),
6813                HarnessRunOutcome::Failed { final_message, .. } => final_message.as_ref(),
6814                HarnessRunOutcome::Suspended { .. } => None,
6815            },
6816            Err(_) => None,
6817        };
6818        reconcile_streamed_assistant_completion(
6819            &state.current_assistant,
6820            &state.last_assistant_text,
6821            final_message,
6822        );
6823    }
6824
6825    state.set_status(RunStatus::Idle);
6826
6827    match outcome {
6828        Ok(result) => match &result.outcome {
6829            HarnessRunOutcome::Failed {
6830                error,
6831                final_message,
6832                ..
6833            } => {
6834                // Only add an error line if the stream did NOT already render
6835                // an assistant message for it (drain task leaves
6836                // current_assistant Some only on an abrupt end).
6837                // A final assistant error is emitted by the event drain only
6838                // in streaming mode. In regular mode there is no drain task,
6839                // so suppressing this branch merely hides 405/auth/network
6840                // diagnostics from the user.
6841                let already_rendered = streaming && final_message.is_some();
6842                if !already_rendered {
6843                    let msg = final_message
6844                        .as_ref()
6845                        .and_then(|m| m.error_message.clone())
6846                        .unwrap_or_else(|| format!("{error:?}"));
6847                    add_error_message(&state.chat_container, &msg);
6848                }
6849            }
6850            HarnessRunOutcome::Suspended { .. } => {
6851                add_error_message(
6852                    &state.chat_container,
6853                    "Run suspended (deferred) — resume is not supported in v1.",
6854                );
6855            }
6856            HarnessRunOutcome::Aborted { final_message, .. } => {
6857                // Aborted runs render their own partial/final message via the
6858                // stream; only add a note on the blocking fallback path.
6859                if !streaming {
6860                    add_error_message(&state.chat_container, "Request aborted.");
6861                    let _ = final_message; // (rendered by the stream in streaming mode)
6862                }
6863            }
6864            HarnessRunOutcome::Completed { final_message, .. } => {
6865                if !streaming {
6866                    let text = assistant_text(final_message);
6867                    if !text.is_empty() {
6868                        add_assistant_message_blocking(
6869                            &state.chat_container,
6870                            &text,
6871                            state.markdown_transformer(),
6872                        );
6873                        *state.last_assistant_text.lock().unwrap() = text;
6874                    }
6875                }
6876            }
6877        },
6878        Err(e) => {
6879            add_error_message(&state.chat_container, &e.to_string());
6880        }
6881    }
6882
6883    tui.request_render(false);
6884}
6885
6886/// `/compact`: drive a compaction on the lane (mirrors TS `app.compact`).
6887/// Reports the outcome as a transcript note; v1's compaction summarizes the
6888/// session in place, so no streaming display is wired (compaction emits no
6889/// `AgentEvent`s — only the harness bus `RunEnd`).
6890async fn run_compact(lane: &Arc<dyn AgentLane>, tui: &Arc<TuiAltScreen>, state: &Arc<TuiState>) {
6891    state.set_status(RunStatus::Working);
6892    tui.request_render(false);
6893    match lane.compact(None).await {
6894        Ok(_) => {
6895            add_note_message(&state.chat_container, "Conversation compacted.");
6896        }
6897        Err(e) => {
6898            add_error_message(&state.chat_container, &format!("Compact failed: {e}"));
6899        }
6900    }
6901    state.set_status(RunStatus::Idle);
6902    tui.request_render(false);
6903}
6904
6905/// `/copy`: copy the last assistant reply to the clipboard. Best-effort —
6906/// when no clipboard is available (or the `clipboard` feature is off), prints a
6907/// hint instead. Mirrors the TS `/copy` (copies `this.messages.at(-1)` text).
6908fn copy_last_assistant(state: &Arc<TuiState>, chat: &Arc<Container>) {
6909    let text = state.last_assistant_text.lock().unwrap().clone();
6910    if text.is_empty() {
6911        add_note_message(chat, "Nothing to copy yet — no assistant reply captured.");
6912        return;
6913    }
6914    if copy_to_clipboard(&text) {
6915        add_note_message(chat, "Copied last reply to the clipboard.");
6916    } else {
6917        // Clipboard unavailable — print the text to the transcript so the user
6918        // can select/copy it manually (degrades gracefully in headless envs).
6919        let preview: String = text.chars().take(200).collect();
6920        add_note_message(
6921            chat,
6922            &format!(
6923                "Clipboard unavailable. Last reply: {preview}{}",
6924                if text.chars().count() > 200 {
6925                    "…"
6926                } else {
6927                    ""
6928                }
6929            ),
6930        );
6931    }
6932}
6933
6934/// Extract text from the chat container within the given screen coordinates.
6935/// Returns None if the selection is invalid or empty.
6936fn extract_selected_text(
6937    chat_container: &Arc<Container>,
6938    start: (u16, u16),
6939    end: (u16, u16),
6940) -> Option<String> {
6941    use rpi_tui::Component;
6942    
6943    // Get the rendered lines from the chat container
6944    let lines = chat_container.render(80); // Use a reasonable width
6945    if lines.is_empty() {
6946        return None;
6947    }
6948    
6949    // Normalize coordinates (ensure start is before end)
6950    let (start_row, end_row) = if start.1 <= end.1 {
6951        (start.1 as usize, end.1 as usize)
6952    } else {
6953        (end.1 as usize, start.1 as usize)
6954    };
6955    
6956    // Clamp to valid range
6957    let start_row = start_row.min(lines.len().saturating_sub(1));
6958    let end_row = end_row.min(lines.len().saturating_sub(1));
6959    
6960    if start_row > end_row {
6961        return None;
6962    }
6963    
6964    // Extract the selected lines
6965    let selected_lines: Vec<String> = lines[start_row..=end_row].to_vec();
6966    
6967    if selected_lines.is_empty() {
6968        return None;
6969    }
6970    
6971    Some(selected_lines.join("\n"))
6972}
6973
6974/// Best-effort clipboard write. Enabled only with the `clipboard` feature
6975/// (`arboard`) on non-Android platforms; otherwise returns `false` so the 
6976/// caller degrades to a hint.
6977#[cfg(all(feature = "clipboard", not(target_os = "android")))]
6978fn copy_to_clipboard(text: &str) -> bool {
6979    match arboard::Clipboard::new() {
6980        Ok(mut cb) => cb.set_text(text).is_ok(),
6981        Err(_) => false,
6982    }
6983}
6984
6985#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
6986fn copy_to_clipboard(_text: &str) -> bool {
6987    false
6988}
6989
6990/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
6991/// `ImageContent` contract. The optional clipboard feature keeps headless
6992/// builds free of platform clipboard dependencies.
6993#[cfg(all(feature = "clipboard", not(target_os = "android")))]
6994fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6995    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6996    let image = match clipboard.get_image() {
6997        Ok(image) => image,
6998        Err(_) => return Ok(None),
6999    };
7000    let width =
7001        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7002    let height =
7003        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7004    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7005        return Err("clipboard image dimensions are outside the supported range".into());
7006    }
7007    let mut bytes = Vec::new();
7008    {
7009        let mut encoder = png::Encoder::new(&mut bytes, width, height);
7010        encoder.set_color(png::ColorType::Rgba);
7011        encoder.set_depth(png::BitDepth::Eight);
7012        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7013        writer
7014            .write_image_data(&image.bytes)
7015            .map_err(|e| e.to_string())?;
7016    }
7017    Ok(Some(rpi_ai::types::ImageContent {
7018        kind: rpi_ai::types::ImageContentType,
7019        data: base64::engine::general_purpose::STANDARD.encode(bytes),
7020        mime_type: "image/png".into(),
7021    }))
7022}
7023
7024fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7025    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7026        let mut options = ImageOptions::default();
7027        options.width = Some(48);
7028        options.alt_text = Some("Attached image".into());
7029        chat.add_child(Arc::new(Image::from_data(bytes, options)));
7030        chat.add_child(Arc::new(Spacer::new(1)));
7031    }
7032}
7033
7034#[cfg(any(not(feature = "clipboard"), target_os = "android"))]
7035fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7036    Ok(None)
7037}
7038
7039/// Blocking fallback (no `event_rx`): render the final assistant text as a
7040/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
7041/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
7042/// the identity path. The blocking path only fires when `event_rx` is absent,
7043/// so it shares the same transformer the streaming path installs on its
7044/// components.
7045fn add_assistant_message_blocking(
7046    container: &Arc<Container>,
7047    text: &str,
7048    transformer: Option<MarkdownTransformer>,
7049) {
7050    if text.is_empty() {
7051        return;
7052    }
7053    let msg = Arc::new(AssistantMessageComponent::new(
7054        AssistantMessageOptions::default(),
7055    ));
7056    if let Some(t) = &transformer {
7057        msg.set_markdown_transformer(Some(t.clone()));
7058    }
7059    msg.update_text(text);
7060    container.add_child(msg);
7061    container.add_child(Arc::new(Spacer::new(1)));
7062}
7063
7064// ===========================================================================
7065// AgentEvent drain task — the streaming core
7066// ===========================================================================
7067
7068/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
7069/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
7070/// lifetime of the TUI.
7071async fn drain_agent_events(
7072    mut rx: broadcast::Receiver<AgentEvent>,
7073    tui: Arc<TuiAltScreen>,
7074    state: Arc<TuiState>,
7075    chat: Arc<Container>,
7076) {
7077    loop {
7078        match rx.recv().await {
7079            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
7080            Err(broadcast::error::RecvError::Lagged(_)) => {
7081                // We dropped some intermediate deltas; the next MessageUpdate/
7082                // MessageEnd carries a full partial snapshot so the UI re-syncs.
7083                continue;
7084            }
7085            Err(broadcast::error::RecvError::Closed) => break,
7086        }
7087    }
7088}
7089
7090/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
7091/// (`interactive-mode.ts:3068-3396`).
7092async fn handle_agent_event(
7093    event: AgentEvent,
7094    tui: &Arc<TuiAltScreen>,
7095    state: &Arc<TuiState>,
7096    chat: &Arc<Container>,
7097) {
7098    match event {
7099        AgentEvent::AgentStart => {
7100            state.set_status(RunStatus::Working);
7101            tui.request_render(false);
7102        }
7103
7104        AgentEvent::AgentEnd { .. } => {
7105            // Finalize any still-streaming assistant message.
7106            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7107                comp.set_streaming(false);
7108            }
7109            state.set_status(RunStatus::Idle);
7110            tui.request_render(false);
7111        }
7112
7113        AgentEvent::RetryScheduled {
7114            attempt,
7115            max_retries,
7116            delay_ms,
7117            ..
7118        } => {
7119            state.show_retry(attempt, max_retries, delay_ms);
7120            tui.request_render(false);
7121        }
7122
7123        AgentEvent::TurnStart => {
7124            // A new turn: reset the streaming-assistant guard so the next
7125            // MessageStart creates a fresh component.
7126            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7127                comp.set_streaming(false);
7128            }
7129        }
7130
7131        AgentEvent::TurnEnd {
7132            message,
7133            tool_results,
7134        } => {
7135            // Finalize the assistant message for this turn.
7136            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7137                if let AgentMessage::Assistant(a) = &message {
7138                    comp.update_blocks(&assistant_blocks(a));
7139                }
7140                comp.set_streaming(false);
7141            }
7142            // Any tool results whose components were never ended by a
7143            // ToolExecutionEnd get a static rendering here (best-effort). The
7144            // normal path removes the component via ToolExecutionEnd; this is
7145            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
7146            let tools = state.tool_components.lock().unwrap();
7147            for tr in &tool_results {
7148                if tools.contains_key(&tr.tool_call_id) {
7149                    // Will be removed below via ToolExecutionEnd in the normal
7150                    // path; leave as-is if still present.
7151                    let _ = tr;
7152                }
7153            }
7154            drop(tools);
7155            tui.request_render(false);
7156        }
7157
7158        AgentEvent::MessageStart { message } => match message {
7159            AgentMessage::Assistant(a) => {
7160                let comp = Arc::new(AssistantMessageComponent::new(
7161                    AssistantMessageOptions::default(),
7162                ));
7163                // B5e: install the live markdown transformer so the plugin's
7164                // `register_markdown_transformer` handlers apply from the very
7165                // first streamed delta. `set_streaming` before the transform
7166                // install is fine (transform fires on `update_blocks`, below).
7167                if let Some(t) = state.markdown_transformer() {
7168                    comp.set_markdown_transformer(Some(t));
7169                }
7170                comp.set_hide_thinking(state.hide_thinking());
7171                comp.set_streaming(true);
7172                // Render text AND thinking blocks in order (the old path fed
7173                // only the concatenated text, so thinking blocks never showed).
7174                comp.update_blocks(&assistant_blocks(&a));
7175                chat.add_child(comp.clone());
7176                // Spacer(1) separates this assistant turn from the next entry;
7177                // the component itself adds no leading spacer.
7178                chat.add_child(Arc::new(Spacer::new(1)));
7179                *state.current_assistant.lock().unwrap() = Some(comp);
7180                tui.request_render(false);
7181            }
7182            AgentMessage::Custom(custom) => {
7183                let payload = serde_json::json!({
7184                    "customType": custom.role,
7185                    "content": custom.content,
7186                    "details": custom.data,
7187                    "expanded": false,
7188                    "outputPad": 1,
7189                });
7190                if let Some(component) = extension_message_component(
7191                    &state.extension_session,
7192                    &custom.role,
7193                    &payload,
7194                    state.markdown_transformer(),
7195                ) {
7196                    chat.add_child(component);
7197                    chat.add_child(Arc::new(Spacer::new(1)));
7198                    tui.request_render(false);
7199                } else {
7200                    add_note_message(chat, &custom_message_fallback(&custom));
7201                    tui.request_render(false);
7202                }
7203            }
7204            // User / ToolResult / Custom starts are echoed at submit time or
7205            // via the tool-execution components; ignore user/tool dupes.
7206            _ => {}
7207        },
7208
7209        AgentEvent::MessageUpdate {
7210            message,
7211            assistant_message_event,
7212        } => {
7213            if let AgentMessage::Assistant(a) = &message {
7214                let text = assistant_text(a);
7215                let mut saw_bash_tool_call = false;
7216                // Scan content for finalized tool calls → proactively create
7217                // tool components (TS shows the tool as soon as the assistant
7218                // emits the ToolCall; ToolExecutionStart coalesces if it
7219                // already exists).
7220                for c in &a.content {
7221                    if let Content::ToolCall(tc) = c {
7222                        // Streaming providers may expose a placeholder tool
7223                        // call before its name has arrived. It is not a real
7224                        // tool panel and must not leave an empty first row.
7225                        if tc.name.trim().is_empty() {
7226                            continue;
7227                        }
7228                        if tc.name == "bash" {
7229                            // Bash has a dedicated component. Create it here as
7230                            // well as on ToolExecutionStart because the tool
7231                            // call can become visible in a MessageUpdate first.
7232                            // Keeping it in the bash map lets Start coalesce
7233                            // with this panel instead of appending a second one.
7234                            let command = tc
7235                                .arguments
7236                                .get("command")
7237                                .and_then(|v| v.as_str())
7238                                .unwrap_or("");
7239                            // Streaming tool-call arguments may still be `{}`
7240                            // here. Do not create a running bash panel until
7241                            // the lifecycle start event provides the command;
7242                            // otherwise the spinner renders first and the
7243                            // actual `$ command` header appears one frame
7244                            // later.
7245                            if command.trim().is_empty() {
7246                                continue;
7247                            }
7248                            saw_bash_tool_call = true;
7249                            let mut bash = state.bash_components.lock().unwrap();
7250                            if let Some(existing) = bash.get(&tc.id) {
7251                                // Tool-call arguments arrive incrementally.
7252                                // Refresh the running panel from every full
7253                                // assistant snapshot instead of leaving its
7254                                // header on the first partial command.
7255                                existing.set_command(command);
7256                            } else {
7257                                let comp = Arc::new(BashExecutionComponent::new(command));
7258                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7259                                chat.add_child(comp.clone());
7260                                bash.insert(tc.id.clone(), comp);
7261                            }
7262                        } else {
7263                            let mut tools = state.tool_components.lock().unwrap();
7264                            let display_args = if is_ask_user_tool(&tc.name) {
7265                                ask_user_args_display(&tc.arguments)
7266                            } else {
7267                                tc.arguments.to_string()
7268                            };
7269                            if let Some(existing) = tools.get(&tc.id) {
7270                                // Regular tool arguments stream incrementally
7271                                // too, so refresh their live header as soon as
7272                                // a more complete snapshot arrives.
7273                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
7274                                    existing.set_args(&display_args);
7275                                }
7276                            } else {
7277                                let comp = Arc::new(ToolExecutionComponent::new(
7278                                    &tc.name,
7279                                    &display_args,
7280                                ));
7281                                if is_ask_user_tool(&tc.name) {
7282                                    comp.set_display_title("ASK USER");
7283                                }
7284                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7285                                comp.set_running();
7286                                chat.add_child(comp.clone());
7287                                tools.insert(tc.id.clone(), comp);
7288                            }
7289                        }
7290                    }
7291                }
7292                // MessageUpdate can expose the finalized bash call before
7293                // ToolExecutionStart arrives. Hide the global `Working…`
7294                // loader immediately when creating that bash panel; otherwise
7295                // it briefly appears alongside the panel's `Running…` spinner.
7296                if saw_bash_tool_call {
7297                    state.sync_working_loader_with_bash();
7298                }
7299                let _ = assistant_message_event; // snapshot already applied via `a`
7300                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
7301                    // Stream the full block list (text + thinking) each update
7302                    // so thinking blocks render live as they arrive.
7303                    comp.update_blocks(&assistant_blocks(a));
7304                }
7305                *state.last_assistant_text.lock().unwrap() = text;
7306                tui.request_render(false);
7307            }
7308        }
7309
7310        AgentEvent::MessageEnd { message } => {
7311            if let AgentMessage::Assistant(a) = &message {
7312                let text = assistant_text(a);
7313                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7314                    comp.update_blocks(&assistant_blocks(a));
7315                    comp.set_streaming(false);
7316                }
7317                // Cache the finalized text for `/copy`.
7318                if !text.is_empty() {
7319                    *state.last_assistant_text.lock().unwrap() = text;
7320                }
7321                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
7322                // the previous turn's input established a cacheable prefix; a
7323                // large input this turn that read nothing from cache means the
7324                // prefix was re-billed. No cost display — v1 has no per-run
7325                // cost tracking here.
7326                let usage = &a.usage;
7327                let prev_input = *state.last_input_tokens.lock().unwrap();
7328                if prev_input > 0
7329                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
7330                    && usage.cache_read == 0
7331                {
7332                    add_note_message(
7333                        &state.chat_container,
7334                        &format!(
7335                            "Cache miss: {} tokens re-billed",
7336                            format_tokens(usage.input)
7337                        ),
7338                    );
7339                }
7340                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7341                    add_note_message(chat, &text);
7342                }
7343                // Error assistant messages carry the provider diagnostic in
7344                // `error_message`, not in text content. The assistant
7345                // component is empty for these messages, so surface the
7346                // diagnostic as a visible error row in the transcript.
7347                if let Some(error) = assistant_error_text(a) {
7348                    add_error_message(chat, &error);
7349                }
7350                *state.last_input_tokens.lock().unwrap() = usage.input;
7351            }
7352            tui.request_render(false);
7353        }
7354
7355        AgentEvent::ToolExecutionStart {
7356            tool_call_id,
7357            tool_name,
7358            args,
7359        } => {
7360            // Ignore placeholder lifecycle events emitted before the
7361            // provider has supplied a tool name.
7362            if tool_name.trim().is_empty() {
7363                return;
7364            }
7365            if tool_name == "bash" {
7366                // Bash streams into a dedicated BashExecutionComponent (command
7367                // header + live preview + exit/truncation status) rather than a
7368                // generic ToolExecutionComponent. The command comes from the
7369                // `command` field of the bash tool args.
7370                let command = args
7371                    .get("command")
7372                    .and_then(|v| v.as_str())
7373                    .unwrap_or("")
7374                    .to_string();
7375                // Bash arguments can still be `{}` when the lifecycle event
7376                // races the streamed tool-call argument finalization. Defer
7377                // the panel until a later start event carries the command.
7378                if command.trim().is_empty() {
7379                    return;
7380                }
7381                let mut bash_map = state.bash_components.lock().unwrap();
7382                if let Some(existing) = bash_map.get(&tool_call_id) {
7383                    // A ToolExecutionUpdate already created the panel (fast
7384                    // command — Update can arrive before Start); backfill the
7385                    // command header instead of adding a SECOND panel, which
7386                    // used to stack an empty "$ " box above the real one.
7387                    existing.set_command(&command);
7388                } else {
7389                    let comp = Arc::new(BashExecutionComponent::new(command));
7390                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7391                    chat.add_child(comp.clone());
7392                    bash_map.insert(tool_call_id.clone(), comp);
7393                }
7394            } else {
7395                let _comp = {
7396                    let mut tools = state.tool_components.lock().unwrap();
7397                    if let Some(existing) = tools.get(&tool_call_id) {
7398                        if is_ask_user_tool(&tool_name) {
7399                            existing.set_display_title("ASK USER");
7400                            existing.set_args(&ask_user_args_display(&args));
7401                        } else {
7402                            existing.set_args(&args.to_string());
7403                        }
7404                        existing.clone()
7405                    } else {
7406                        let display_args = if is_ask_user_tool(&tool_name) {
7407                            ask_user_args_display(&args)
7408                        } else {
7409                            args.to_string()
7410                        };
7411                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7412                        if is_ask_user_tool(&tool_name) {
7413                            comp.set_display_title("ASK USER");
7414                        }
7415                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7416                        // A `read` of a SKILL.md renders as native Pi's
7417                        // `[skill] <name>` invocation box (custom-message
7418                        // background, collapsed to one line, Ctrl+O expands the
7419                        // skill markdown) instead of a generic READ tool panel.
7420                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
7421                            comp.set_skill_name(skill);
7422                        }
7423                        comp.set_running();
7424                        chat.add_child(comp.clone());
7425                        tools.insert(tool_call_id.clone(), comp.clone());
7426                        comp
7427                    }
7428                };
7429            }
7430            state.sync_working_loader_with_bash();
7431            tui.request_render(false);
7432        }
7433
7434        AgentEvent::ToolExecutionUpdate {
7435            tool_call_id,
7436            tool_name,
7437            args,
7438            partial_result,
7439        } => {
7440            if tool_name.trim().is_empty() {
7441                return;
7442            }
7443            let partial_text = if is_ask_user_tool(&tool_name) {
7444                ask_user_progress_text(&args, &partial_result)
7445            } else {
7446                tool_result_text(&partial_result)
7447            };
7448            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7449            if tool_name == "bash" {
7450                // Append the streamed chunk to the bash component's preview.
7451                // RAW text (no single-line collapsing) — the old
7452                // `summarize_tool_result` folded every newline into a `⏎`
7453                // glyph, cramming e.g. `ls -la`'s listing onto one line.
7454                let chunk = partial_text;
7455                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7456                    // Lifecycle updates can contain a more complete args object
7457                    // than the snapshot that created this running panel.
7458                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7459                        bash.set_command(command);
7460                    }
7461                    if has_partial_payload {
7462                        bash.append_output(&chunk);
7463                    }
7464                } else if has_partial_payload {
7465                    // ToolExecutionStart is emitted before a tool can run.
7466                    // Ignore an out-of-order partial until that event gives us
7467                    // the real command, rather than showing a spinner above an
7468                    // empty `$ ` header. Normal updates are handled by the
7469                    // component created in ToolExecutionStart.
7470                }
7471            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7472                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7473                    comp.set_skill_name(skill);
7474                }
7475                if is_ask_user_tool(&tool_name) {
7476                    comp.set_display_title("ASK USER");
7477                    comp.set_args(&ask_user_args_display(&args));
7478                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7479                    comp.set_args(&args.to_string());
7480                }
7481                // Raw multi-line text — read/ls-style tools must show their
7482                // full content, not the single-line ⏎-folded summary.
7483                if has_partial_payload {
7484                    comp.set_result(&partial_text, false);
7485                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7486                }
7487            } else if has_partial_payload {
7488                // No component yet — create a running one so the partial shows.
7489                // Empty callbacks are common before ToolExecutionStart; wait
7490                // for Start so the first panel has the real arguments instead
7491                // of an empty `TOOLS` box.
7492                let display_args = if is_ask_user_tool(&tool_name) {
7493                    ask_user_args_display(&args)
7494                } else {
7495                    args.to_string()
7496                };
7497                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7498                if is_ask_user_tool(&tool_name) {
7499                    comp.set_display_title("ASK USER");
7500                }
7501                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7502                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7503                    comp.set_skill_name(skill);
7504                }
7505                comp.set_running();
7506                comp.set_result(&partial_text, false);
7507                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7508                chat.add_child(comp.clone());
7509                state
7510                    .tool_components
7511                    .lock()
7512                    .unwrap()
7513                    .insert(tool_call_id.clone(), comp.clone());
7514            }
7515            state.sync_working_loader_with_bash();
7516            tui.request_render(false);
7517        }
7518
7519        AgentEvent::ToolExecutionEnd {
7520            tool_call_id,
7521            tool_name,
7522            result,
7523            is_error,
7524        } => {
7525            if tool_name.trim().is_empty() {
7526                return;
7527            }
7528            if tool_name == "bash" {
7529                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7530                if let Some(bash) = bash {
7531                    finalize_bash(&bash, &result, is_error);
7532                } else {
7533                    // Bash ended without a Start/Update — render a finalized
7534                    // component directly from the result text.
7535                    let command = result
7536                        .details
7537                        .get("command")
7538                        .and_then(|v| v.as_str())
7539                        .unwrap_or("")
7540                        .to_string();
7541                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7542                        state.sync_working_loader_with_bash();
7543                        // Edge case: empty bash result. Still render immediately
7544                        // to clear the panel state for the user.
7545                        tui.render_now(false);
7546                        return;
7547                    }
7548                    let comp = Arc::new(BashExecutionComponent::new(command));
7549                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7550                    comp.append_output(&tool_result_text(&result));
7551                    finalize_bash(&comp, &result, is_error);
7552                    chat.add_child(comp);
7553                }
7554            } else {
7555                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7556                if let Some(comp) = comp {
7557                    let result_text = if is_ask_user_tool(&tool_name) {
7558                        ask_user_result_text(&result)
7559                    } else {
7560                        tool_result_text(&result)
7561                    };
7562                    comp.set_result(&result_text, is_error);
7563                    if !is_ask_user_tool(&tool_name) {
7564                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7565                    }
7566                } else {
7567                    // Tool ended without a Start/Update (e.g. a very fast tool):
7568                    // render a finalized component directly.
7569                    let comp = Arc::new(ToolExecutionComponent::new(
7570                        &tool_name,
7571                        &if is_ask_user_tool(&tool_name) {
7572                            ask_user_args_display(&serde_json::Value::Null)
7573                        } else {
7574                            "".to_string()
7575                        },
7576                    ));
7577                    if is_ask_user_tool(&tool_name) {
7578                        comp.set_display_title("ASK USER");
7579                    }
7580                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7581                    let result_text = if is_ask_user_tool(&tool_name) {
7582                        ask_user_result_text(&result)
7583                    } else {
7584                        tool_result_text(&result)
7585                    };
7586                    comp.set_result(&result_text, is_error);
7587                    if !is_ask_user_tool(&tool_name) {
7588                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7589                    }
7590                    chat.add_child(comp.clone());
7591                }
7592            }
7593            state.sync_working_loader_with_bash();
7594            // Bash tool completion is user-visible: immediate render ensures the
7595            // result is displayed even when the scheduler thread is busy or the
7596            // throttle window has not elapsed. This single immediate frame does
7597            // not regress the streaming-output throttle that keeps CPU/GPU use
7598            // low (bash completions are discrete events, not per-token bursts).
7599            tui.render_now(false);
7600        }
7601    }
7602}
7603
7604/// Return the diagnostic carried by a failed or aborted provider request.
7605/// Providers may omit `error_message`; keep a stable fallback so a terminal
7606/// request failure can never render as an empty transcript turn.
7607fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7608    let fallback = match message.stop_reason {
7609        rpi_ai::StopReason::Error => "Provider request failed.",
7610        rpi_ai::StopReason::Aborted => "Request aborted.",
7611        _ => return None,
7612    };
7613    Some(
7614        message
7615            .error_message
7616            .as_deref()
7617            .filter(|text| !text.trim().is_empty())
7618            .unwrap_or(fallback)
7619            .to_string(),
7620    )
7621}
7622
7623/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
7624/// tool result and mark the component complete. Mirrors the TS bash finalize
7625/// path; only the fields `BashExecutionComponent` needs are read.
7626fn finalize_bash(
7627    comp: &Arc<BashExecutionComponent>,
7628    result: &rpi_agent::AgentToolResult,
7629    is_error: bool,
7630) {
7631    // The exit code isn't in details directly (TS carries it elsewhere); use
7632    // `is_error` as the error signal and 0/1 as a best-effort exit code.
7633    let exit_code = if is_error { Some(1) } else { Some(0) };
7634    let truncated = result
7635        .details
7636        .get("truncation")
7637        .and_then(|t| t.get("truncated"))
7638        .and_then(|v| v.as_bool())
7639        .unwrap_or(false);
7640    let full_output_path = result
7641        .details
7642        .get("full_output_path")
7643        .and_then(|v| v.as_str())
7644        .map(|s| s.to_string());
7645    let truncation = BashTruncation {
7646        truncated,
7647        full_output_path,
7648    };
7649    // Some tool backends attach the authoritative command to result details.
7650    // Backfill it before completing so this is a fallback, never the first
7651    // opportunity for the UI to show the whole command.
7652    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7653        comp.set_command(command);
7654    }
7655    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
7656    comp.set_complete(exit_code, cancelled, truncation);
7657}
7658
7659/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
7660/// display-diff string, render it with colors and attach to the component so
7661/// the changes show in the transcript. `write` has no diff (details: Null) and
7662/// stays a plain summary.
7663fn apply_edit_diff(
7664    comp: &Arc<ToolExecutionComponent>,
7665    tool_name: &str,
7666    details: &serde_json::Value,
7667    tui: &Arc<TuiAltScreen>,
7668) {
7669    if tool_name != "edit" {
7670        return;
7671    }
7672    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7673        return;
7674    };
7675    if diff_text.is_empty() {
7676        return;
7677    }
7678    let width = tui.width();
7679    let lines = render_diff(diff_text, width);
7680    comp.set_diff(lines);
7681}
7682
7683/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
7684/// `None`. The name is the `SKILL.md` parent directory's basename (matching
7685/// native Pi's skill-file convention). Ordinary markdown/document reads
7686/// return `None` and remain regular `READ` tool panels.
7687fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7688    if tool_name != "read" {
7689        return None;
7690    }
7691    let path = args.get("path").and_then(|value| value.as_str())?;
7692    let normalized = path.replace('\\', "/");
7693    let file_name = normalized.rsplit('/').next()?;
7694    if !file_name.eq_ignore_ascii_case("SKILL.md") {
7695        return None;
7696    }
7697    normalized
7698        .trim_end_matches('/')
7699        .rsplit('/')
7700        .nth(1)
7701        .filter(|name| !name.is_empty())
7702        .map(str::to_string)
7703}
7704
7705/// Render an `AgentToolResult` as a single-line summary for the
7706/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
7707fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7708    use rpi_agent::TextContentOrImage;
7709    let mut parts: Vec<String> = Vec::new();
7710    for c in &result.content {
7711        if let TextContentOrImage::Text(t) = c {
7712            parts.push(t.text.clone());
7713        }
7714    }
7715    let joined = parts.join("\n");
7716    // Keep the tool line compact: collapse to a single line, trim length.
7717    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7718    if one_line.chars().count() > 200 {
7719        let truncated: String = one_line.chars().take(200).collect();
7720        format!("{truncated}…")
7721    } else {
7722        one_line
7723    }
7724}
7725
7726/// The raw multi-line text of a tool result (no single-line collapsing). The
7727/// bash panel needs the original line structure — the old path fed it through
7728/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
7729/// crammed e.g. `ls -la`'s whole listing onto one line.
7730fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7731    use rpi_agent::TextContentOrImage;
7732    let mut parts: Vec<String> = Vec::new();
7733    for c in &result.content {
7734        if let TextContentOrImage::Text(t) = c {
7735            parts.push(t.text.clone());
7736        }
7737    }
7738    parts.join("\n")
7739}
7740
7741/// Empty progress callbacks are valid (notably before a tool's start event),
7742/// but they do not contain anything useful to render. Defer those callbacks so
7743/// the first tool panel is created from `ToolExecutionStart` with real args.
7744fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7745    !text.trim().is_empty() || !result.details.is_null()
7746}
7747
7748/// Tool names rendered through the dedicated `ASK USER` panel instead of a
7749/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
7750fn is_ask_user_tool(tool_name: &str) -> bool {
7751    matches!(tool_name, "ask_user" | "ask_user_question")
7752}
7753
7754/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
7755/// transport JSON must never leak into the transcript.
7756fn ask_user_args_display(args: &serde_json::Value) -> String {
7757    if args.is_null() {
7758        return String::new();
7759    }
7760    if let Some(summary) = args
7761        .get("summary")
7762        .and_then(serde_json::Value::as_str)
7763        .map(str::trim)
7764        .filter(|text| !text.is_empty())
7765    {
7766        return format!("Confirmation requested: {summary}");
7767    }
7768
7769    let mut lines: Vec<String> = Vec::new();
7770    let questions: Vec<&serde_json::Value> = args
7771        .get("questions")
7772        .and_then(serde_json::Value::as_array)
7773        .filter(|items| !items.is_empty())
7774        .map(|items| items.iter().collect())
7775        .unwrap_or_else(|| vec![args]);
7776
7777    for question in questions {
7778        let prompt = question
7779            .get("question")
7780            .and_then(serde_json::Value::as_str)
7781            .or_else(|| {
7782                args.get("question")
7783                    .and_then(serde_json::Value::as_str)
7784            })
7785            .map(str::trim)
7786            .unwrap_or_default();
7787        if prompt.is_empty() {
7788            continue;
7789        }
7790        if let Some(header) = question
7791            .get("header")
7792            .and_then(serde_json::Value::as_str)
7793            .map(str::trim)
7794            .filter(|text| !text.is_empty())
7795        {
7796            lines.push(format!("{header}: {prompt}"));
7797        } else {
7798            lines.push(prompt.to_string());
7799        }
7800        let context = question
7801            .get("context")
7802            .and_then(serde_json::Value::as_str)
7803            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7804            .map(str::trim)
7805            .filter(|text| !text.is_empty());
7806        if let Some(context) = context {
7807            lines.push(format!("  {context}"));
7808        }
7809        let options = question
7810            .get("options")
7811            .and_then(serde_json::Value::as_array)
7812            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7813        if let Some(options) = options {
7814            let labels: Vec<String> = options
7815                .iter()
7816                .filter_map(|option| {
7817                    option
7818                        .get("title")
7819                        .and_then(serde_json::Value::as_str)
7820                        .or_else(|| option.as_str())
7821                        .map(str::trim)
7822                        .filter(|label| !label.is_empty())
7823                        .map(str::to_string)
7824                })
7825                .collect();
7826            if !labels.is_empty() {
7827                lines.push(format!("Choices: {}", labels.join(", ")));
7828            }
7829        }
7830        if let Some(suggest) = question
7831            .get("suggest")
7832            .and_then(serde_json::Value::as_str)
7833            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7834            .map(str::trim)
7835            .filter(|text| !text.is_empty())
7836        {
7837            lines.push(format!("Suggestions: {suggest}"));
7838        }
7839    }
7840
7841    lines.join("\n")
7842}
7843
7844/// Progress text while an ask_user tool is pending. Prefer the plugin's own
7845/// readable `content`, otherwise show the question so the panel is not blank.
7846fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7847    let text = tool_result_text(result);
7848    if !text.trim().is_empty() {
7849        return text;
7850    }
7851    let args_text = ask_user_args_display(args);
7852    if args_text.trim().is_empty() {
7853        "Waiting for your answer…".to_string()
7854    } else {
7855        args_text
7856    }
7857}
7858
7859/// Final result text for an ask_user tool. The plugin returns a human-readable
7860/// answer summary in `content`; never surface the transport JSON.
7861fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7862    let text = tool_result_text(result);
7863    if text.trim().is_empty() {
7864        "Answer recorded.".to_string()
7865    } else {
7866        text
7867    }
7868}
7869
7870// ===========================================================================
7871// Selectors — editor-container swap (TS showSelector pattern)
7872// ===========================================================================
7873
7874/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
7875/// hiding the editor while the selector is open. Records the selector in
7876/// `state.active_selector` so the key loop routes to it.
7877fn open_selector(
7878    state: &Arc<TuiState>,
7879    editor_container: &Arc<Container>,
7880    editor: &Arc<Editor>,
7881    tui: &Arc<TuiAltScreen>,
7882    list: Arc<SelectList>,
7883    kind: SelectorKind,
7884) {
7885    open_selector_with_view(
7886        state,
7887        editor_container,
7888        editor,
7889        tui,
7890        list.clone(),
7891        list,
7892        kind,
7893    );
7894}
7895
7896/// Open a selector with an optional framed view. Native extension selectors
7897/// wrap the list with a title and hint while built-in selectors keep the list
7898/// as the complete view.
7899fn open_selector_with_view<C: Component + 'static>(
7900    state: &Arc<TuiState>,
7901    editor_container: &Arc<Container>,
7902    editor: &Arc<Editor>,
7903    tui: &Arc<TuiAltScreen>,
7904    list: Arc<SelectList>,
7905    view: Arc<C>,
7906    kind: SelectorKind,
7907) {
7908    // Dispatch ui_prompt_start event
7909    dispatch_ui_prompt_event(state);
7910    
7911    // Unfocus the editor so its cursor marker doesn't render behind the list.
7912    editor.set_focused(false);
7913    // A selector replaces the editor slot. Drop stale slash/@file
7914    // suggestions so they cannot reappear after the selector closes.
7915    state.autocomplete_container.clear();
7916    // Swap: clear the container and add the selector view.
7917    editor_container.clear();
7918    editor_container.add_child(view.clone());
7919    *state.active_selector.lock().unwrap() = Some((list, kind));
7920    let focused: Arc<dyn Component> = view;
7921    tui.set_focus(Some(focused));
7922    tui.request_render(false);
7923}
7924
7925/// Restore the editor into the `editor_container` and clear the active
7926/// selector. Called by selector `on_cancel` and the Esc handler.
7927fn close_selector(
7928    state: &Arc<TuiState>,
7929    editor_container: &Arc<Container>,
7930    editor: &Arc<Editor>,
7931    tui: &Arc<TuiAltScreen>,
7932) {
7933    // Dispatch ui_prompt_end event
7934    dispatch_ui_prompt_event_end(state);
7935    
7936    editor_container.clear();
7937    editor_container.add_child(editor.clone());
7938    state.autocomplete_container.clear();
7939    editor.set_focused(true);
7940    *state.active_selector.lock().unwrap() = None;
7941    *state.active_extension_cancel.lock().unwrap() = None;
7942    tui.set_focus(Some(editor.clone()));
7943    tui.request_render(false);
7944}
7945
7946/// Build + open the `/model` selector. Items are the resolved catalog (display
7947/// label = model name; description = id), with the current model marked.
7948/// Selecting applies the model **live** via `lane.set_model` (takes effect on
7949/// the next user message — the in-flight run's config is already snapshotted),
7950/// updates the footer, and notes the next-prompt effect.
7951fn open_model_selector(
7952    state: &Arc<TuiState>,
7953    editor_container: &Arc<Container>,
7954    editor: &Arc<Editor>,
7955    tui: &Arc<TuiAltScreen>,
7956    catalog: &[rpi_ai::Model],
7957    lane: &Arc<dyn AgentLane>,
7958    lane_model_id: &str,
7959    chat: &Arc<Container>,
7960) {
7961    let items = model_selector_items(catalog, lane_model_id);
7962    if items.is_empty() {
7963        add_note_message(
7964            chat,
7965            "No models in the catalog. Use --model at startup to select one.",
7966        );
7967        tui.request_render(false);
7968        return;
7969    }
7970    let list = Arc::new(SelectList::new(items, 10));
7971
7972    // Capture the catalog + lane so the on_select closure can resolve the
7973    // chosen Model and apply it. `on_select` fires on the blocking key thread,
7974    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
7975    let catalog_arc = catalog.to_vec();
7976    let state_sel = state.clone();
7977    let ec_sel = editor_container.clone();
7978    let editor_sel = editor.clone();
7979    let tui_sel = tui.clone();
7980    let chat_sel = chat.clone();
7981    let lane_sel = lane.clone();
7982    list.on_select(Arc::new(move |item| {
7983        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7984            add_note_message(
7985                &chat_sel,
7986                &format!("Model {} not found in catalog.", item.label),
7987            );
7988            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7989            return;
7990        };
7991        state_sel.set_current_model(&model);
7992        let lane = lane_sel.clone();
7993        tokio::spawn(async move {
7994            let _ = lane.set_model(model).await;
7995        });
7996        add_note_message(
7997            &chat_sel,
7998            &format!(
7999                "Model set to {} — applies to the next message.",
8000                short_model_name(&item.value)
8001            ),
8002        );
8003        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8004    }));
8005    let state_cancel = state.clone();
8006    let ec_cancel = editor_container.clone();
8007    let editor_cancel = editor.clone();
8008    let tui_cancel = tui.clone();
8009    list.on_cancel(Arc::new(move || {
8010        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8011    }));
8012
8013    open_selector(
8014        state,
8015        editor_container,
8016        editor,
8017        tui,
8018        list,
8019        SelectorKind::Model,
8020    );
8021}
8022
8023/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
8024/// Returns `None` only when the catalog is empty or the current id isn't
8025/// found (in which case the first entry is returned — a no-op if it IS the
8026/// current). Used by the Ctrl+M model-cycle hotkey.
8027fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8028    if catalog.is_empty() {
8029        return None;
8030    }
8031    let idx = catalog
8032        .iter()
8033        .position(|m| m.id.eq_ignore_ascii_case(current_id));
8034    match idx {
8035        Some(i) => {
8036            let next = (i + 1) % catalog.len();
8037            Some(catalog[next].clone())
8038        }
8039        None => Some(catalog[0].clone()),
8040    }
8041}
8042
8043/// Build + open the `/session` selector. Lists JSONL session files under the
8044/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
8045/// fallback). Selecting reports "restore not
8046/// implemented in v1" (existing constraint) but shows the list for
8047/// discoverability.
8048fn open_session_selector(
8049    state: &Arc<TuiState>,
8050    editor_container: &Arc<Container>,
8051    editor: &Arc<Editor>,
8052    tui: &Arc<TuiAltScreen>,
8053    cwd: &std::path::Path,
8054    tx: &mpsc::UnboundedSender<TuiMessage>,
8055) {
8056    let dir = crate::session::default_session_dir(cwd);
8057    let mut items: Vec<SelectItem> = Vec::new();
8058    if let Ok(entries) = std::fs::read_dir(&dir) {
8059        for entry in entries.flatten() {
8060            let path = entry.path();
8061            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
8062                continue;
8063            }
8064            let stem = path
8065                .file_stem()
8066                .and_then(|s| s.to_str())
8067                .unwrap_or("(unnamed)")
8068                .to_string();
8069            let display = path
8070                .file_name()
8071                .and_then(|s| s.to_str())
8072                .unwrap_or(&stem)
8073                .to_string();
8074            items.push(SelectItem::new(&stem, &display));
8075        }
8076    }
8077    if items.is_empty() {
8078        add_note_message(
8079            &state.chat_container,
8080            "No saved sessions found. Sessions are created automatically in interactive mode.",
8081        );
8082        tui.request_render(false);
8083        return;
8084    }
8085    let list = Arc::new(SelectList::new(items, 10));
8086
8087    let state_sel = state.clone();
8088    let ec_sel = editor_container.clone();
8089    let editor_sel = editor.clone();
8090    let tui_sel = tui.clone();
8091    let tx_sel = tx.clone();
8092    list.on_select(Arc::new(move |item| {
8093        // Close the selector first, then ask the async loop to hot-switch:
8094        // opening the session file + swapping the harness backing is async
8095        // (repo list/open) and must not run on the blocking key thread.
8096        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8097        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8098    }));
8099    let state_cancel = state.clone();
8100    let ec_cancel = editor_container.clone();
8101    let editor_cancel = editor.clone();
8102    let tui_cancel = tui.clone();
8103    list.on_cancel(Arc::new(move || {
8104        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8105    }));
8106
8107    open_selector(
8108        state,
8109        editor_container,
8110        editor,
8111        tui,
8112        list,
8113        SelectorKind::Session,
8114    );
8115}
8116
8117fn custom_entry_display_text(
8118    custom_type: &str,
8119    data: Option<&serde_json::Value>,
8120) -> Option<String> {
8121    let data = data?;
8122    let text = data
8123        .get("summary")
8124        .or_else(|| data.get("text"))
8125        .or_else(|| data.get("output"))
8126        .and_then(|value| value.as_str())
8127        .filter(|value| !value.trim().is_empty())?;
8128    let label = match custom_type {
8129        "compactionSummary" => "Compaction summary",
8130        "branchSummary" => "Branch summary",
8131        "bashExecution" => "Command output",
8132        other => other,
8133    };
8134    Some(format!("{label}: {text}"))
8135}
8136
8137/// Open a selector for the current session's persisted entry tree. Selecting a
8138/// message moves the main lane leaf to that entry, then the caller reloads the
8139/// visible branch from durable storage.
8140async fn open_tree_selector(
8141    harness: &AgentHarness,
8142    state: &Arc<TuiState>,
8143    editor_container: &Arc<Container>,
8144    editor: &Arc<Editor>,
8145    tui: &Arc<TuiAltScreen>,
8146    chat: &Arc<Container>,
8147    tx: &mpsc::UnboundedSender<TuiMessage>,
8148) {
8149    let entries = match harness
8150        .session()
8151        .view("main")
8152        .find_entries(&EntryQuery {
8153            order: Some(EntryOrder::OldestFirst),
8154            ..Default::default()
8155        })
8156        .await
8157    {
8158        Ok(entries) => entries,
8159        Err(error) => {
8160            add_error_message(chat, &format!("Could not read session tree: {error}"));
8161            tui.request_render(false);
8162            return;
8163        }
8164    };
8165    let current = harness.session().get_leaf_id().await.ok().flatten();
8166    let items: Vec<SelectItem> = entries
8167        .iter()
8168        .map(|entry| {
8169            let marker = if current.as_deref() == Some(entry.id()) {
8170                " (current)"
8171            } else {
8172                ""
8173            };
8174            SelectItem::new(
8175                entry.id(),
8176                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
8177            )
8178            .with_description(&entry.id()[..entry.id().len().min(12)])
8179        })
8180        .collect();
8181    if items.is_empty() {
8182        add_note_message(chat, "The current session has no entries to navigate.");
8183        tui.request_render(false);
8184        return;
8185    }
8186    let list = Arc::new(SelectList::new(items, 12));
8187    let state_sel = state.clone();
8188    let ec_sel = editor_container.clone();
8189    let editor_sel = editor.clone();
8190    let tui_sel = tui.clone();
8191    let tx_sel = tx.clone();
8192    list.on_select(Arc::new(move |item| {
8193        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8194        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8195    }));
8196    let state_cancel = state.clone();
8197    let ec_cancel = editor_container.clone();
8198    let editor_cancel = editor.clone();
8199    let tui_cancel = tui.clone();
8200    list.on_cancel(Arc::new(move || {
8201        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8202    }));
8203    open_selector(
8204        state,
8205        editor_container,
8206        editor,
8207        tui,
8208        list,
8209        SelectorKind::Tree,
8210    );
8211}
8212
8213/// Build + open the `/theme` selector. Built-in presets and enabled package
8214/// themes are shown; selecting applies the theme live and re-renders.
8215fn open_theme_selector(
8216    state: &Arc<TuiState>,
8217    editor_container: &Arc<Container>,
8218    editor: &Arc<Editor>,
8219    tui: &Arc<TuiAltScreen>,
8220    cwd: &std::path::Path,
8221    package_resources: &Arc<crate::packages::PackageResources>,
8222) {
8223    let mut items = vec![
8224        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8225        SelectItem::new("light", "Light").with_description("Light background"),
8226        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8227    ];
8228    if state.themes_enabled {
8229        for path in package_resources.theme_files() {
8230            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
8231                items.push(SelectItem::new(name, name).with_description("Package theme"));
8232            }
8233        }
8234    }
8235    let list = Arc::new(SelectList::new(items, 10));
8236
8237    let state_sel = state.clone();
8238    let ec_sel = editor_container.clone();
8239    let editor_sel = editor.clone();
8240    let tui_sel = tui.clone();
8241    let chat_sel = state.chat_container.clone();
8242    let cwd_sel = cwd.to_path_buf();
8243    let package_resources_sel = package_resources.clone();
8244    list.on_select(Arc::new(move |item| {
8245        let preset = match item.value.as_str() {
8246            "light" => Some(ThemePreset::Light),
8247            "monochrome" => Some(ThemePreset::Monochrome),
8248            "dark" => Some(ThemePreset::Dark),
8249            name => {
8250                if state_sel.themes_enabled {
8251                    if let Ok(custom) = crate::packages::load_theme_with_resources(
8252                        &cwd_sel,
8253                        name,
8254                        &package_resources_sel,
8255                    ) {
8256                        rpi_tui::global_theme_manager().set(custom.clone());
8257                        state_sel.theme_manager.set(custom);
8258                    }
8259                }
8260                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8261                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8262                tui_sel.render_now(true);
8263                return;
8264            }
8265        };
8266        let Some(preset) = preset else { return };
8267        apply_theme_preset(preset);
8268        state_sel.theme_manager.apply_preset(preset);
8269        // A quick accent note so the user sees the change registered even if
8270        // the terminal's own colors mask the preset difference.
8271        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8272        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8273        tui_sel.render_now(true);
8274    }));
8275    let state_cancel = state.clone();
8276    let ec_cancel = editor_container.clone();
8277    let editor_cancel = editor.clone();
8278    let tui_cancel = tui.clone();
8279    list.on_cancel(Arc::new(move || {
8280        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8281    }));
8282
8283    open_selector(
8284        state,
8285        editor_container,
8286        editor,
8287        tui,
8288        list,
8289        SelectorKind::Theme,
8290    );
8291}
8292
8293// ===========================================================================
8294// Feasible selectors — /thinking, /tools, /images
8295// ===========================================================================
8296
8297/// One-line descriptions for each thinking level, ported from
8298/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
8299fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8300    use rpi_ai::types::ThinkingLevel::*;
8301    match level {
8302        Off => "Off — No reasoning",
8303        Minimal => "Minimal — Brief reasoning (~1k tokens)",
8304        Low => "Low — Light reasoning (~1k tokens)",
8305        Medium => "Medium — Moderate reasoning (~80% of max)",
8306        High => "High — Extensive reasoning (~95% of max)",
8307        Xhigh => "Xhigh — Near-maximal reasoning",
8308        Max => "Max — Maximum reasoning",
8309    }
8310}
8311
8312/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
8313/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
8314fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8315    use rpi_ai::types::ThinkingLevel::*;
8316    match level {
8317        Off => "off",
8318        Minimal => "minimal",
8319        Low => "low",
8320        Medium => "medium",
8321        High => "high",
8322        Xhigh => "xhigh",
8323        Max => "max",
8324    }
8325}
8326
8327/// Parse a thinking-level name back to the enum (case-insensitive). Returns
8328/// `None` for an unknown name; used by the `/thinking` selector callback.
8329fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8330    use rpi_ai::types::ThinkingLevel::*;
8331    match name.to_ascii_lowercase().as_str() {
8332        "off" => Some(Off),
8333        "minimal" => Some(Minimal),
8334        "low" => Some(Low),
8335        "medium" => Some(Medium),
8336        "high" => Some(High),
8337        "xhigh" => Some(Xhigh),
8338        "max" => Some(Max),
8339        _ => None,
8340    }
8341}
8342
8343/// Build + open the `/thinking` selector. Items are the levels the current
8344/// model supports (`Model::supported_thinking_levels`), each with a
8345/// description; the current level (read beforehand via `lane.get_thinking_level`)
8346/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
8347///
8348/// `on_select` fires on the blocking key thread, so it can't await
8349/// `lane.get_thinking_level()` to know the current level — the opener resolves
8350/// it first (best-effort) and preselects; the toggle on_select just applies
8351/// whatever was picked.
8352fn open_thinking_selector(
8353    state: &Arc<TuiState>,
8354    editor_container: &Arc<Container>,
8355    editor: &Arc<Editor>,
8356    tui: &Arc<TuiAltScreen>,
8357    lane: &Arc<dyn AgentLane>,
8358    catalog: &[rpi_ai::Model],
8359    lane_model_id: &str,
8360    chat: &Arc<Container>,
8361) {
8362    // Find the current model in the catalog to read its supported levels. If
8363    // absent, fall back to all levels so the selector still opens.
8364    let model = catalog
8365        .iter()
8366        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8367    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8368        .map(|m| m.supported_thinking_levels())
8369        .unwrap_or_else(|| {
8370            use rpi_ai::types::ThinkingLevel::*;
8371            vec![Off, Minimal, Low, Medium, High]
8372        });
8373    let mut items: Vec<SelectItem> = Vec::new();
8374    for lvl in &levels {
8375        let name = thinking_level_name(*lvl);
8376        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8377    }
8378    if items.is_empty() {
8379        add_note_message(chat, "This model has no supported thinking levels.");
8380        tui.request_render(false);
8381        return;
8382    }
8383    let list = Arc::new(SelectList::new(items, 10));
8384
8385    let state_sel = state.clone();
8386    let ec_sel = editor_container.clone();
8387    let editor_sel = editor.clone();
8388    let tui_sel = tui.clone();
8389    let chat_sel = chat.clone();
8390    let lane_sel = lane.clone();
8391    list.on_select(Arc::new(move |item| {
8392        let Some(level) = thinking_level_from_name(&item.value) else {
8393            add_note_message(
8394                &chat_sel,
8395                &format!("Unknown thinking level: {}.", item.label),
8396            );
8397            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8398            return;
8399        };
8400        let lane = lane_sel.clone();
8401        let footer_sel = state_sel.footer.clone();
8402        tokio::spawn(async move {
8403            let _ = lane.set_thinking_level(level).await;
8404        });
8405        // Reflect the chosen level in the footer's model suffix (pi parity:
8406        // `model • thinking off` / `model • medium`). The shown text for the
8407        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
8408        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8409        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8410        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8411    }));
8412    let state_cancel = state.clone();
8413    let ec_cancel = editor_container.clone();
8414    let editor_cancel = editor.clone();
8415    let tui_cancel = tui.clone();
8416    list.on_cancel(Arc::new(move || {
8417        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8418    }));
8419
8420    open_selector(
8421        state,
8422        editor_container,
8423        editor,
8424        tui,
8425        list,
8426        SelectorKind::Thinking,
8427    );
8428}
8429
8430/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
8431/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
8432/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
8433/// — the blocking key thread can't await) and selecting a tool **toggles** it
8434/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
8435fn open_tools_selector(
8436    state: &Arc<TuiState>,
8437    editor_container: &Arc<Container>,
8438    editor: &Arc<Editor>,
8439    tui: &Arc<TuiAltScreen>,
8440    lane: &Arc<dyn AgentLane>,
8441    chat: &Arc<Container>,
8442) {
8443    // Best-effort read of the current active set. The opener runs on the async
8444    // runtime (it's called from the main loop's channel dispatch or the submit
8445    // closure that lives on the blocking thread — but `handle.block_on` is safe
8446    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
8447    let mut active = match tokio::runtime::Handle::try_current() {
8448        Ok(h) => h
8449            .block_on(async { lane.get_active_tools().await })
8450            .unwrap_or_default(),
8451        Err(_) => Vec::new(),
8452    };
8453    // An empty active set is the harness sentinel for "all registered tools"
8454    // (the selector only exposes built-ins). Expand it before rendering and
8455    // toggling so the first `/tools` visit does not show every tool as off or
8456    // accidentally reduce the active set to the one item selected.
8457    if active.is_empty() {
8458        active = crate::session::BUILTIN_TOOL_NAMES
8459            .iter()
8460            .map(|name| (*name).to_string())
8461            .collect();
8462    }
8463    let mut items: Vec<SelectItem> = Vec::new();
8464    for name in crate::session::BUILTIN_TOOL_NAMES {
8465        let on = active.iter().any(|a| a == name);
8466        let label = if on {
8467            format!("{name} (on)")
8468        } else {
8469            (*name).to_string()
8470        };
8471        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8472    }
8473    let list = Arc::new(SelectList::new(items, 10));
8474
8475    // Capture the active set so on_select can toggle without re-reading.
8476    let active_captured = active.clone();
8477    let state_sel = state.clone();
8478    let ec_sel = editor_container.clone();
8479    let editor_sel = editor.clone();
8480    let tui_sel = tui.clone();
8481    let chat_sel = chat.clone();
8482    let lane_sel = lane.clone();
8483    list.on_select(Arc::new(move |item| {
8484        let mut next = active_captured.clone();
8485        if let Some(pos) = next.iter().position(|a| a == &item.value) {
8486            next.remove(pos);
8487        } else {
8488            next.push(item.value.clone());
8489        }
8490        let on = next.iter().any(|a| a == &item.value);
8491        let lane = lane_sel.clone();
8492        let next_clone = next.clone();
8493        tokio::spawn(async move {
8494            let _ = lane.set_active_tools(next_clone).await;
8495        });
8496        let list_str = if next.is_empty() {
8497            "(none)".to_string()
8498        } else {
8499            next.join(", ")
8500        };
8501        add_note_message(
8502            &chat_sel,
8503            &format!(
8504                "{} {} — active tools: {}",
8505                item.value,
8506                if on { "enabled" } else { "disabled" },
8507                list_str
8508            ),
8509        );
8510        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8511    }));
8512    let state_cancel = state.clone();
8513    let ec_cancel = editor_container.clone();
8514    let editor_cancel = editor.clone();
8515    let tui_cancel = tui.clone();
8516    list.on_cancel(Arc::new(move || {
8517        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8518    }));
8519
8520    open_selector(
8521        state,
8522        editor_container,
8523        editor,
8524        tui,
8525        list,
8526        SelectorKind::Tools,
8527    );
8528}
8529
8530/// Build + open the `/images` selector (Yes/No). Stores the choice in
8531/// `state.show_images` and notes it. Image wiring is minimal this pass — the
8532/// flag is consulted where images would be shown and echoed back here.
8533fn open_images_selector(
8534    state: &Arc<TuiState>,
8535    editor_container: &Arc<Container>,
8536    editor: &Arc<Editor>,
8537    tui: &Arc<TuiAltScreen>,
8538    chat: &Arc<Container>,
8539) {
8540    let current = *state.show_images.lock().unwrap();
8541    let items = vec![
8542        SelectItem::new("yes", "Yes").with_description(if current {
8543            "Inline images (current)"
8544        } else {
8545            "Inline images"
8546        }),
8547        SelectItem::new("no", "No").with_description(if current {
8548            "Placeholder only"
8549        } else {
8550            "Placeholder only (current)"
8551        }),
8552    ];
8553    let list = Arc::new(SelectList::new(items, 5));
8554
8555    let state_sel = state.clone();
8556    let ec_sel = editor_container.clone();
8557    let editor_sel = editor.clone();
8558    let tui_sel = tui.clone();
8559    let chat_sel = chat.clone();
8560    list.on_select(Arc::new(move |item| {
8561        let on = item.value == "yes";
8562        *state_sel.show_images.lock().unwrap() = on;
8563        add_note_message(
8564            &chat_sel,
8565            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8566        );
8567        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8568    }));
8569    let state_cancel = state.clone();
8570    let ec_cancel = editor_container.clone();
8571    let editor_cancel = editor.clone();
8572    let tui_cancel = tui.clone();
8573    list.on_cancel(Arc::new(move || {
8574        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8575    }));
8576
8577    open_selector(
8578        state,
8579        editor_container,
8580        editor,
8581        tui,
8582        list,
8583        SelectorKind::Images,
8584    );
8585}
8586
8587// ===========================================================================
8588// Autocomplete
8589// ===========================================================================
8590
8591/// Refresh the autocomplete suggestion list from the current editor text +
8592/// cursor. Renders the suggestions into `autocomplete_container` (above the
8593/// editor) or clears it when there are none.
8594fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8595    let text = editor.get_text();
8596    let cursor = editor_cursor_offset(editor, &text);
8597    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8598    render_autocomplete(state, suggestions);
8599}
8600
8601/// Convert the editor's logical `(row, byte-column)` caret into the absolute
8602/// byte offset expected by autocomplete providers.
8603fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8604    let (row, col) = editor.cursor_position();
8605    let mut offset = 0;
8606    for (index, line) in text.split('\n').enumerate() {
8607        if index == row {
8608            return (offset + col.min(line.len())).min(text.len());
8609        }
8610        offset = offset.saturating_add(line.len() + 1);
8611    }
8612    text.len()
8613}
8614
8615/// Restore an editor caret from an absolute byte offset after autocomplete
8616/// replaces a span in a multi-line draft.
8617fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8618    let offset = offset.min(text.len());
8619    let mut consumed = 0;
8620    for (row, line) in text.split('\n').enumerate() {
8621        let end = consumed + line.len();
8622        if offset <= end {
8623            editor.set_cursor(row, offset - consumed);
8624            return;
8625        }
8626        consumed = end + 1;
8627    }
8628    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8629    editor.set_cursor(
8630        last_row,
8631        text.rsplit('\n').next().map(str::len).unwrap_or(0),
8632    );
8633}
8634
8635/// Render (or clear) the autocomplete suggestion list into the container.
8636fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8637    state.autocomplete_container.clear();
8638    let Some(sugg) = suggestions else {
8639        return;
8640    };
8641    if sugg.items.is_empty() {
8642        return;
8643    }
8644    // Build a compact list: top item marked with `→`, rest with `  `.
8645    // Cap the list so the dock doesn't swallow the transcript.
8646    let accent = state.theme_manager.get().colors.accent;
8647    let muted = state.theme_manager.get().colors.muted;
8648    for (i, item) in sugg
8649        .items
8650        .iter()
8651        .take(state.autocomplete_max_visible)
8652        .enumerate()
8653    {
8654        let prefix = if i == 0 { "→ " } else { "  " };
8655        let label = item.display_text();
8656        let line = if i == 0 {
8657            format!(
8658                "{prefix}{} {}",
8659                accent.fg(label),
8660                muted.fg(item.description.as_deref().unwrap_or(""))
8661            )
8662        } else {
8663            format!(
8664                "{prefix}{} {}",
8665                muted.fg(label),
8666                muted.fg(item.description.as_deref().unwrap_or(""))
8667            )
8668        };
8669        state
8670            .autocomplete_container
8671            .add_child(Arc::new(Text::new(line, 1, 0)));
8672    }
8673}
8674
8675/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
8676/// suggestion text, reposition the caret, and clear the suggestion list.
8677/// Returns `true` if a suggestion was accepted.
8678fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8679    let text = editor.get_text();
8680    let cursor = editor_cursor_offset(editor, &text);
8681    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8682        return false;
8683    };
8684    let Some(top) = sugg.items.first() else {
8685        return false;
8686    };
8687    // Replace the [start, end) span with the suggestion text. `start`/`end`
8688    // are byte offsets emitted by the providers on char boundaries, so the
8689    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
8690    let start = sugg.start.min(text.len());
8691    let end = sugg.end.min(text.len());
8692    let mut replaced = String::with_capacity(text.len() + top.text.len());
8693    replaced.push_str(&text[..start]);
8694    replaced.push_str(&top.text);
8695    // Keep the text AFTER the replaced span (mid-line completion: replacing
8696    // `[start, end)` must not drop the rest of the line).
8697    replaced.push_str(&text[end..]);
8698    if top.insert_space && !replaced.ends_with('/') {
8699        replaced.push(' ');
8700    }
8701    // New caret position: after the inserted text (byte offset; the editor
8702    // snaps `set_cursor` to a char boundary as a safety net).
8703    let new_cursor = replaced.len().min(
8704        start
8705            + top.text.len()
8706            + if top.insert_space && !top.text.ends_with('/') {
8707                1
8708            } else {
8709                0
8710            },
8711    );
8712    editor.set_text(&replaced);
8713    set_editor_cursor_offset(editor, &replaced, new_cursor);
8714    state.autocomplete_container.clear();
8715    true
8716}
8717
8718// ===========================================================================
8719// Transcript message helpers
8720// ===========================================================================
8721
8722/// Add the welcome header to the chat container.
8723fn add_welcome_message(container: &Arc<Container>) {
8724    add_welcome_message_with_capabilities(container, &[], &[]);
8725}
8726
8727/// Add the startup welcome header and a compact snapshot of active tools and
8728/// discovered skills. The snapshot reflects the harness configuration used by
8729/// the first turn, including tools contributed by extensions.
8730fn add_welcome_message_with_capabilities(
8731    container: &Arc<Container>,
8732    active_tools: &[String],
8733    skills: &[String],
8734) {
8735    let c = current_theme().colors;
8736    // Accent logotype + a dim tagline, separated from the rest by a thin
8737    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
8738    // to the body text, so the header didn't read as a header.
8739    let title = format!(
8740        "{} {}",
8741        c.accent.fg(&tui_bold("rpi")),
8742        c.muted.fg("interactive TUI")
8743    );
8744    container.add_child(Arc::new(Text::new(title, 1, 0)));
8745    container.add_child(Arc::new(Spacer::new(1)));
8746    container.add_child(Arc::new(Text::new(
8747        c.dim.fg("Type your message and press Enter to send."),
8748        1,
8749        0,
8750    )));
8751    let hint = c
8752        .dim
8753        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8754    container.add_child(Arc::new(Text::new(hint, 1, 0)));
8755    container.add_child(Arc::new(Spacer::new(1)));
8756    container.add_child(Arc::new(Text::new(
8757        welcome_capability_line("Tools", active_tools),
8758        1,
8759        0,
8760    )));
8761    container.add_child(Arc::new(Text::new(
8762        welcome_capability_line("Skills", skills),
8763        1,
8764        0,
8765    )));
8766    container.add_child(Arc::new(DynamicBorder::new()));
8767}
8768
8769/// Append update notices inside the live transcript. Fullscreen mode clears
8770/// pre-TUI stdout/stderr, so update state must be represented by components.
8771fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8772    let colors = current_theme().colors;
8773    let group = Arc::new(Container::new());
8774
8775    for warning in &report.warnings {
8776        let body = format!(
8777            "{}\n{} {}{}",
8778            colors.error.fg(&warning.message),
8779            colors.muted.fg("Run"),
8780            colors.accent.fg(&warning.command),
8781            colors.muted.fg(" to retry.")
8782        );
8783        add_update_panel(&group, "Update Failed", &body, colors.error);
8784    }
8785
8786    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8787        let body = format!(
8788            "{} {}{}",
8789            colors
8790                .muted
8791                .fg(&format!("New version {} is available. Run", notice.latest)),
8792            colors.accent.fg(&notice.command),
8793            colors.muted.fg(".")
8794        );
8795        add_update_panel(&group, "Update Available", &body, colors.warning);
8796    }
8797
8798    let package_notices = report
8799        .notices
8800        .iter()
8801        .filter(|notice| notice.name != "rpi")
8802        .collect::<Vec<_>>();
8803    if !package_notices.is_empty() {
8804        let command = package_notices[0].command.as_str();
8805        let mut lines = vec![format!(
8806            "{} {}{}",
8807            colors.muted.fg("Package updates are available. Run"),
8808            colors.accent.fg(command),
8809            colors.muted.fg(".")
8810        )];
8811        lines.push(colors.muted.fg("Packages:"));
8812        lines.extend(
8813            package_notices
8814                .into_iter()
8815                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8816        );
8817        add_update_panel(
8818            &group,
8819            "Package Updates Available",
8820            &lines.join("\n"),
8821            colors.warning,
8822        );
8823    }
8824
8825    // Other transcript producers append concurrently. Add the fully built
8826    // group in one operation so card borders and content cannot interleave
8827    // with user, assistant, tool, or extension messages.
8828    if group.child_count() > 0 {
8829        container.add_child(group);
8830    }
8831}
8832
8833fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8834    container.add_child(Arc::new(Spacer::new(1)));
8835    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8836    container.add_child(Arc::new(Text::new(
8837        format!("{}\n{body}", color.fg(&tui_bold(title))),
8838        1,
8839        0,
8840    )));
8841    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8842}
8843
8844fn welcome_capability_line(label: &str, names: &[String]) -> String {
8845    let c = current_theme().colors;
8846    let value = if names.is_empty() {
8847        "none".to_string()
8848    } else {
8849        names.join(" · ")
8850    };
8851    format!(
8852        "{} {}",
8853        c.accent.fg(&format!("{label} ({})", names.len())),
8854        c.muted.fg(&value)
8855    )
8856}
8857
8858/// Add the `/help` command listing to the chat container.
8859fn add_help_message(container: &Arc<Container>) {
8860    let c = current_theme().colors;
8861    // Section header + a thin themed rule, then a two-column command table:
8862    // `cmd` in accent, `— desc` in muted. The old single-space layout made
8863    // the description column wander depending on command length.
8864    container.add_child(Arc::new(Text::new(
8865        c.md_heading.fg(&tui_bold("📚 Available Commands")),
8866        1,
8867        0,
8868    )));
8869    container.add_child(Arc::new(Spacer::new(1)));
8870
8871    let cmds: &[(&str, &str)] = &[
8872        ("/help, /?", "Show this help message"),
8873        ("/clear, /new", "Clear the conversation"),
8874        ("/exit, /quit, /q", "Exit the application"),
8875        ("/version, /v", "Show version information"),
8876        ("/changelog", "Show recent release changes"),
8877        ("/model, /m", "Choose a model (live switch)"),
8878        ("/thinking, /think", "Set reasoning depth (selector)"),
8879        ("/tools", "Toggle built-in tools on/off"),
8880        ("/images", "Toggle inline image rendering"),
8881        ("/session", "List saved sessions"),
8882        ("/theme", "Choose a theme (selector)"),
8883        ("/compact", "Compact the conversation"),
8884        ("/copy", "Copy last reply to clipboard"),
8885        ("/hotkeys", "Show keyboard shortcuts"),
8886        ("/armin", "🐾 Easter egg"),
8887        ("/earendil", "Earendil announcement"),
8888    ];
8889    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8890    for (cmd, desc) in cmds {
8891        let row = format!(
8892            "  {:<cmd_w$}  {}  {}",
8893            c.accent.fg(cmd),
8894            c.dim.fg("—"),
8895            c.muted.fg(desc)
8896        );
8897        container.add_child(Arc::new(Text::new(row, 1, 0)));
8898    }
8899    container.add_child(Arc::new(Spacer::new(1)));
8900}
8901
8902/// Add the `/version` block to the chat container.
8903fn add_version_message(container: &Arc<Container>) {
8904    let c = current_theme().colors;
8905    container.add_child(Arc::new(Text::new(
8906        c.md_heading.fg(&tui_bold("📦 Version Information")),
8907        1,
8908        0,
8909    )));
8910    container.add_child(Arc::new(Spacer::new(1)));
8911    // Use the crate version (kept in sync via `version.workspace = true`)
8912    // instead of the stale hardcoded "v0.1.2".
8913    container.add_child(Arc::new(Text::new(
8914        format!(
8915            "  {} {}",
8916            c.muted.fg("rpi-cli"),
8917            c.text.fg(&format!("v{}", crate::VERSION))
8918        ),
8919        1,
8920        0,
8921    )));
8922    container.add_child(Arc::new(Text::new(
8923        format!(
8924            "  {}",
8925            c.dim.fg("Rust implementation of pi coding agent TUI")
8926        ),
8927        1,
8928        0,
8929    )));
8930    container.add_child(Arc::new(Spacer::new(1)));
8931}
8932
8933/// Add a compact `/changelog` block to the chat container. Keep this local to
8934/// the binary so the command remains useful in installed builds without a
8935/// source checkout or a network request.
8936fn add_changelog_message(container: &Arc<Container>) {
8937    let c = current_theme().colors;
8938    container.add_child(Arc::new(Text::new(
8939        c.md_heading.fg(&tui_bold("Recent Changes")),
8940        1,
8941        0,
8942    )));
8943    container.add_child(Arc::new(Spacer::new(1)));
8944    let entries = [
8945        (
8946            "Native parity phase 1",
8947            "models, images, trust, export, and JSON events",
8948        ),
8949        (
8950            "TUI controls",
8951            "external editor, thinking levels, and tool output toggles",
8952        ),
8953        (
8954            "Provider auth",
8955            "OpenAI-compatible API key aliases and gateway headers",
8956        ),
8957    ];
8958    for (release, summary) in entries {
8959        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
8960        container.add_child(Arc::new(Text::new(row, 1, 0)));
8961    }
8962    container.add_child(Arc::new(Text::new(
8963        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8964        1,
8965        0,
8966    )));
8967    container.add_child(Arc::new(Spacer::new(1)));
8968}
8969
8970/// Add the `/hotkeys` block to the chat container.
8971fn add_hotkeys_message(container: &Arc<Container>) {
8972    let c = current_theme().colors;
8973    container.add_child(Arc::new(Text::new(
8974        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
8975        1,
8976        0,
8977    )));
8978    container.add_child(Arc::new(Spacer::new(1)));
8979    let keys: &[(&str, &str)] = &[
8980        ("Enter", "Send message"),
8981        ("Shift+Enter", "New line"),
8982        ("Tab", "Accept autocomplete suggestion"),
8983        ("Ctrl+A / Ctrl+E", "Line start / end"),
8984        (
8985            "Ctrl+K / Ctrl+U",
8986            "Kill to end / start of line (Ctrl+Y yanks)",
8987        ),
8988        ("Ctrl+- / Ctrl+R", "Undo / redo"),
8989        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8990        ("Alt+Backspace", "Kill previous word"),
8991        ("Ctrl+C", "Abort a run, or exit when idle"),
8992        ("Esc", "Abort a running prompt"),
8993        ("Ctrl+L", "Open model selector"),
8994        ("Ctrl+M", "Cycle to the next model (live)"),
8995        ("Ctrl+O", "Expand/collapse all tool output"),
8996        ("Ctrl+T", "Show/hide reasoning blocks"),
8997        ("PageUp/Down", "Scroll transcript by one page"),
8998        ("Home / End", "Jump to transcript start / latest output"),
8999    ];
9000    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9001    for (key, desc) in keys {
9002        let row = format!(
9003            "  {:<key_w$}  {}  {}",
9004            c.accent.fg(key),
9005            c.dim.fg("—"),
9006            c.muted.fg(desc)
9007        );
9008        container.add_child(Arc::new(Text::new(row, 1, 0)));
9009    }
9010    container.add_child(Arc::new(Spacer::new(1)));
9011}
9012
9013/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
9014/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
9015/// plain `> text` echo. A trailing Spacer(1) separates it from the next
9016// transcript entry (every entry contributes one trailing spacer so
9017// consecutive turns are separated by exactly one blank line).
9018fn add_user_message(container: &Arc<Container>, text: &str) {
9019    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9020    container.add_child(Arc::new(Spacer::new(1)));
9021}
9022
9023/// Add an error message to the chat container.
9024/// Collect all rendered lines from the chat container for search.
9025fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9026    let width = 80; // Default width for search; actual width varies by terminal
9027    chat_container.render(width)
9028}
9029
9030fn add_error_message(container: &Arc<Container>, text: &str) {
9031    let c = current_theme().colors;
9032    let text = sanitize_error_message(text);
9033    container.add_child(Arc::new(Text::new(
9034        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
9035        1,
9036        0,
9037    )));
9038    container.add_child(Arc::new(Spacer::new(1)));
9039}
9040
9041/// Keep provider diagnostics printable in the transcript. HTTP error bodies
9042/// may contain carriage returns, terminal escapes, or an unexpectedly large
9043/// JSON payload; letting those bytes reach the renderer can corrupt the input
9044/// row or make the whole frame exceed terminal limits.
9045fn sanitize_error_message(text: &str) -> String {
9046    const MAX_ERROR_CHARS: usize = 16 * 1024;
9047    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9048    let mut count = 0;
9049    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
9050    let mut escape_mode = 0u8;
9051    for ch in text.chars() {
9052        if escape_mode != 0 {
9053            match escape_mode {
9054                1 if ch == '[' => escape_mode = 2,
9055                1 if ch == ']' => escape_mode = 3,
9056                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9057                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9058                3 if ch == '\x07' => escape_mode = 0,
9059                _ => {}
9060            }
9061            continue;
9062        }
9063        if ch == '\x1b' {
9064            escape_mode = 1;
9065            continue;
9066        }
9067        if count >= MAX_ERROR_CHARS {
9068            result.push_str("…");
9069            break;
9070        }
9071        match ch {
9072            '\n' | '\t' => {
9073                result.push(ch);
9074                count += 1;
9075            }
9076            '\r' => {}
9077            c if c.is_control() => {}
9078            c => {
9079                result.push(c);
9080                count += 1;
9081            }
9082        }
9083    }
9084    if result.trim().is_empty() {
9085        "Provider request failed.".to_string()
9086    } else {
9087        result
9088    }
9089}
9090
9091/// Add a neutral note (e.g. unsupported-command message) to the chat container.
9092fn add_note_message(container: &Arc<Container>, text: &str) {
9093    let c = current_theme().colors;
9094    container.add_child(Arc::new(Text::new(
9095        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9096        1,
9097        0,
9098    )));
9099    container.add_child(Arc::new(Spacer::new(1)));
9100}
9101
9102/// Render the `/context` panel: a transcript message listing the discovered
9103/// context files, skills, and prompt templates loaded for this session
9104/// (Part A resource discovery). Reads the harness resources snapshot captured
9105/// at TUI startup (the blocking submit handler can't `await get_resources()`.
9106///
9107/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
9108/// via `/reload`); here it's a transcript note rather than an overlay since the
9109/// resource set is session-static between `/reload`s (deferred).
9110fn show_context_panel(
9111    chat: &Arc<Container>,
9112    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9113) {
9114    let skills = resources.skills.as_deref().unwrap_or(&[]);
9115    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9116    let mut lines: Vec<String> = Vec::new();
9117    lines.push("📂 Discovered resources for this session:".into());
9118
9119    if skills.is_empty() {
9120        lines.push(
9121            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
9122        );
9123    } else {
9124        lines.push(format!("  Skills ({}):", skills.len()));
9125        for s in skills {
9126            let marker = if s.disable_model_invocation == Some(true) {
9127                " [hidden]"
9128            } else {
9129                ""
9130            };
9131            let desc: String = s.description.chars().take(72).collect();
9132            lines.push(format!("    • {}{marker} — {desc}", s.name));
9133        }
9134    }
9135
9136    if templates.is_empty() {
9137        lines.push(
9138            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
9139        );
9140    } else {
9141        lines.push(format!("  Prompt templates ({}):", templates.len()));
9142        for t in templates {
9143            let desc = t
9144                .description
9145                .as_deref()
9146                .unwrap_or("(no description)")
9147                .chars()
9148                .take(72)
9149                .collect::<String>();
9150            lines.push(format!("    • /{} — {desc}", t.name));
9151        }
9152    }
9153    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9154    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9155    lines.push(
9156        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9157            .into(),
9158    );
9159    let body = lines.join("\n");
9160    container_note_block(chat, &body);
9161}
9162
9163/// Append a multi-line neutral note (header line + body) to the chat container.
9164fn container_note_block(container: &Arc<Container>, body: &str) {
9165    for line in body.lines() {
9166        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9167    }
9168    container.add_child(Arc::new(Spacer::new(1)));
9169}
9170
9171// ===========================================================================
9172// TUI support + entry detection
9173// ===========================================================================
9174
9175/// Check if the terminal supports TUI mode.
9176pub fn is_tui_supported() -> bool {
9177    std::io::stdout().is_terminal()
9178}
9179
9180// Keep the `Color` import used (theme accent rendering in autocomplete).
9181#[allow(unused_imports)]
9182use rpi_tui::Color as _Color;
9183
9184#[cfg(test)]
9185mod tests {
9186    use super::*;
9187    use rpi_tui::Component;
9188
9189    #[test]
9190    fn verbose_overrides_quiet_startup_listing() {
9191        assert!(should_show_startup_listing(false, false));
9192        assert!(should_show_startup_listing(true, false));
9193        assert!(should_show_startup_listing(true, true));
9194        assert!(!should_show_startup_listing(false, true));
9195    }
9196
9197    #[test]
9198    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
9199        let tmp = tempfile::tempdir().unwrap();
9200        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
9201        let local_root = tmp.path().join("local-package");
9202        std::fs::create_dir_all(git_root.join(".git")).unwrap();
9203        std::fs::create_dir_all(&local_root).unwrap();
9204        std::fs::write(
9205            git_root.join("package.json"),
9206            r#"{"name":"git-demo","version":"1.0.0"}"#,
9207        )
9208        .unwrap();
9209        std::fs::write(
9210            local_root.join("package.json"),
9211            r#"{"name":"local-demo","version":"1.0.0"}"#,
9212        )
9213        .unwrap();
9214        let resources = crate::packages::discover(
9215            tmp.path(),
9216            &[
9217                "git:github.com/example/repo".to_string(),
9218                local_root.to_string_lossy().into_owned(),
9219            ],
9220        );
9221        assert_eq!(resources.packages.len(), 2);
9222
9223        let filtered = git_only_update_resources(resources);
9224
9225        assert_eq!(filtered.packages.len(), 1);
9226        assert!(matches!(
9227            filtered.packages[0].source,
9228            crate::packages::PackageSource::Git
9229        ));
9230    }
9231
9232    #[test]
9233    fn trust_command_uses_the_session_cwd_after_process_chdir() {
9234        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9235        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9236        const TEST_NAME: &str =
9237            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9238
9239        if std::env::var_os(CHILD_ENV).is_some() {
9240            let session_cwd = std::path::PathBuf::from(
9241                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9242            );
9243            let changed_cwd = std::env::current_dir().unwrap();
9244
9245            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9246
9247            assert_eq!(
9248                crate::config::project_trust_decision(&session_cwd).unwrap(),
9249                Some(true)
9250            );
9251            assert_eq!(
9252                crate::config::project_trust_decision(&changed_cwd).unwrap(),
9253                None
9254            );
9255            return;
9256        }
9257
9258        let tmp = tempfile::tempdir().unwrap();
9259        let session_cwd = tmp.path().join("session-project");
9260        let changed_cwd = tmp.path().join("extension-cwd");
9261        let agent_dir = tmp.path().join("agent");
9262        std::fs::create_dir_all(&session_cwd).unwrap();
9263        std::fs::create_dir_all(&changed_cwd).unwrap();
9264        std::fs::create_dir_all(&agent_dir).unwrap();
9265
9266        let status = std::process::Command::new(std::env::current_exe().unwrap())
9267            .arg("--exact")
9268            .arg(TEST_NAME)
9269            .arg("--nocapture")
9270            .env(CHILD_ENV, "1")
9271            .env(SESSION_CWD_ENV, &session_cwd)
9272            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9273            .current_dir(&changed_cwd)
9274            .status()
9275            .unwrap();
9276
9277        assert!(status.success(), "child test process failed: {status}");
9278    }
9279
9280    #[test]
9281    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9282        let global = crate::settings::Settings {
9283            editor_padding_x: Some(3),
9284            autocomplete_max_visible: Some(6),
9285            hide_thinking_block: Some(false),
9286            quiet_startup: Some(true),
9287            show_terminal_progress: Some(true),
9288            ..Default::default()
9289        };
9290        let rpi_project = crate::settings::Settings {
9291            editor_padding_x: Some(0),
9292            hide_thinking_block: Some(true),
9293            quiet_startup: Some(false),
9294            show_terminal_progress: Some(false),
9295            ..Default::default()
9296        };
9297        let pi_project = crate::settings::Settings {
9298            editor_padding_x: Some(9),
9299            autocomplete_max_visible: Some(12),
9300            quiet_startup: Some(true),
9301            ..Default::default()
9302        };
9303
9304        assert_eq!(
9305            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9306            TuiStartupSettings {
9307                editor_padding_x: 0,
9308                autocomplete_max_visible: 12,
9309                hide_thinking: true,
9310                quiet_startup: false,
9311                show_terminal_progress: false,
9312            }
9313        );
9314    }
9315
9316    #[test]
9317    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9318        let global = crate::settings::Settings {
9319            quiet_startup: Some(false),
9320            ..Default::default()
9321        };
9322        let rpi_project = crate::settings::Settings::default();
9323        let pi_project = crate::settings::Settings {
9324            quiet_startup: Some(true),
9325            ..Default::default()
9326        };
9327
9328        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9329
9330        assert!(resolved.quiet_startup);
9331    }
9332
9333    #[test]
9334    fn tui_startup_settings_use_global_values_without_project_fields() {
9335        let global = crate::settings::Settings {
9336            editor_padding_x: Some(7),
9337            autocomplete_max_visible: Some(8),
9338            hide_thinking_block: Some(true),
9339            quiet_startup: Some(true),
9340            show_terminal_progress: Some(false),
9341            ..Default::default()
9342        };
9343
9344        assert_eq!(
9345            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9346            TuiStartupSettings {
9347                editor_padding_x: 7,
9348                autocomplete_max_visible: 8,
9349                hide_thinking: true,
9350                quiet_startup: true,
9351                show_terminal_progress: false,
9352            }
9353        );
9354    }
9355
9356    #[test]
9357    fn tui_startup_settings_ignore_untrusted_project_values() {
9358        let global = crate::settings::Settings {
9359            quiet_startup: Some(false),
9360            show_terminal_progress: Some(true),
9361            ..Default::default()
9362        };
9363        let project = crate::settings::Settings {
9364            quiet_startup: Some(true),
9365            show_terminal_progress: Some(false),
9366            ..Default::default()
9367        };
9368
9369        let resolved = resolve_tui_startup_settings(&global, &[project], false);
9370
9371        assert!(!resolved.quiet_startup);
9372        assert!(resolved.show_terminal_progress);
9373    }
9374
9375    #[test]
9376    fn transcript_page_uses_viewport_with_overlap() {
9377        assert_eq!(transcript_page_size(24), 20);
9378        assert_eq!(transcript_page_size(4), 1);
9379        assert_eq!(transcript_page_size(0), 1);
9380    }
9381
9382    #[test]
9383    fn key_repeat_is_dispatched_but_release_is_not() {
9384        assert!(should_dispatch_key(KeyEventKind::Press));
9385        assert!(should_dispatch_key(KeyEventKind::Repeat));
9386        assert!(!should_dispatch_key(KeyEventKind::Release));
9387    }
9388
9389    #[test]
9390    fn key_event_encoding_matches_pi_keybinding_protocol() {
9391        let key = |code, modifiers| KeyEvent::new(code, modifiers);
9392        assert_eq!(
9393            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9394            "\r"
9395        );
9396        assert_eq!(
9397            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9398            "\x1b[13;2u"
9399        );
9400        assert_eq!(
9401            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9402            "\x1b[9;2u"
9403        );
9404        assert_eq!(
9405            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9406            "\x1b[Z"
9407        );
9408        assert_eq!(
9409            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9410            "\x1b[Z"
9411        );
9412        assert_eq!(
9413            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9414            "\x03"
9415        );
9416        assert_eq!(
9417            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9418            "\x0f"
9419        );
9420        assert_eq!(
9421            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9422            "!"
9423        );
9424        assert_eq!(
9425            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9426            "1"
9427        );
9428    }
9429
9430    #[test]
9431    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9432        let bridge = JsDialogBridge::default();
9433        let (sender, receiver) = std_mpsc::channel();
9434        bridge.pending.lock().unwrap().push_back(JsDialogPending {
9435            request: JsDialogRequest {
9436                id: "dialog-1".into(),
9437                method: "input".into(),
9438                title: String::new(),
9439                message: String::new(),
9440                options: Vec::new(),
9441                placeholder: None,
9442                prefill: None,
9443            },
9444            result: sender,
9445        });
9446
9447        // Model the cancellation arriving after the queue entry has been
9448        // removed but before the TUI has installed the native widget.
9449        bridge.cancel("dialog-1");
9450        assert!(bridge.take_pending().is_none());
9451        assert_eq!(
9452            receiver.recv().unwrap(),
9453            serde_json::json!({ "cancelled": true })
9454        );
9455        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9456    }
9457
9458    #[test]
9459    fn test_layout_renders_welcome_message() {
9460        let chat = Arc::new(Container::new());
9461        add_welcome_message(&chat);
9462
9463        let scroll = Arc::new(ScrollView::new(
9464            chat.clone(),
9465            ScrollViewOptions {
9466                follow: FollowMode::End,
9467                primary: true,
9468                ..Default::default()
9469            },
9470        ));
9471
9472        let editor = Arc::new(Editor::new(
9473            EditorOptions {
9474                padding_x: 1,
9475                ..Default::default()
9476            },
9477            EditorStyle::default(),
9478            Arc::new(rpi_tui::Keybindings::new()),
9479        ));
9480        let dock = Arc::new(Container::new());
9481        dock.add_child(editor);
9482
9483        let footer = Arc::new(FooterComponent::new());
9484
9485        let root = VStack::from_children(vec![
9486            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9487            StackChild::Entry(StackEntry::new(dock)),
9488            StackChild::Entry(StackEntry::new(footer)),
9489        ]);
9490
9491        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9492
9493        let all: String = frame.lines.join("\n");
9494        assert!(
9495            all.contains("rpi"),
9496            "Welcome message not found. Rendered: {}",
9497            all
9498        );
9499        assert!(
9500            all.contains("Type your message"),
9501            "Help text not found. Rendered: {}",
9502            all
9503        );
9504    }
9505
9506    #[test]
9507    fn test_chat_container_has_welcome_content() {
9508        let chat = Arc::new(Container::new());
9509        add_welcome_message_with_capabilities(
9510            &chat,
9511            &["read".into(), "bash".into(), "web_fetch".into()],
9512            &["rust-review".into(), "release".into()],
9513        );
9514
9515        let lines = chat.render(80);
9516        let all: String = lines.join("\n");
9517        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
9518        // joined by an ANSI reset — strip ANSI before checking the substring.
9519        let plain = strip_ansi(&all);
9520        assert!(
9521            plain.contains("rpi"),
9522            "Welcome message not in chat container: {:?}",
9523            lines
9524        );
9525        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9526        assert!(
9527            plain.contains("read · bash · web_fetch"),
9528            "Tool names missing: {plain}"
9529        );
9530        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9531        assert!(
9532            plain.contains("rust-review · release"),
9533            "Skill names missing: {plain}"
9534        );
9535    }
9536
9537    #[test]
9538    fn update_notices_render_inside_the_transcript() {
9539        let chat = Arc::new(Container::new());
9540        let report = crate::updates::UpdateReport {
9541            notices: vec![
9542                crate::updates::UpdateNotice {
9543                    name: "rpi".into(),
9544                    current: "0.1.10".into(),
9545                    latest: "0.1.11".into(),
9546                    command: "rpi update".into(),
9547                },
9548                crate::updates::UpdateNotice {
9549                    name: "rpi-search".into(),
9550                    current: "0.1.0".into(),
9551                    latest: "0.1.1".into(),
9552                    command: "rpi pi-package update".into(),
9553                },
9554            ],
9555            warnings: vec![crate::updates::UpdateWarning {
9556                message: "The previously scheduled rpi update failed: access denied".into(),
9557                command: "rpi update".into(),
9558            }],
9559        };
9560
9561        add_update_notices(&chat, &report);
9562
9563        assert_eq!(chat.child_count(), 1);
9564        let plain = strip_ansi(&chat.render(80).join("\n"));
9565        assert!(plain.contains("Update Failed"), "{plain}");
9566        assert!(
9567            plain.contains("rpi update failed: access denied"),
9568            "{plain}"
9569        );
9570        assert!(plain.contains("Update Available"), "{plain}");
9571        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9572        assert!(plain.contains("rpi update"), "{plain}");
9573        assert!(plain.contains("Package Updates Available"), "{plain}");
9574        assert!(plain.contains("rpi pi-package update"), "{plain}");
9575        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9576    }
9577
9578    #[test]
9579    fn empty_update_report_does_not_add_transcript_content() {
9580        let chat = Arc::new(Container::new());
9581
9582        add_update_notices(&chat, &crate::updates::UpdateReport::default());
9583
9584        assert_eq!(chat.child_count(), 0);
9585    }
9586
9587    #[test]
9588    fn skill_reads_are_detected_by_path() {
9589        let name = skill_tool_name(
9590            "read",
9591            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9592        );
9593        assert_eq!(name.as_deref(), Some("release"));
9594
9595        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9596        assert!(name.is_none());
9597
9598        // Only `read` (not other tools) triggers the skill box.
9599        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9600    }
9601
9602    #[test]
9603    fn welcome_capabilities_show_empty_state() {
9604        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9605        assert_eq!(plain, "Skills (0) none");
9606    }
9607
9608    #[test]
9609    fn empty_tool_progress_is_deferred_until_start() {
9610        let empty = rpi_agent::AgentToolResult::default();
9611        assert!(!tool_update_has_payload("", &empty));
9612
9613        let text = rpi_agent::AgentToolResult::text("partial output");
9614        assert!(tool_update_has_payload("partial output", &text));
9615
9616        let details = rpi_agent::AgentToolResult {
9617            details: serde_json::json!({"path": "src/lib.rs"}),
9618            ..Default::default()
9619        };
9620        assert!(tool_update_has_payload("", &details));
9621    }
9622
9623    #[test]
9624    fn completed_stream_reconciles_the_final_tail_before_detaching() {
9625        let component = Arc::new(AssistantMessageComponent::new(
9626            AssistantMessageOptions::default(),
9627        ));
9628        component.set_streaming(true);
9629        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9630
9631        let current = Mutex::new(Some(component.clone()));
9632        let cached = Mutex::new("partial response".to_string());
9633        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9634        final_message.content = vec![Content::text(
9635            "partial response with the previously missing final tail",
9636        )];
9637        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9638
9639        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
9640
9641        assert!(current.lock().unwrap().is_none());
9642        assert_eq!(
9643            cached.lock().unwrap().as_str(),
9644            "partial response with the previously missing final tail"
9645        );
9646        let rendered = strip_ansi(&component.render(100).join("\n"));
9647        assert!(
9648            rendered.contains("previously missing final tail"),
9649            "{rendered}"
9650        );
9651    }
9652
9653    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
9654    /// replaces the editor text, the NEXT rendered frame must show the
9655    /// completed text (" /model " with the caret after it), not the old
9656    /// prefix. Mirrors the real dock layout (autocomplete_container above the
9657    /// bordered editor) and drives the same accept path the Tab handler uses.
9658    #[test]
9659    fn tab_accept_suggestion_reflects_in_next_render() {
9660        use rpi_tui::render_layout_frame;
9661
9662        let editor = Arc::new(Editor::new(
9663            EditorOptions {
9664                padding_x: 1,
9665                ..Default::default()
9666            },
9667            EditorStyle::default(),
9668            Arc::new(rpi_tui::Keybindings::new()),
9669        ));
9670        editor.set_focused(true);
9671        let editor_container = Arc::new(Container::new());
9672        editor_container.add_child(editor.clone());
9673        let autocomplete_container = Arc::new(Container::new());
9674        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9675        let dock = Arc::new(VStack::from_children(vec![
9676            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9677            StackChild::Entry(
9678                StackEntry::new(editor_container.clone())
9679                    .shrink(0)
9680                    .min_size(3),
9681            ),
9682            StackChild::Entry(StackEntry::new(footer)),
9683        ]));
9684
9685        // Simulate the user typing "/mo" (the popup shows suggestions).
9686        let mut manager = AutocompleteManager::new();
9687        let mut combined = CombinedAutocompleteProvider::new();
9688        combined.add_provider(Arc::new(
9689            SlashCommandAutocompleteProvider::with_default_commands(),
9690        ));
9691        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9692        manager.set_provider(Arc::new(combined));
9693        // Simulate typing "/mo" via the real insert path (advances the caret
9694        // by char length, like `handle_key` does).
9695        editor.insert("/mo");
9696        assert_eq!(editor.cursor_position(), (0, 3));
9697
9698        let frame_before = render_layout_frame(dock.clone(), 80, 10);
9699        assert!(
9700            frame_before.lines.iter().any(|l| l.contains("/mo")),
9701            "precondition: editor shows the typed prefix. Frame rows:\n{}",
9702            frame_before
9703                .lines
9704                .iter()
9705                .map(|l| format!("  [{l}]"))
9706                .collect::<Vec<_>>()
9707                .join("\n")
9708        );
9709
9710        // Tab: accept the top suggestion (the same code path as the key loop).
9711        let text = editor.get_text();
9712        let cursor = editor_cursor_offset(&editor, &text);
9713        let sugg = manager
9714            .get_suggestions(&text, cursor)
9715            .expect("slash suggestions for /mo");
9716        let top = sugg.items.first().expect("at least one suggestion");
9717        let start = sugg.start.min(text.len());
9718        let end = sugg.end.min(text.len());
9719        let mut replaced = String::new();
9720        replaced.push_str(&text[..start]);
9721        replaced.push_str(&top.text);
9722        replaced.push_str(&text[end..]);
9723        if top.insert_space && !replaced.ends_with('/') {
9724            replaced.push(' ');
9725        }
9726        let new_cursor = start + top.text.len();
9727        editor.set_text(&replaced);
9728        set_editor_cursor_offset(&editor, &replaced, new_cursor);
9729        autocomplete_container.clear();
9730        assert_eq!(editor.get_text(), "/model");
9731
9732        // The next render MUST display the completed text.
9733        let frame_after = render_layout_frame(dock, 80, 10);
9734        let all: String = frame_after.lines.join("\n");
9735        assert!(
9736            all.contains("/model"),
9737            "completed text missing from next render. Got:\n{all}"
9738        );
9739        // The caret must sit AFTER the completed command (the snap_boundary
9740        // regression put it one char early: "/mode|l" with the final char
9741        // dangling past the caret).
9742        let editor_line = frame_after
9743            .lines
9744            .iter()
9745            .find(|l| l.contains("/model"))
9746            .expect("editor row with completed text");
9747        assert!(
9748            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9749            "caret must follow the full completed text. Got: {editor_line:?}"
9750        );
9751    }
9752
9753    #[test]
9754    fn multiline_autocomplete_preserves_row_and_column() {
9755        let editor = Arc::new(Editor::simple());
9756        editor.set_text("first\n/mo");
9757        editor.set_cursor(1, 3);
9758
9759        let text = editor.get_text();
9760        assert_eq!(editor_cursor_offset(&editor, &text), 9);
9761
9762        let replaced = "first\n/model";
9763        editor.set_text(replaced);
9764        set_editor_cursor_offset(&editor, replaced, 12);
9765        assert_eq!(editor.cursor_position(), (1, 6));
9766    }
9767
9768    #[test]
9769    fn test_slash_command_dispatch() {
9770        // The registry is the single source of truth for dispatch: `find(token)`
9771        // returns the command (by name or alias) whose `name()` is the canonical
9772        // form, or `None` for an unknown token. This replaces the old enum-based
9773        // `handle_slash_command` assertions with equivalent registry lookups.
9774        let registry = build_builtin_registry();
9775
9776        // Helper: a token resolves to the command with this canonical name.
9777        let resolves_to = |token: &str, canonical: &str| {
9778            let found = registry.find(token).expect("{token} should resolve");
9779            assert_eq!(
9780                found.name(),
9781                canonical,
9782                "{token} resolved to {} (expected {canonical})",
9783                found.name()
9784            );
9785        };
9786
9787        resolves_to("/help", "/help");
9788        resolves_to("/?", "/help"); // alias → canonical
9789        resolves_to("/clear", "/clear");
9790        resolves_to("/new", "/clear"); // alias
9791        resolves_to("/q", "/exit"); // alias
9792        resolves_to("/quit", "/exit"); // alias
9793        resolves_to("/version", "/version");
9794        resolves_to("/v", "/version"); // alias
9795        resolves_to("/changelog", "/changelog");
9796        resolves_to("/hotkeys", "/hotkeys");
9797        resolves_to("/model", "/model");
9798        resolves_to("/m", "/model"); // alias
9799        resolves_to("/theme", "/theme");
9800        resolves_to("/session", "/session");
9801        resolves_to("/resume", "/session"); // alias
9802        resolves_to("/compact", "/compact");
9803        resolves_to("/copy", "/copy");
9804        resolves_to("/thinking", "/thinking");
9805        resolves_to("/think", "/thinking"); // alias
9806        resolves_to("/tools", "/tools");
9807        resolves_to("/images", "/images");
9808        resolves_to("/armin", "/armin");
9809        resolves_to("/earendil", "/earendil");
9810        resolves_to("/context", "/context");
9811        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
9812        resolves_to("/settings", "/settings");
9813        resolves_to("/name", "/name");
9814        resolves_to("/export", "/export");
9815
9816        // Unknown token → not found.
9817        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9818    }
9819
9820    #[test]
9821
9822    fn test_registry_visible_entries_cover_dispatch() {
9823        // The autocomplete list is derived from the registry, so every visible
9824        // command the dispatcher recognizes must appear in it — by construction,
9825        // but this guards against a future command being registered with
9826        // `visible()` / a non-empty description that the builder drops.
9827        let registry = build_builtin_registry();
9828        let names: Vec<String> = registry
9829            .visible_entries()
9830            .iter()
9831            .map(|c| c.name.clone())
9832            .collect();
9833        for recognized in [
9834            "/help",
9835            "/clear",
9836            "/new",
9837            "/exit",
9838            "/quit",
9839            "/version",
9840            "/changelog",
9841            "/model",
9842            "/session",
9843            "/theme",
9844            "/compact",
9845            "/copy",
9846            "/hotkeys",
9847            "/tools",
9848            "/images",
9849            "/thinking",
9850            "/usage",
9851            "/armin",
9852            "/earendil",
9853        ] {
9854            assert!(
9855                names.contains(&recognized.to_string()),
9856                "{recognized} missing from autocomplete list"
9857            );
9858        }
9859        // Hidden commands stay off the list.
9860        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9861            assert!(
9862                !names.contains(&hidden.to_string()),
9863                "{hidden} should be hidden from autocomplete"
9864            );
9865        }
9866    }
9867
9868    #[test]
9869    fn test_agent_event_mapping_creates_assistant_and_tool() {
9870        // Synthetic AgentEvent sequence → UI mutations, exercised against the
9871        // real drain handler with a no-op TUI stand-in.
9872        use rpi_ai::types::{
9873            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9874            ToolCall, ToolCallType, Usage,
9875        };
9876
9877        let state = Arc::new(TuiState {
9878            current_assistant: std::sync::Mutex::new(None),
9879            tool_components: std::sync::Mutex::new(HashMap::new()),
9880            bash_components: std::sync::Mutex::new(HashMap::new()),
9881            themes_enabled: true,
9882            hide_thinking: std::sync::Mutex::new(false),
9883            tool_outputs_expanded: std::sync::Mutex::new(false),
9884            show_terminal_progress: true,
9885            status: std::sync::Mutex::new(RunStatus::Idle),
9886            js_preparation_cancel: std::sync::Mutex::new(None),
9887            footer: Arc::new(FooterComponent::new()),
9888            status_container: Arc::new(Container::new()),
9889            chat_container: Arc::new(Container::new()),
9890            loader: Arc::new(Loader::new()),
9891            last_assistant_text: std::sync::Mutex::new(String::new()),
9892            active_selector: std::sync::Mutex::new(None),
9893            active_extension_editor: std::sync::Mutex::new(None),
9894            active_extension_input: std::sync::Mutex::new(None),
9895            active_extension_cancel: std::sync::Mutex::new(None),
9896            autocomplete: AutocompleteManager::new(),
9897            autocomplete_container: Arc::new(Container::new()),
9898            autocomplete_max_visible: 5,
9899            pending_images: std::sync::Mutex::new(Vec::new()),
9900            theme_manager: Arc::new(ThemeManager::new()),
9901            tui: None,
9902            current_model_id: std::sync::Mutex::new(String::new()),
9903            show_images: std::sync::Mutex::new(true),
9904            history: std::sync::Mutex::new(Vec::new()),
9905            history_index: std::sync::Mutex::new(-1),
9906            history_draft: std::sync::Mutex::new(None),
9907            last_input_tokens: std::sync::Mutex::new(0),
9908            scoped_edit: std::sync::Mutex::new(None),
9909            markdown_transformer: std::sync::Mutex::new(None),
9910            extension_session: Arc::new(std::sync::Mutex::new(
9911                rpi_extensions::ExtensionSession::none(),
9912            )),
9913            search: Arc::new(AltScreenSearch::new()),
9914            search_bar: Arc::new(SearchBar::new()),
9915            selection_start: std::sync::Mutex::new(None),
9916            selection_end: std::sync::Mutex::new(None),
9917        });
9918
9919        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
9920        // terminal; instead, exercise the *mutation* half directly against a
9921        // captured chat container via a synthetic message-start event's data.
9922        let assistant = AssistantMessage {
9923            role: rpi_ai::types::AssistantRole,
9924            content: vec![
9925                Content::Thinking(ThinkingContent {
9926                    kind: ThinkingContentType,
9927                    thinking: "Reasoning about the reply.".into(),
9928                    thinking_signature: None,
9929                    redacted: false,
9930                }),
9931                Content::Text(TextContent {
9932                    kind: TextContentType,
9933                    text: "Hello.".into(),
9934                    text_signature: None,
9935                }),
9936                Content::ToolCall(ToolCall {
9937                    kind: ToolCallType,
9938                    id: "tc1".into(),
9939                    name: "bash".into(),
9940                    arguments: serde_json::json!({"command": "echo hi"}),
9941                    thought_signature: None,
9942                    namespace: None,
9943                }),
9944            ],
9945            api: rpi_ai::Api::AnthropicMessages,
9946            provider: "anthropic".into(),
9947            model: "claude-sonnet-5".into(),
9948            response_model: None,
9949            response_id: None,
9950            usage: Usage::zero(),
9951            stop_reason: StopReason::Stop,
9952            deferred: None,
9953            error_message: None,
9954            raw_stop_reason: None,
9955            end_turn: None,
9956            timestamp: 0,
9957        };
9958
9959        // Manually apply the MessageStart assistant branch logic (mirrors the
9960        // drain handler, without needing a TuiAltScreen).
9961        let comp = Arc::new(AssistantMessageComponent::new(
9962            AssistantMessageOptions::default(),
9963        ));
9964        comp.set_streaming(true);
9965        comp.update_blocks(&assistant_blocks(&assistant));
9966        let chat = Arc::new(Container::new());
9967        chat.add_child(comp.clone());
9968        *state.current_assistant.lock().unwrap() = Some(comp);
9969
9970        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
9971        for c in &assistant.content {
9972            if let Content::ToolCall(tc) = c {
9973                let mut tools = state.tool_components.lock().unwrap();
9974                if !tools.contains_key(&tc.id) {
9975                    let tc_comp = Arc::new(ToolExecutionComponent::new(
9976                        &tc.name,
9977                        &tc.arguments.to_string(),
9978                    ));
9979                    tc_comp.set_running();
9980                    chat.add_child(tc_comp.clone());
9981                    tools.insert(tc.id.clone(), tc_comp);
9982                }
9983            }
9984        }
9985
9986        // Assert: the assistant component rendered the text + the thinking
9987        // block (the update_blocks path keeps thinking visible), and a tool
9988        // component was registered.
9989        let rendered = chat.render(80);
9990        let joined: String = rendered.join("\n");
9991        assert!(
9992            joined.contains("Hello."),
9993            "assistant text not rendered: {joined}"
9994        );
9995        assert!(
9996            joined.contains("Reasoning about the reply."),
9997            "thinking block not rendered: {joined}"
9998        );
9999        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
10000        assert!(state.current_assistant.lock().unwrap().is_some());
10001
10002        // Manually apply ToolExecutionEnd (mirrors drain).
10003        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10004        ended.set_result("hi", false);
10005        assert!(state.tool_components.lock().unwrap().is_empty());
10006
10007        // A running bash panel owns the visible spinner. The global loader is
10008        // hidden until the last concurrent bash tool completes, then restored
10009        // while the agent remains in the Working state.
10010        assert!(state.try_start_working());
10011        assert!(
10012            !state.try_start_working(),
10013            "a second submit must be rejected"
10014        );
10015        state.set_status(RunStatus::Idle);
10016        state.set_status(RunStatus::Working);
10017        assert_eq!(state.status_container.child_count(), 1);
10018        assert!(state.footer.get_status().is_empty());
10019        state.show_retry(3, 10, 8_000);
10020        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10021        assert!(retry_status.contains("Retrying (3/10)"));
10022        state.set_status(RunStatus::Working);
10023        {
10024            let mut bash = state.bash_components.lock().unwrap();
10025            bash.insert(
10026                "bash-1".into(),
10027                Arc::new(BashExecutionComponent::new("one")),
10028            );
10029            bash.insert(
10030                "bash-2".into(),
10031                Arc::new(BashExecutionComponent::new("two")),
10032            );
10033        }
10034        state.sync_working_loader_with_bash();
10035        assert_eq!(state.status_container.child_count(), 0);
10036        state.bash_components.lock().unwrap().remove("bash-1");
10037        state.sync_working_loader_with_bash();
10038        assert_eq!(state.status_container.child_count(), 0);
10039        state.bash_components.lock().unwrap().remove("bash-2");
10040        state.sync_working_loader_with_bash();
10041        assert_eq!(state.status_container.child_count(), 1);
10042
10043        state.set_status(RunStatus::Aborting);
10044        assert_eq!(state.status_container.child_count(), 0);
10045        assert!(!state.loader.is_running());
10046    }
10047
10048    #[test]
10049    fn fresh_launch_does_not_restore_old_history() {
10050        let fresh = Args::default();
10051        assert!(!launch_restores_history(&fresh));
10052
10053        let continued = Args {
10054            continue_session: true,
10055            ..Args::default()
10056        };
10057        assert!(launch_restores_history(&continued));
10058
10059        let selected = Args {
10060            session: Some("session-id".into()),
10061            ..Args::default()
10062        };
10063        assert!(launch_restores_history(&selected));
10064    }
10065
10066    #[test]
10067    fn test_short_model_name() {
10068        assert_eq!(
10069            short_model_name("anthropic:claude-sonnet-5"),
10070            "claude-sonnet-5"
10071        );
10072        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10073    }
10074
10075    #[test]
10076    fn model_selector_items_are_deduplicated_and_provider_qualified() {
10077        use rpi_ai::{Api, Model};
10078
10079        let mut gateway = Model::new(
10080            "gpt-5.6-sol",
10081            "GPT 5.6 Sol",
10082            Api::OpenaiCompletions,
10083            "routeryo-copy",
10084            "https://gateway.example.com",
10085        );
10086        let duplicate = gateway.clone();
10087        let anthropic = Model::new(
10088            "claude-sonnet-5",
10089            "Claude Sonnet 5",
10090            Api::AnthropicMessages,
10091            "anthropic",
10092            "https://api.anthropic.com",
10093        );
10094        gateway.headers = Some(std::collections::BTreeMap::from([(
10095            "authorization".into(),
10096            "Bearer test".into(),
10097        )]));
10098
10099        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10100        assert_eq!(items.len(), 2);
10101        assert_eq!(items[0].value, "gpt-5.6-sol");
10102        assert_eq!(items[0].label, "GPT 5.6 Sol");
10103        assert_eq!(
10104            items[0].description.as_deref(),
10105            Some("routeryo-copy/gpt-5.6-sol (current)")
10106        );
10107        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10108    }
10109
10110    #[test]
10111    fn model_selector_match_accepts_bare_and_qualified_ids() {
10112        use rpi_ai::{Api, Model};
10113
10114        let gateway = Model::new(
10115            "gpt-5.6-sol",
10116            "GPT 5.6 Sol",
10117            Api::OpenaiCompletions,
10118            "routeryo-copy",
10119            "https://gateway.example.com",
10120        );
10121        let anthropic = Model::new(
10122            "claude-sonnet-5",
10123            "Claude Sonnet 5",
10124            Api::AnthropicMessages,
10125            "anthropic",
10126            "https://api.anthropic.com",
10127        );
10128        let catalog = [gateway, anthropic];
10129        assert_eq!(
10130            find_model_selector_match(&catalog, "gpt-5.6-sol")
10131                .unwrap()
10132                .provider,
10133            "routeryo-copy"
10134        );
10135        assert_eq!(
10136            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10137                .unwrap()
10138                .id,
10139            "gpt-5.6-sol"
10140        );
10141        assert_eq!(
10142            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10143                .unwrap()
10144                .id,
10145            "claude-sonnet-5"
10146        );
10147        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10148    }
10149
10150    #[test]
10151    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10152        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10153
10154        let failed = AssistantMessage {
10155            role: AssistantRole,
10156            content: Vec::new(),
10157            api: rpi_ai::Api::AnthropicMessages,
10158            provider: "anthropic".into(),
10159            model: "claude-sonnet-5".into(),
10160            response_model: None,
10161            response_id: None,
10162            usage: Usage::zero(),
10163            stop_reason: StopReason::Error,
10164            deferred: None,
10165            error_message: Some("upstream returned 401".into()),
10166            raw_stop_reason: None,
10167            end_turn: None,
10168            timestamp: 0,
10169        };
10170        assert_eq!(
10171            assistant_error_text(&failed).as_deref(),
10172            Some("upstream returned 401")
10173        );
10174
10175        let mut no_detail = failed;
10176        no_detail.error_message = Some("  ".into());
10177        assert_eq!(
10178            assistant_error_text(&no_detail).as_deref(),
10179            Some("Provider request failed.")
10180        );
10181
10182        let mut aborted = no_detail;
10183        aborted.stop_reason = StopReason::Aborted;
10184        aborted.error_message = Some("abort error: Request aborted".into());
10185        assert_eq!(
10186            assistant_error_text(&aborted).as_deref(),
10187            Some("abort error: Request aborted")
10188        );
10189
10190        aborted.error_message = None;
10191        assert_eq!(
10192            assistant_error_text(&aborted).as_deref(),
10193            Some("Request aborted.")
10194        );
10195    }
10196
10197    #[test]
10198    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10199        assert_eq!(
10200            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10201            "405\nMethod Not Allowed"
10202        );
10203        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10204        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
10205        let long = "x".repeat(20_000);
10206        let cleaned = sanitize_error_message(&long);
10207        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10208        assert!(cleaned.ends_with('…'));
10209    }
10210
10211    #[test]
10212    fn test_cycle_next_model_wraps_around() {
10213        use rpi_ai::{Api, Model};
10214        let mk = |id: &str| {
10215            Model::new(
10216                id,
10217                id,
10218                Api::AnthropicMessages,
10219                "anthropic",
10220                "https://api.anthropic.com",
10221            )
10222        };
10223        let catalog = [mk("a"), mk("b"), mk("c")];
10224        // Next after "a" is "b"; after "c" wraps to "a".
10225        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10226        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10227        // An unknown current id falls back to the first model.
10228        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10229        // Empty catalog yields None.
10230        let empty: Vec<Model> = vec![];
10231        assert!(cycle_next_model(&empty, "a").is_none());
10232    }
10233
10234    #[test]
10235    fn test_autocomplete_slash_suggestions_render() {
10236        // The autocomplete container should render at least one suggestion
10237        // line when the editor holds a `/` prefix, and clear when it doesn't.
10238        let state = Arc::new(TuiState {
10239            current_assistant: std::sync::Mutex::new(None),
10240            tool_components: std::sync::Mutex::new(HashMap::new()),
10241            bash_components: std::sync::Mutex::new(HashMap::new()),
10242            themes_enabled: true,
10243            hide_thinking: std::sync::Mutex::new(false),
10244            tool_outputs_expanded: std::sync::Mutex::new(false),
10245            show_terminal_progress: true,
10246            status: std::sync::Mutex::new(RunStatus::Idle),
10247            js_preparation_cancel: std::sync::Mutex::new(None),
10248            footer: Arc::new(FooterComponent::new()),
10249            status_container: Arc::new(Container::new()),
10250            chat_container: Arc::new(Container::new()),
10251            loader: Arc::new(Loader::new()),
10252            last_assistant_text: std::sync::Mutex::new(String::new()),
10253            active_selector: std::sync::Mutex::new(None),
10254            active_extension_editor: std::sync::Mutex::new(None),
10255            active_extension_input: std::sync::Mutex::new(None),
10256            active_extension_cancel: std::sync::Mutex::new(None),
10257            autocomplete: AutocompleteManager::new(),
10258            autocomplete_container: Arc::new(Container::new()),
10259            autocomplete_max_visible: 5,
10260            pending_images: std::sync::Mutex::new(Vec::new()),
10261            theme_manager: Arc::new(ThemeManager::new()),
10262            tui: None,
10263            current_model_id: std::sync::Mutex::new(String::new()),
10264            show_images: std::sync::Mutex::new(true),
10265            history: std::sync::Mutex::new(Vec::new()),
10266            history_index: std::sync::Mutex::new(-1),
10267            history_draft: std::sync::Mutex::new(None),
10268            last_input_tokens: std::sync::Mutex::new(0),
10269            scoped_edit: std::sync::Mutex::new(None),
10270            markdown_transformer: std::sync::Mutex::new(None),
10271            extension_session: Arc::new(std::sync::Mutex::new(
10272                rpi_extensions::ExtensionSession::none(),
10273            )),
10274            search: Arc::new(AltScreenSearch::new()),
10275            search_bar: Arc::new(SearchBar::new()),
10276            selection_start: std::sync::Mutex::new(None),
10277            selection_end: std::sync::Mutex::new(None),
10278        });
10279        {
10280            let mut combined = CombinedAutocompleteProvider::new();
10281            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10282                build_builtin_registry().visible_entries(),
10283            )));
10284            state.autocomplete.set_provider(Arc::new(combined));
10285        }
10286
10287        let editor = Arc::new(Editor::simple());
10288        editor.set_text("/he");
10289        editor.set_cursor(0, 3);
10290        refresh_autocomplete(&state, &editor);
10291        let lines = state.autocomplete_container.render(80);
10292        let joined: String = lines.join("\n");
10293        assert!(
10294            joined.contains("/help"),
10295            "slash suggestions not rendered: {joined}"
10296        );
10297
10298        // Clear: no suggestions for plain text.
10299        editor.set_text("hello");
10300        editor.set_cursor(0, 5);
10301        refresh_autocomplete(&state, &editor);
10302        assert!(state.autocomplete_container.render(80).is_empty());
10303    }
10304
10305    #[test]
10306    fn test_select_list_swap_restores_editor() {
10307        // The editor-container swap: opening a selector replaces the editor
10308        // child; closing restores it. Verify the container child count + the
10309        // active_selector flag round-trip.
10310        let state = Arc::new(TuiState {
10311            current_assistant: std::sync::Mutex::new(None),
10312            tool_components: std::sync::Mutex::new(HashMap::new()),
10313            bash_components: std::sync::Mutex::new(HashMap::new()),
10314            themes_enabled: true,
10315            hide_thinking: std::sync::Mutex::new(false),
10316            tool_outputs_expanded: std::sync::Mutex::new(false),
10317            show_terminal_progress: true,
10318            status: std::sync::Mutex::new(RunStatus::Idle),
10319            js_preparation_cancel: std::sync::Mutex::new(None),
10320            footer: Arc::new(FooterComponent::new()),
10321            status_container: Arc::new(Container::new()),
10322            chat_container: Arc::new(Container::new()),
10323            loader: Arc::new(Loader::new()),
10324            last_assistant_text: std::sync::Mutex::new(String::new()),
10325            active_selector: std::sync::Mutex::new(None),
10326            active_extension_editor: std::sync::Mutex::new(None),
10327            active_extension_input: std::sync::Mutex::new(None),
10328            active_extension_cancel: std::sync::Mutex::new(None),
10329            autocomplete: AutocompleteManager::new(),
10330            autocomplete_container: Arc::new(Container::new()),
10331            autocomplete_max_visible: 5,
10332            pending_images: std::sync::Mutex::new(Vec::new()),
10333            theme_manager: Arc::new(ThemeManager::new()),
10334            tui: None,
10335            current_model_id: std::sync::Mutex::new(String::new()),
10336            show_images: std::sync::Mutex::new(true),
10337            history: std::sync::Mutex::new(Vec::new()),
10338            history_index: std::sync::Mutex::new(-1),
10339            history_draft: std::sync::Mutex::new(None),
10340            last_input_tokens: std::sync::Mutex::new(0),
10341            scoped_edit: std::sync::Mutex::new(None),
10342            markdown_transformer: std::sync::Mutex::new(None),
10343            extension_session: Arc::new(std::sync::Mutex::new(
10344                rpi_extensions::ExtensionSession::none(),
10345            )),
10346            search: Arc::new(AltScreenSearch::new()),
10347            search_bar: Arc::new(SearchBar::new()),
10348            selection_start: std::sync::Mutex::new(None),
10349            selection_end: std::sync::Mutex::new(None),
10350        });
10351        let editor_container = Arc::new(Container::new());
10352        let editor = Arc::new(Editor::simple());
10353        editor_container.add_child(editor.clone());
10354        assert!(!state.selector_open());
10355
10356        let tui_terminal = Box::new(ProcessTerminal::new());
10357        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
10358        let list = Arc::new(SelectList::new(
10359            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
10360            5,
10361        ));
10362        open_selector(
10363            &state,
10364            &editor_container,
10365            &editor,
10366            &tui,
10367            list,
10368            SelectorKind::Theme,
10369        );
10370        assert!(state.selector_open());
10371        // list only (editor swapped out).
10372        assert_eq!(editor_container.child_count(), 1);
10373
10374        close_selector(&state, &editor_container, &editor, &tui);
10375        assert!(!state.selector_open());
10376        // editor restored.
10377        assert_eq!(editor_container.child_count(), 1);
10378    }
10379
10380    #[test]
10381    fn test_message_history_browse_restores_draft() {
10382        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
10383        // messages, browse older → newer → back past the newest restores the
10384        // draft the user was typing.
10385        let state = Arc::new(TuiState {
10386            current_assistant: std::sync::Mutex::new(None),
10387            tool_components: std::sync::Mutex::new(HashMap::new()),
10388            bash_components: std::sync::Mutex::new(HashMap::new()),
10389            themes_enabled: true,
10390            hide_thinking: std::sync::Mutex::new(false),
10391            tool_outputs_expanded: std::sync::Mutex::new(false),
10392            show_terminal_progress: true,
10393            status: std::sync::Mutex::new(RunStatus::Idle),
10394            js_preparation_cancel: std::sync::Mutex::new(None),
10395            footer: Arc::new(FooterComponent::new()),
10396            status_container: Arc::new(Container::new()),
10397            chat_container: Arc::new(Container::new()),
10398            loader: Arc::new(Loader::new()),
10399            last_assistant_text: std::sync::Mutex::new(String::new()),
10400            active_selector: std::sync::Mutex::new(None),
10401            active_extension_editor: std::sync::Mutex::new(None),
10402            active_extension_input: std::sync::Mutex::new(None),
10403            active_extension_cancel: std::sync::Mutex::new(None),
10404            autocomplete: AutocompleteManager::new(),
10405            autocomplete_container: Arc::new(Container::new()),
10406            autocomplete_max_visible: 5,
10407            pending_images: std::sync::Mutex::new(Vec::new()),
10408            theme_manager: Arc::new(ThemeManager::new()),
10409            tui: None,
10410            current_model_id: std::sync::Mutex::new(String::new()),
10411            show_images: std::sync::Mutex::new(true),
10412            history: std::sync::Mutex::new(Vec::new()),
10413            history_index: std::sync::Mutex::new(-1),
10414            history_draft: std::sync::Mutex::new(None),
10415            last_input_tokens: std::sync::Mutex::new(0),
10416            scoped_edit: std::sync::Mutex::new(None),
10417            markdown_transformer: std::sync::Mutex::new(None),
10418            extension_session: Arc::new(std::sync::Mutex::new(
10419                rpi_extensions::ExtensionSession::none(),
10420            )),
10421            search: Arc::new(AltScreenSearch::new()),
10422            search_bar: Arc::new(SearchBar::new()),
10423            selection_start: std::sync::Mutex::new(None),
10424            selection_end: std::sync::Mutex::new(None),
10425        });
10426        let editor = Arc::new(Editor::simple());
10427
10428        push_history(&state, "first message");
10429        push_history(&state, "second message");
10430        // Consecutive duplicate is skipped.
10431        push_history(&state, "second message");
10432        push_history(&state, "   "); // empty → skipped
10433        assert_eq!(state.history.lock().unwrap().len(), 2);
10434        assert_eq!(state.history.lock().unwrap()[0], "second message");
10435
10436        // User starts typing a fresh prompt.
10437        editor.set_text("half-typed");
10438        editor.set_cursor(0, 11);
10439
10440        // ↑ → most recent.
10441        navigate_history(&state, &editor, -1);
10442        assert_eq!(editor.get_text(), "second message");
10443        assert_eq!(*state.history_index.lock().unwrap(), 0);
10444        // ↑ → older.
10445        navigate_history(&state, &editor, -1);
10446        assert_eq!(editor.get_text(), "first message");
10447        assert_eq!(*state.history_index.lock().unwrap(), 1);
10448        // ↑ past the oldest → stays (no wrap).
10449        navigate_history(&state, &editor, -1);
10450        assert_eq!(editor.get_text(), "first message");
10451        // ↓ → newer.
10452        navigate_history(&state, &editor, 1);
10453        assert_eq!(editor.get_text(), "second message");
10454        // ↓ past the newest → restores the draft.
10455        navigate_history(&state, &editor, 1);
10456        assert_eq!(editor.get_text(), "half-typed");
10457        assert_eq!(*state.history_index.lock().unwrap(), -1);
10458    }
10459
10460    #[test]
10461    fn test_accept_top_suggestion_replaces_prefix() {
10462        // `/he` + Tab → `/help ` (slash command provider inserts a space).
10463        let state = Arc::new(TuiState {
10464            current_assistant: std::sync::Mutex::new(None),
10465            tool_components: std::sync::Mutex::new(HashMap::new()),
10466            bash_components: std::sync::Mutex::new(HashMap::new()),
10467            themes_enabled: true,
10468            hide_thinking: std::sync::Mutex::new(false),
10469            tool_outputs_expanded: std::sync::Mutex::new(false),
10470            show_terminal_progress: true,
10471            status: std::sync::Mutex::new(RunStatus::Idle),
10472            js_preparation_cancel: std::sync::Mutex::new(None),
10473            footer: Arc::new(FooterComponent::new()),
10474            status_container: Arc::new(Container::new()),
10475            chat_container: Arc::new(Container::new()),
10476            loader: Arc::new(Loader::new()),
10477            last_assistant_text: std::sync::Mutex::new(String::new()),
10478            active_selector: std::sync::Mutex::new(None),
10479            active_extension_editor: std::sync::Mutex::new(None),
10480            active_extension_input: std::sync::Mutex::new(None),
10481            active_extension_cancel: std::sync::Mutex::new(None),
10482            autocomplete: AutocompleteManager::new(),
10483            autocomplete_container: Arc::new(Container::new()),
10484            autocomplete_max_visible: 5,
10485            pending_images: std::sync::Mutex::new(Vec::new()),
10486            theme_manager: Arc::new(ThemeManager::new()),
10487            tui: None,
10488            current_model_id: std::sync::Mutex::new(String::new()),
10489            show_images: std::sync::Mutex::new(true),
10490            history: std::sync::Mutex::new(Vec::new()),
10491            history_index: std::sync::Mutex::new(-1),
10492            history_draft: std::sync::Mutex::new(None),
10493            last_input_tokens: std::sync::Mutex::new(0),
10494            scoped_edit: std::sync::Mutex::new(None),
10495            markdown_transformer: std::sync::Mutex::new(None),
10496            extension_session: Arc::new(std::sync::Mutex::new(
10497                rpi_extensions::ExtensionSession::none(),
10498            )),
10499            search: Arc::new(AltScreenSearch::new()),
10500            search_bar: Arc::new(SearchBar::new()),
10501            selection_start: std::sync::Mutex::new(None),
10502            selection_end: std::sync::Mutex::new(None),
10503        });
10504        {
10505            let mut combined = CombinedAutocompleteProvider::new();
10506            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10507                build_builtin_registry().visible_entries(),
10508            )));
10509            state.autocomplete.set_provider(Arc::new(combined));
10510        }
10511        let editor = Arc::new(Editor::simple());
10512        editor.set_text("/he");
10513        editor.set_cursor(0, 3);
10514        refresh_autocomplete(&state, &editor);
10515        let accepted = accept_top_suggestion(&state, &editor);
10516        assert!(accepted, "should accept the top suggestion");
10517        let text = editor.get_text();
10518        assert!(
10519            text.starts_with("/help"),
10520            "editor text should start with /help, got {text}"
10521        );
10522    }
10523
10524    #[test]
10525    fn configured_key_parser_supports_native_notation() {
10526        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10527        assert_eq!(combo.code, KeyCode::Char('g'));
10528        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10529        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10530        assert_eq!(combo.code, KeyCode::BackTab);
10531    }
10532
10533    #[test]
10534    fn double_escape_trigger_has_half_second_window() {
10535        let now = std::time::Instant::now();
10536        assert!(!double_escape_trigger(None, now));
10537        assert!(double_escape_trigger(
10538            Some(now - std::time::Duration::from_millis(500)),
10539            now
10540        ));
10541        assert!(!double_escape_trigger(
10542            Some(now - std::time::Duration::from_millis(501)),
10543            now
10544        ));
10545    }
10546
10547    #[test]
10548    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
10549        // End-to-end shape of the fix: replay the key stream a Windows paste
10550        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
10551        // the key loop uses — the burst discriminator for each Enter and the
10552        // editor for the text — and assert that *no* submit fired while the
10553        // block arrived, then exactly one submit on the user's own Enter.
10554        use std::sync::atomic::{AtomicUsize, Ordering};
10555
10556        let editor = rpi_tui::Editor::simple();
10557        let submits = Arc::new(AtomicUsize::new(0));
10558        let submits_for_cb = submits.clone();
10559        editor.on_submit(Arc::new(move |_text: &str| {
10560            submits_for_cb.fetch_add(1, Ordering::SeqCst);
10561        }));
10562
10563        // Every pasted key is delivered back-to-back, so `now` is unchanged and
10564        // the console queue is never empty until the paste is drained.
10565        let now = std::time::Instant::now();
10566        let mut last_text_key_at: Option<std::time::Instant> = None;
10567        let block = ["l1", "l2", "l3"];
10568        for (index, line) in block.iter().enumerate() {
10569            for ch in line.chars() {
10570                editor.insert(&ch.to_string());
10571                last_text_key_at = Some(now);
10572            }
10573            if index + 1 < block.len() {
10574                // The pasted newline: more input is still queued behind it.
10575                let more_queued = true;
10576                assert!(
10577                    enter_is_paste_burst(last_text_key_at, now, more_queued),
10578                    "pasted newline {index} must not submit"
10579                );
10580                editor.insert("\n");
10581                last_text_key_at = Some(now);
10582            }
10583        }
10584        assert_eq!(editor.get_text(), "l1\nl2\nl3");
10585        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
10586
10587        // The user's own Enter: a real gap, nothing queued.
10588        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
10589        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
10590        editor.handle_key(crossterm::event::KeyEvent::new(
10591            KeyCode::Enter,
10592            KeyModifiers::NONE,
10593        ));
10594        assert_eq!(submits.load(Ordering::SeqCst), 1, "typed Enter submits once");
10595    }
10596
10597    #[test]
10598    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
10599        // A Windows paste delivers CRLF; the editor must fold it to LF so the
10600        // rendered block is clean and the cursor arithmetic stays sound.
10601        let editor = rpi_tui::Editor::simple();
10602        editor.insert("alpha\r\nbeta\r\n");
10603        assert_eq!(editor.get_text(), "alpha\nbeta\n");
10604        assert!(!editor.get_text().contains('\r'));
10605    }
10606
10607    #[test]
10608    fn pasted_enter_is_not_a_submit() {
10609        let now = std::time::Instant::now();
10610
10611        // Queued input behind the Enter => paste, regardless of history.
10612        assert!(enter_is_paste_burst(None, now, true));
10613
10614        // Enter immediately after pasted characters => paste (also covers a
10615        // paste whose final line ends with a newline, where nothing is queued).
10616        assert!(enter_is_paste_burst(
10617            Some(now - std::time::Duration::from_millis(1)),
10618            now,
10619            false
10620        ));
10621        assert!(enter_is_paste_burst(
10622            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
10623            now,
10624            false
10625        ));
10626
10627        // A typed Enter: nothing queued and a real gap since the last key.
10628        assert!(!enter_is_paste_burst(None, now, false));
10629        assert!(!enter_is_paste_burst(
10630            Some(now - PASTE_BURST_GAP),
10631            now,
10632            false
10633        ));
10634        assert!(!enter_is_paste_burst(
10635            Some(now - std::time::Duration::from_millis(120)),
10636            now,
10637            false
10638        ));
10639    }
10640
10641    // ---- ask_user TUI bridge ----
10642
10643    #[test]
10644    fn ask_user_tool_names_are_recognized() {
10645        assert!(is_ask_user_tool("ask_user"));
10646        assert!(is_ask_user_tool("ask_user_question"));
10647        assert!(!is_ask_user_tool("bash"));
10648    }
10649
10650    #[test]
10651    fn ask_user_args_display_never_leaks_transport_json() {
10652        let text = ask_user_args_display(&serde_json::json!({
10653            "question": "你的代理端口是多少?",
10654            "context": "本机",
10655            "options": ["7890", {"title": "7897", "description": "clash"}],
10656            "suggest": "1080"
10657        }));
10658        assert!(text.contains("你的代理端口是多少?"));
10659        assert!(text.contains("本机"));
10660        assert!(text.contains("Choices: 7890, 7897"));
10661        assert!(text.contains("Suggestions: 1080"));
10662        assert!(!text.trim_start().starts_with('{'));
10663
10664        let multi = ask_user_args_display(&serde_json::json!({
10665            "questions": [
10666                {"id": "a", "question": "Q1"},
10667                {"id": "b", "header": "H", "question": "Q2"}
10668            ]
10669        }));
10670        assert!(multi.contains("Q1"));
10671        assert!(multi.contains("H: Q2"));
10672
10673        assert_eq!(
10674            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10675            "Confirmation requested: Deploy?"
10676        );
10677        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10678    }
10679
10680    #[test]
10681    fn ask_user_result_and_progress_prefer_readable_content() {
10682        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10683        assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10684            .contains("端口?"));
10685        let empty = rpi_agent::AgentToolResult::default();
10686        assert!(
10687            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10688                .contains("端口?")
10689        );
10690        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10691        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10692        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10693    }
10694
10695    #[test]
10696    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10697        let flat = rpi_extensions::UiDialogRequest {
10698            request_id: "r1".into(),
10699            tool_call_id: Some("c1".into()),
10700            ui: serde_json::json!({
10701                "kind": "selector",
10702                "id": "proxy_port",
10703                "header": "Proxy",
10704                "question": "端口?",
10705                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10706                "allowMultiple": true,
10707                "allowFreeform": false,
10708                "suggest": "1080"
10709            }),
10710            raw: serde_json::json!({}),
10711        };
10712        let prompt = parse_ask_user_prompt(&flat);
10713        assert_eq!(prompt.question_id, "proxy_port");
10714        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10715        assert_eq!(prompt.options.len(), 2);
10716        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10717        assert!(prompt.allow_multiple);
10718        assert!(!prompt.allow_freeform);
10719        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10720
10721        let alias = rpi_extensions::UiDialogRequest {
10722            ui: serde_json::json!({
10723                "questions": [{"id": "q", "question": "Q?", "options": []}]
10724            }),
10725            ..flat.clone()
10726        };
10727        let prompt = parse_ask_user_prompt(&alias);
10728        assert_eq!(prompt.question_id, "q");
10729        assert!(prompt.allow_freeform, "no options defaults to freeform");
10730
10731        let confirm = rpi_extensions::UiDialogRequest {
10732            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10733            ..flat.clone()
10734        };
10735        let prompt = parse_ask_user_prompt(&confirm);
10736        assert_eq!(prompt.kind, "confirm");
10737        assert_eq!(prompt.options.len(), 2);
10738        assert_eq!(prompt.options[0].0, "Yes");
10739    }
10740
10741    #[test]
10742    fn ask_user_bridge_answers_and_cancels_by_request_id() {
10743        let mailbox = rpi_extensions::UiDialogMailbox::new();
10744        mailbox.attach();
10745        let bridge = AskUserBridge::new(mailbox.clone());
10746
10747        // Two concurrent requests must not cross answers.
10748        mailbox
10749            .handle(serde_json::json!({
10750                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10751                "ui": {"kind": "input", "question": "Port?"}
10752            }))
10753            .unwrap();
10754        mailbox
10755            .handle(serde_json::json!({
10756                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10757                "ui": {"kind": "input", "question": "Level?"}
10758            }))
10759            .unwrap();
10760
10761        let first = bridge.take_pending().expect("r1 visible");
10762        assert_eq!(first.request_id, "r1");
10763        assert!(
10764            bridge.take_pending().is_none(),
10765            "only one request occupies the input slot"
10766        );
10767        bridge.respond("r1", serde_json::json!({"text": "7897"}));
10768        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10769
10770        let second = bridge.take_pending().expect("r2 visible");
10771        assert_eq!(second.request_id, "r2");
10772        bridge.cancel("r2");
10773        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10774
10775        // cancel_all drops anything still queued.
10776        mailbox
10777            .handle(serde_json::json!({
10778                "op": "open", "requestId": "r3",
10779                "ui": {"kind": "input", "question": "X?"}
10780            }))
10781            .unwrap();
10782        bridge.cancel_all();
10783        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10784
10785        // A detached mailbox rejects new prompts (headless contract).
10786        bridge.shutdown();
10787        assert!(mailbox
10788            .handle(serde_json::json!({
10789                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10790            }))
10791            .is_err());
10792    }
10793}