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`); otherwise returns `false` so the caller degrades to a hint.
6976#[cfg(feature = "clipboard")]
6977fn copy_to_clipboard(text: &str) -> bool {
6978    match arboard::Clipboard::new() {
6979        Ok(mut cb) => cb.set_text(text).is_ok(),
6980        Err(_) => false,
6981    }
6982}
6983
6984#[cfg(not(feature = "clipboard"))]
6985fn copy_to_clipboard(_text: &str) -> bool {
6986    false
6987}
6988
6989/// Read a clipboard bitmap and normalize it to PNG for the provider-neutral
6990/// `ImageContent` contract. The optional clipboard feature keeps headless
6991/// builds free of platform clipboard dependencies.
6992#[cfg(feature = "clipboard")]
6993fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
6994    let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
6995    let image = match clipboard.get_image() {
6996        Ok(image) => image,
6997        Err(_) => return Ok(None),
6998    };
6999    let width =
7000        u32::try_from(image.width).map_err(|_| "clipboard image is too wide".to_string())?;
7001    let height =
7002        u32::try_from(image.height).map_err(|_| "clipboard image is too tall".to_string())?;
7003    if width == 0 || height == 0 || width > 16_384 || height > 16_384 {
7004        return Err("clipboard image dimensions are outside the supported range".into());
7005    }
7006    let mut bytes = Vec::new();
7007    {
7008        let mut encoder = png::Encoder::new(&mut bytes, width, height);
7009        encoder.set_color(png::ColorType::Rgba);
7010        encoder.set_depth(png::BitDepth::Eight);
7011        let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
7012        writer
7013            .write_image_data(&image.bytes)
7014            .map_err(|e| e.to_string())?;
7015    }
7016    Ok(Some(rpi_ai::types::ImageContent {
7017        kind: rpi_ai::types::ImageContentType,
7018        data: base64::engine::general_purpose::STANDARD.encode(bytes),
7019        mime_type: "image/png".into(),
7020    }))
7021}
7022
7023fn add_image_preview(chat: &Arc<Container>, image: &rpi_ai::types::ImageContent) {
7024    if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) {
7025        let mut options = ImageOptions::default();
7026        options.width = Some(48);
7027        options.alt_text = Some("Attached image".into());
7028        chat.add_child(Arc::new(Image::from_data(bytes, options)));
7029        chat.add_child(Arc::new(Spacer::new(1)));
7030    }
7031}
7032
7033#[cfg(not(feature = "clipboard"))]
7034fn read_clipboard_image() -> Result<Option<rpi_ai::types::ImageContent>, String> {
7035    Ok(None)
7036}
7037
7038/// Blocking fallback (no `event_rx`): render the final assistant text as a
7039/// single `AssistantMessageComponent`, mirroring the pre-streaming behavior.
7040/// `transformer` is the live assistant-markdown transformer (B5e); `None` is
7041/// the identity path. The blocking path only fires when `event_rx` is absent,
7042/// so it shares the same transformer the streaming path installs on its
7043/// components.
7044fn add_assistant_message_blocking(
7045    container: &Arc<Container>,
7046    text: &str,
7047    transformer: Option<MarkdownTransformer>,
7048) {
7049    if text.is_empty() {
7050        return;
7051    }
7052    let msg = Arc::new(AssistantMessageComponent::new(
7053        AssistantMessageOptions::default(),
7054    ));
7055    if let Some(t) = &transformer {
7056        msg.set_markdown_transformer(Some(t.clone()));
7057    }
7058    msg.update_text(text);
7059    container.add_child(msg);
7060    container.add_child(Arc::new(Spacer::new(1)));
7061}
7062
7063// ===========================================================================
7064// AgentEvent drain task — the streaming core
7065// ===========================================================================
7066
7067/// Drain `AgentEvent`s from the broadcast receiver and apply the TS
7068/// `handleEvent` event→UI mapping. Runs on a `tokio::spawn`'d task for the
7069/// lifetime of the TUI.
7070async fn drain_agent_events(
7071    mut rx: broadcast::Receiver<AgentEvent>,
7072    tui: Arc<TuiAltScreen>,
7073    state: Arc<TuiState>,
7074    chat: Arc<Container>,
7075) {
7076    loop {
7077        match rx.recv().await {
7078            Ok(event) => handle_agent_event(event, &tui, &state, &chat).await,
7079            Err(broadcast::error::RecvError::Lagged(_)) => {
7080                // We dropped some intermediate deltas; the next MessageUpdate/
7081                // MessageEnd carries a full partial snapshot so the UI re-syncs.
7082                continue;
7083            }
7084            Err(broadcast::error::RecvError::Closed) => break,
7085        }
7086    }
7087}
7088
7089/// Apply a single `AgentEvent` to the UI. Mirrors the TS `handleEvent` switch
7090/// (`interactive-mode.ts:3068-3396`).
7091async fn handle_agent_event(
7092    event: AgentEvent,
7093    tui: &Arc<TuiAltScreen>,
7094    state: &Arc<TuiState>,
7095    chat: &Arc<Container>,
7096) {
7097    match event {
7098        AgentEvent::AgentStart => {
7099            state.set_status(RunStatus::Working);
7100            tui.request_render(false);
7101        }
7102
7103        AgentEvent::AgentEnd { .. } => {
7104            // Finalize any still-streaming assistant message.
7105            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7106                comp.set_streaming(false);
7107            }
7108            state.set_status(RunStatus::Idle);
7109            tui.request_render(false);
7110        }
7111
7112        AgentEvent::RetryScheduled {
7113            attempt,
7114            max_retries,
7115            delay_ms,
7116            ..
7117        } => {
7118            state.show_retry(attempt, max_retries, delay_ms);
7119            tui.request_render(false);
7120        }
7121
7122        AgentEvent::TurnStart => {
7123            // A new turn: reset the streaming-assistant guard so the next
7124            // MessageStart creates a fresh component.
7125            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7126                comp.set_streaming(false);
7127            }
7128        }
7129
7130        AgentEvent::TurnEnd {
7131            message,
7132            tool_results,
7133        } => {
7134            // Finalize the assistant message for this turn.
7135            if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7136                if let AgentMessage::Assistant(a) = &message {
7137                    comp.update_blocks(&assistant_blocks(a));
7138                }
7139                comp.set_streaming(false);
7140            }
7141            // Any tool results whose components were never ended by a
7142            // ToolExecutionEnd get a static rendering here (best-effort). The
7143            // normal path removes the component via ToolExecutionEnd; this is
7144            // just a no-op guard so a stray TurnEnd doesn't double-finalize.
7145            let tools = state.tool_components.lock().unwrap();
7146            for tr in &tool_results {
7147                if tools.contains_key(&tr.tool_call_id) {
7148                    // Will be removed below via ToolExecutionEnd in the normal
7149                    // path; leave as-is if still present.
7150                    let _ = tr;
7151                }
7152            }
7153            drop(tools);
7154            tui.request_render(false);
7155        }
7156
7157        AgentEvent::MessageStart { message } => match message {
7158            AgentMessage::Assistant(a) => {
7159                let comp = Arc::new(AssistantMessageComponent::new(
7160                    AssistantMessageOptions::default(),
7161                ));
7162                // B5e: install the live markdown transformer so the plugin's
7163                // `register_markdown_transformer` handlers apply from the very
7164                // first streamed delta. `set_streaming` before the transform
7165                // install is fine (transform fires on `update_blocks`, below).
7166                if let Some(t) = state.markdown_transformer() {
7167                    comp.set_markdown_transformer(Some(t));
7168                }
7169                comp.set_hide_thinking(state.hide_thinking());
7170                comp.set_streaming(true);
7171                // Render text AND thinking blocks in order (the old path fed
7172                // only the concatenated text, so thinking blocks never showed).
7173                comp.update_blocks(&assistant_blocks(&a));
7174                chat.add_child(comp.clone());
7175                // Spacer(1) separates this assistant turn from the next entry;
7176                // the component itself adds no leading spacer.
7177                chat.add_child(Arc::new(Spacer::new(1)));
7178                *state.current_assistant.lock().unwrap() = Some(comp);
7179                tui.request_render(false);
7180            }
7181            AgentMessage::Custom(custom) => {
7182                let payload = serde_json::json!({
7183                    "customType": custom.role,
7184                    "content": custom.content,
7185                    "details": custom.data,
7186                    "expanded": false,
7187                    "outputPad": 1,
7188                });
7189                if let Some(component) = extension_message_component(
7190                    &state.extension_session,
7191                    &custom.role,
7192                    &payload,
7193                    state.markdown_transformer(),
7194                ) {
7195                    chat.add_child(component);
7196                    chat.add_child(Arc::new(Spacer::new(1)));
7197                    tui.request_render(false);
7198                } else {
7199                    add_note_message(chat, &custom_message_fallback(&custom));
7200                    tui.request_render(false);
7201                }
7202            }
7203            // User / ToolResult / Custom starts are echoed at submit time or
7204            // via the tool-execution components; ignore user/tool dupes.
7205            _ => {}
7206        },
7207
7208        AgentEvent::MessageUpdate {
7209            message,
7210            assistant_message_event,
7211        } => {
7212            if let AgentMessage::Assistant(a) = &message {
7213                let text = assistant_text(a);
7214                let mut saw_bash_tool_call = false;
7215                // Scan content for finalized tool calls → proactively create
7216                // tool components (TS shows the tool as soon as the assistant
7217                // emits the ToolCall; ToolExecutionStart coalesces if it
7218                // already exists).
7219                for c in &a.content {
7220                    if let Content::ToolCall(tc) = c {
7221                        // Streaming providers may expose a placeholder tool
7222                        // call before its name has arrived. It is not a real
7223                        // tool panel and must not leave an empty first row.
7224                        if tc.name.trim().is_empty() {
7225                            continue;
7226                        }
7227                        if tc.name == "bash" {
7228                            // Bash has a dedicated component. Create it here as
7229                            // well as on ToolExecutionStart because the tool
7230                            // call can become visible in a MessageUpdate first.
7231                            // Keeping it in the bash map lets Start coalesce
7232                            // with this panel instead of appending a second one.
7233                            let command = tc
7234                                .arguments
7235                                .get("command")
7236                                .and_then(|v| v.as_str())
7237                                .unwrap_or("");
7238                            // Streaming tool-call arguments may still be `{}`
7239                            // here. Do not create a running bash panel until
7240                            // the lifecycle start event provides the command;
7241                            // otherwise the spinner renders first and the
7242                            // actual `$ command` header appears one frame
7243                            // later.
7244                            if command.trim().is_empty() {
7245                                continue;
7246                            }
7247                            saw_bash_tool_call = true;
7248                            let mut bash = state.bash_components.lock().unwrap();
7249                            if let Some(existing) = bash.get(&tc.id) {
7250                                // Tool-call arguments arrive incrementally.
7251                                // Refresh the running panel from every full
7252                                // assistant snapshot instead of leaving its
7253                                // header on the first partial command.
7254                                existing.set_command(command);
7255                            } else {
7256                                let comp = Arc::new(BashExecutionComponent::new(command));
7257                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7258                                chat.add_child(comp.clone());
7259                                bash.insert(tc.id.clone(), comp);
7260                            }
7261                        } else {
7262                            let mut tools = state.tool_components.lock().unwrap();
7263                            let display_args = if is_ask_user_tool(&tc.name) {
7264                                ask_user_args_display(&tc.arguments)
7265                            } else {
7266                                tc.arguments.to_string()
7267                            };
7268                            if let Some(existing) = tools.get(&tc.id) {
7269                                // Regular tool arguments stream incrementally
7270                                // too, so refresh their live header as soon as
7271                                // a more complete snapshot arrives.
7272                                if !display_args.trim().is_empty() && display_args.trim() != "{}" {
7273                                    existing.set_args(&display_args);
7274                                }
7275                            } else {
7276                                let comp = Arc::new(ToolExecutionComponent::new(
7277                                    &tc.name,
7278                                    &display_args,
7279                                ));
7280                                if is_ask_user_tool(&tc.name) {
7281                                    comp.set_display_title("ASK USER");
7282                                }
7283                                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7284                                comp.set_running();
7285                                chat.add_child(comp.clone());
7286                                tools.insert(tc.id.clone(), comp);
7287                            }
7288                        }
7289                    }
7290                }
7291                // MessageUpdate can expose the finalized bash call before
7292                // ToolExecutionStart arrives. Hide the global `Working…`
7293                // loader immediately when creating that bash panel; otherwise
7294                // it briefly appears alongside the panel's `Running…` spinner.
7295                if saw_bash_tool_call {
7296                    state.sync_working_loader_with_bash();
7297                }
7298                let _ = assistant_message_event; // snapshot already applied via `a`
7299                if let Some(comp) = state.current_assistant.lock().unwrap().as_ref() {
7300                    // Stream the full block list (text + thinking) each update
7301                    // so thinking blocks render live as they arrive.
7302                    comp.update_blocks(&assistant_blocks(a));
7303                }
7304                *state.last_assistant_text.lock().unwrap() = text;
7305                tui.request_render(false);
7306            }
7307        }
7308
7309        AgentEvent::MessageEnd { message } => {
7310            if let AgentMessage::Assistant(a) = &message {
7311                let text = assistant_text(a);
7312                if let Some(comp) = state.current_assistant.lock().unwrap().take() {
7313                    comp.update_blocks(&assistant_blocks(a));
7314                    comp.set_streaming(false);
7315                }
7316                // Cache the finalized text for `/copy`.
7317                if !text.is_empty() {
7318                    *state.last_assistant_text.lock().unwrap() = text;
7319                }
7320                // Cache-miss notice (simplified `maybeShowCacheMissNotice`):
7321                // the previous turn's input established a cacheable prefix; a
7322                // large input this turn that read nothing from cache means the
7323                // prefix was re-billed. No cost display — v1 has no per-run
7324                // cost tracking here.
7325                let usage = &a.usage;
7326                let prev_input = *state.last_input_tokens.lock().unwrap();
7327                if prev_input > 0
7328                    && usage.input >= CACHE_MISS_MIN_INPUT_TOKENS
7329                    && usage.cache_read == 0
7330                {
7331                    add_note_message(
7332                        &state.chat_container,
7333                        &format!(
7334                            "Cache miss: {} tokens re-billed",
7335                            format_tokens(usage.input)
7336                        ),
7337                    );
7338                }
7339                if let Some(text) = extension_usage_text(Some(&state.extension_session), usage) {
7340                    add_note_message(chat, &text);
7341                }
7342                // Error assistant messages carry the provider diagnostic in
7343                // `error_message`, not in text content. The assistant
7344                // component is empty for these messages, so surface the
7345                // diagnostic as a visible error row in the transcript.
7346                if let Some(error) = assistant_error_text(a) {
7347                    add_error_message(chat, &error);
7348                }
7349                *state.last_input_tokens.lock().unwrap() = usage.input;
7350            }
7351            tui.request_render(false);
7352        }
7353
7354        AgentEvent::ToolExecutionStart {
7355            tool_call_id,
7356            tool_name,
7357            args,
7358        } => {
7359            // Ignore placeholder lifecycle events emitted before the
7360            // provider has supplied a tool name.
7361            if tool_name.trim().is_empty() {
7362                return;
7363            }
7364            if tool_name == "bash" {
7365                // Bash streams into a dedicated BashExecutionComponent (command
7366                // header + live preview + exit/truncation status) rather than a
7367                // generic ToolExecutionComponent. The command comes from the
7368                // `command` field of the bash tool args.
7369                let command = args
7370                    .get("command")
7371                    .and_then(|v| v.as_str())
7372                    .unwrap_or("")
7373                    .to_string();
7374                // Bash arguments can still be `{}` when the lifecycle event
7375                // races the streamed tool-call argument finalization. Defer
7376                // the panel until a later start event carries the command.
7377                if command.trim().is_empty() {
7378                    return;
7379                }
7380                let mut bash_map = state.bash_components.lock().unwrap();
7381                if let Some(existing) = bash_map.get(&tool_call_id) {
7382                    // A ToolExecutionUpdate already created the panel (fast
7383                    // command — Update can arrive before Start); backfill the
7384                    // command header instead of adding a SECOND panel, which
7385                    // used to stack an empty "$ " box above the real one.
7386                    existing.set_command(&command);
7387                } else {
7388                    let comp = Arc::new(BashExecutionComponent::new(command));
7389                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7390                    chat.add_child(comp.clone());
7391                    bash_map.insert(tool_call_id.clone(), comp);
7392                }
7393            } else {
7394                let _comp = {
7395                    let mut tools = state.tool_components.lock().unwrap();
7396                    if let Some(existing) = tools.get(&tool_call_id) {
7397                        if is_ask_user_tool(&tool_name) {
7398                            existing.set_display_title("ASK USER");
7399                            existing.set_args(&ask_user_args_display(&args));
7400                        } else {
7401                            existing.set_args(&args.to_string());
7402                        }
7403                        existing.clone()
7404                    } else {
7405                        let display_args = if is_ask_user_tool(&tool_name) {
7406                            ask_user_args_display(&args)
7407                        } else {
7408                            args.to_string()
7409                        };
7410                        let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7411                        if is_ask_user_tool(&tool_name) {
7412                            comp.set_display_title("ASK USER");
7413                        }
7414                        comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7415                        // A `read` of a SKILL.md renders as native Pi's
7416                        // `[skill] <name>` invocation box (custom-message
7417                        // background, collapsed to one line, Ctrl+O expands the
7418                        // skill markdown) instead of a generic READ tool panel.
7419                        if let Some(skill) = skill_tool_name(&tool_name, &args) {
7420                            comp.set_skill_name(skill);
7421                        }
7422                        comp.set_running();
7423                        chat.add_child(comp.clone());
7424                        tools.insert(tool_call_id.clone(), comp.clone());
7425                        comp
7426                    }
7427                };
7428            }
7429            state.sync_working_loader_with_bash();
7430            tui.request_render(false);
7431        }
7432
7433        AgentEvent::ToolExecutionUpdate {
7434            tool_call_id,
7435            tool_name,
7436            args,
7437            partial_result,
7438        } => {
7439            if tool_name.trim().is_empty() {
7440                return;
7441            }
7442            let partial_text = if is_ask_user_tool(&tool_name) {
7443                ask_user_progress_text(&args, &partial_result)
7444            } else {
7445                tool_result_text(&partial_result)
7446            };
7447            let has_partial_payload = tool_update_has_payload(&partial_text, &partial_result);
7448            if tool_name == "bash" {
7449                // Append the streamed chunk to the bash component's preview.
7450                // RAW text (no single-line collapsing) — the old
7451                // `summarize_tool_result` folded every newline into a `⏎`
7452                // glyph, cramming e.g. `ls -la`'s listing onto one line.
7453                let chunk = partial_text;
7454                if let Some(bash) = state.bash_components.lock().unwrap().get(&tool_call_id) {
7455                    // Lifecycle updates can contain a more complete args object
7456                    // than the snapshot that created this running panel.
7457                    if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
7458                        bash.set_command(command);
7459                    }
7460                    if has_partial_payload {
7461                        bash.append_output(&chunk);
7462                    }
7463                } else if has_partial_payload {
7464                    // ToolExecutionStart is emitted before a tool can run.
7465                    // Ignore an out-of-order partial until that event gives us
7466                    // the real command, rather than showing a spinner above an
7467                    // empty `$ ` header. Normal updates are handled by the
7468                    // component created in ToolExecutionStart.
7469                }
7470            } else if let Some(comp) = state.tool_components.lock().unwrap().get(&tool_call_id) {
7471                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7472                    comp.set_skill_name(skill);
7473                }
7474                if is_ask_user_tool(&tool_name) {
7475                    comp.set_display_title("ASK USER");
7476                    comp.set_args(&ask_user_args_display(&args));
7477                } else if args != serde_json::Value::Null && args != serde_json::json!({}) {
7478                    comp.set_args(&args.to_string());
7479                }
7480                // Raw multi-line text — read/ls-style tools must show their
7481                // full content, not the single-line ⏎-folded summary.
7482                if has_partial_payload {
7483                    comp.set_result(&partial_text, false);
7484                    apply_edit_diff(comp, &tool_name, &partial_result.details, &tui);
7485                }
7486            } else if has_partial_payload {
7487                // No component yet — create a running one so the partial shows.
7488                // Empty callbacks are common before ToolExecutionStart; wait
7489                // for Start so the first panel has the real arguments instead
7490                // of an empty `TOOLS` box.
7491                let display_args = if is_ask_user_tool(&tool_name) {
7492                    ask_user_args_display(&args)
7493                } else {
7494                    args.to_string()
7495                };
7496                let comp = Arc::new(ToolExecutionComponent::new(&tool_name, &display_args));
7497                if is_ask_user_tool(&tool_name) {
7498                    comp.set_display_title("ASK USER");
7499                }
7500                comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7501                if let Some(skill) = skill_tool_name(&tool_name, &args) {
7502                    comp.set_skill_name(skill);
7503                }
7504                comp.set_running();
7505                comp.set_result(&partial_text, false);
7506                apply_edit_diff(&comp, &tool_name, &partial_result.details, &tui);
7507                chat.add_child(comp.clone());
7508                state
7509                    .tool_components
7510                    .lock()
7511                    .unwrap()
7512                    .insert(tool_call_id.clone(), comp.clone());
7513            }
7514            state.sync_working_loader_with_bash();
7515            tui.request_render(false);
7516        }
7517
7518        AgentEvent::ToolExecutionEnd {
7519            tool_call_id,
7520            tool_name,
7521            result,
7522            is_error,
7523        } => {
7524            if tool_name.trim().is_empty() {
7525                return;
7526            }
7527            if tool_name == "bash" {
7528                let bash = state.bash_components.lock().unwrap().remove(&tool_call_id);
7529                if let Some(bash) = bash {
7530                    finalize_bash(&bash, &result, is_error);
7531                } else {
7532                    // Bash ended without a Start/Update — render a finalized
7533                    // component directly from the result text.
7534                    let command = result
7535                        .details
7536                        .get("command")
7537                        .and_then(|v| v.as_str())
7538                        .unwrap_or("")
7539                        .to_string();
7540                    if command.trim().is_empty() && tool_result_text(&result).trim().is_empty() {
7541                        state.sync_working_loader_with_bash();
7542                        // Edge case: empty bash result. Still render immediately
7543                        // to clear the panel state for the user.
7544                        tui.render_now(false);
7545                        return;
7546                    }
7547                    let comp = Arc::new(BashExecutionComponent::new(command));
7548                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7549                    comp.append_output(&tool_result_text(&result));
7550                    finalize_bash(&comp, &result, is_error);
7551                    chat.add_child(comp);
7552                }
7553            } else {
7554                let comp = state.tool_components.lock().unwrap().remove(&tool_call_id);
7555                if let Some(comp) = comp {
7556                    let result_text = if is_ask_user_tool(&tool_name) {
7557                        ask_user_result_text(&result)
7558                    } else {
7559                        tool_result_text(&result)
7560                    };
7561                    comp.set_result(&result_text, is_error);
7562                    if !is_ask_user_tool(&tool_name) {
7563                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7564                    }
7565                } else {
7566                    // Tool ended without a Start/Update (e.g. a very fast tool):
7567                    // render a finalized component directly.
7568                    let comp = Arc::new(ToolExecutionComponent::new(
7569                        &tool_name,
7570                        &if is_ask_user_tool(&tool_name) {
7571                            ask_user_args_display(&serde_json::Value::Null)
7572                        } else {
7573                            "".to_string()
7574                        },
7575                    ));
7576                    if is_ask_user_tool(&tool_name) {
7577                        comp.set_display_title("ASK USER");
7578                    }
7579                    comp.set_expanded(*state.tool_outputs_expanded.lock().unwrap());
7580                    let result_text = if is_ask_user_tool(&tool_name) {
7581                        ask_user_result_text(&result)
7582                    } else {
7583                        tool_result_text(&result)
7584                    };
7585                    comp.set_result(&result_text, is_error);
7586                    if !is_ask_user_tool(&tool_name) {
7587                        apply_edit_diff(&comp, &tool_name, &result.details, &tui);
7588                    }
7589                    chat.add_child(comp.clone());
7590                }
7591            }
7592            state.sync_working_loader_with_bash();
7593            // Bash tool completion is user-visible: immediate render ensures the
7594            // result is displayed even when the scheduler thread is busy or the
7595            // throttle window has not elapsed. This single immediate frame does
7596            // not regress the streaming-output throttle that keeps CPU/GPU use
7597            // low (bash completions are discrete events, not per-token bursts).
7598            tui.render_now(false);
7599        }
7600    }
7601}
7602
7603/// Return the diagnostic carried by a failed or aborted provider request.
7604/// Providers may omit `error_message`; keep a stable fallback so a terminal
7605/// request failure can never render as an empty transcript turn.
7606fn assistant_error_text(message: &rpi_ai::AssistantMessage) -> Option<String> {
7607    let fallback = match message.stop_reason {
7608        rpi_ai::StopReason::Error => "Provider request failed.",
7609        rpi_ai::StopReason::Aborted => "Request aborted.",
7610        _ => return None,
7611    };
7612    Some(
7613        message
7614            .error_message
7615            .as_deref()
7616            .filter(|text| !text.trim().is_empty())
7617            .unwrap_or(fallback)
7618            .to_string(),
7619    )
7620}
7621
7622/// Extract `BashToolDetails` (`truncation`, `full_output_path`) from a bash
7623/// tool result and mark the component complete. Mirrors the TS bash finalize
7624/// path; only the fields `BashExecutionComponent` needs are read.
7625fn finalize_bash(
7626    comp: &Arc<BashExecutionComponent>,
7627    result: &rpi_agent::AgentToolResult,
7628    is_error: bool,
7629) {
7630    // The exit code isn't in details directly (TS carries it elsewhere); use
7631    // `is_error` as the error signal and 0/1 as a best-effort exit code.
7632    let exit_code = if is_error { Some(1) } else { Some(0) };
7633    let truncated = result
7634        .details
7635        .get("truncation")
7636        .and_then(|t| t.get("truncated"))
7637        .and_then(|v| v.as_bool())
7638        .unwrap_or(false);
7639    let full_output_path = result
7640        .details
7641        .get("full_output_path")
7642        .and_then(|v| v.as_str())
7643        .map(|s| s.to_string());
7644    let truncation = BashTruncation {
7645        truncated,
7646        full_output_path,
7647    };
7648    // Some tool backends attach the authoritative command to result details.
7649    // Backfill it before completing so this is a fallback, never the first
7650    // opportunity for the UI to show the whole command.
7651    if let Some(command) = result.details.get("command").and_then(|v| v.as_str()) {
7652        comp.set_command(command);
7653    }
7654    let cancelled = false; // cancellation surfaces via Abort/AgentEnd, not a bash detail
7655    comp.set_complete(exit_code, cancelled, truncation);
7656}
7657
7658/// If `tool_name` is an editing tool (`edit`) whose `details.diff` carries a
7659/// display-diff string, render it with colors and attach to the component so
7660/// the changes show in the transcript. `write` has no diff (details: Null) and
7661/// stays a plain summary.
7662fn apply_edit_diff(
7663    comp: &Arc<ToolExecutionComponent>,
7664    tool_name: &str,
7665    details: &serde_json::Value,
7666    tui: &Arc<TuiAltScreen>,
7667) {
7668    if tool_name != "edit" {
7669        return;
7670    }
7671    let Some(diff_text) = details.get("diff").and_then(|v| v.as_str()) else {
7672        return;
7673    };
7674    if diff_text.is_empty() {
7675        return;
7676    }
7677    let width = tui.width();
7678    let lines = render_diff(diff_text, width);
7679    comp.set_diff(lines);
7680}
7681
7682/// The skill name when `tool_name` is a `read` of a `SKILL.md` file, else
7683/// `None`. The name is the `SKILL.md` parent directory's basename (matching
7684/// native Pi's skill-file convention). Ordinary markdown/document reads
7685/// return `None` and remain regular `READ` tool panels.
7686fn skill_tool_name(tool_name: &str, args: &serde_json::Value) -> Option<String> {
7687    if tool_name != "read" {
7688        return None;
7689    }
7690    let path = args.get("path").and_then(|value| value.as_str())?;
7691    let normalized = path.replace('\\', "/");
7692    let file_name = normalized.rsplit('/').next()?;
7693    if !file_name.eq_ignore_ascii_case("SKILL.md") {
7694        return None;
7695    }
7696    normalized
7697        .trim_end_matches('/')
7698        .rsplit('/')
7699        .nth(1)
7700        .filter(|name| !name.is_empty())
7701        .map(str::to_string)
7702}
7703
7704/// Render an `AgentToolResult` as a single-line summary for the
7705/// `ToolExecutionComponent` (joins text blocks; truncates for compactness).
7706fn summarize_tool_result(result: &rpi_agent::AgentToolResult) -> String {
7707    use rpi_agent::TextContentOrImage;
7708    let mut parts: Vec<String> = Vec::new();
7709    for c in &result.content {
7710        if let TextContentOrImage::Text(t) = c {
7711            parts.push(t.text.clone());
7712        }
7713    }
7714    let joined = parts.join("\n");
7715    // Keep the tool line compact: collapse to a single line, trim length.
7716    let one_line: String = joined.lines().collect::<Vec<_>>().join(" ⏎ ");
7717    if one_line.chars().count() > 200 {
7718        let truncated: String = one_line.chars().take(200).collect();
7719        format!("{truncated}…")
7720    } else {
7721        one_line
7722    }
7723}
7724
7725/// The raw multi-line text of a tool result (no single-line collapsing). The
7726/// bash panel needs the original line structure — the old path fed it through
7727/// [`summarize_tool_result`], which folded every newline into a `⏎` glyph and
7728/// crammed e.g. `ls -la`'s whole listing onto one line.
7729fn tool_result_text(result: &rpi_agent::AgentToolResult) -> String {
7730    use rpi_agent::TextContentOrImage;
7731    let mut parts: Vec<String> = Vec::new();
7732    for c in &result.content {
7733        if let TextContentOrImage::Text(t) = c {
7734            parts.push(t.text.clone());
7735        }
7736    }
7737    parts.join("\n")
7738}
7739
7740/// Empty progress callbacks are valid (notably before a tool's start event),
7741/// but they do not contain anything useful to render. Defer those callbacks so
7742/// the first tool panel is created from `ToolExecutionStart` with real args.
7743fn tool_update_has_payload(text: &str, result: &rpi_agent::AgentToolResult) -> bool {
7744    !text.trim().is_empty() || !result.details.is_null()
7745}
7746
7747/// Tool names rendered through the dedicated `ASK USER` panel instead of a
7748/// generic tool box. Native `ask_user` plus the JS `ask_user_question` alias.
7749fn is_ask_user_tool(tool_name: &str) -> bool {
7750    matches!(tool_name, "ask_user" | "ask_user_question")
7751}
7752
7753/// Render ask_user tool-call ARGUMENTS as a readable question. The raw
7754/// transport JSON must never leak into the transcript.
7755fn ask_user_args_display(args: &serde_json::Value) -> String {
7756    if args.is_null() {
7757        return String::new();
7758    }
7759    if let Some(summary) = args
7760        .get("summary")
7761        .and_then(serde_json::Value::as_str)
7762        .map(str::trim)
7763        .filter(|text| !text.is_empty())
7764    {
7765        return format!("Confirmation requested: {summary}");
7766    }
7767
7768    let mut lines: Vec<String> = Vec::new();
7769    let questions: Vec<&serde_json::Value> = args
7770        .get("questions")
7771        .and_then(serde_json::Value::as_array)
7772        .filter(|items| !items.is_empty())
7773        .map(|items| items.iter().collect())
7774        .unwrap_or_else(|| vec![args]);
7775
7776    for question in questions {
7777        let prompt = question
7778            .get("question")
7779            .and_then(serde_json::Value::as_str)
7780            .or_else(|| {
7781                args.get("question")
7782                    .and_then(serde_json::Value::as_str)
7783            })
7784            .map(str::trim)
7785            .unwrap_or_default();
7786        if prompt.is_empty() {
7787            continue;
7788        }
7789        if let Some(header) = question
7790            .get("header")
7791            .and_then(serde_json::Value::as_str)
7792            .map(str::trim)
7793            .filter(|text| !text.is_empty())
7794        {
7795            lines.push(format!("{header}: {prompt}"));
7796        } else {
7797            lines.push(prompt.to_string());
7798        }
7799        let context = question
7800            .get("context")
7801            .and_then(serde_json::Value::as_str)
7802            .or_else(|| args.get("context").and_then(serde_json::Value::as_str))
7803            .map(str::trim)
7804            .filter(|text| !text.is_empty());
7805        if let Some(context) = context {
7806            lines.push(format!("  {context}"));
7807        }
7808        let options = question
7809            .get("options")
7810            .and_then(serde_json::Value::as_array)
7811            .or_else(|| args.get("options").and_then(serde_json::Value::as_array));
7812        if let Some(options) = options {
7813            let labels: Vec<String> = options
7814                .iter()
7815                .filter_map(|option| {
7816                    option
7817                        .get("title")
7818                        .and_then(serde_json::Value::as_str)
7819                        .or_else(|| option.as_str())
7820                        .map(str::trim)
7821                        .filter(|label| !label.is_empty())
7822                        .map(str::to_string)
7823                })
7824                .collect();
7825            if !labels.is_empty() {
7826                lines.push(format!("Choices: {}", labels.join(", ")));
7827            }
7828        }
7829        if let Some(suggest) = question
7830            .get("suggest")
7831            .and_then(serde_json::Value::as_str)
7832            .or_else(|| args.get("suggest").and_then(serde_json::Value::as_str))
7833            .map(str::trim)
7834            .filter(|text| !text.is_empty())
7835        {
7836            lines.push(format!("Suggestions: {suggest}"));
7837        }
7838    }
7839
7840    lines.join("\n")
7841}
7842
7843/// Progress text while an ask_user tool is pending. Prefer the plugin's own
7844/// readable `content`, otherwise show the question so the panel is not blank.
7845fn ask_user_progress_text(args: &serde_json::Value, result: &rpi_agent::AgentToolResult) -> String {
7846    let text = tool_result_text(result);
7847    if !text.trim().is_empty() {
7848        return text;
7849    }
7850    let args_text = ask_user_args_display(args);
7851    if args_text.trim().is_empty() {
7852        "Waiting for your answer…".to_string()
7853    } else {
7854        args_text
7855    }
7856}
7857
7858/// Final result text for an ask_user tool. The plugin returns a human-readable
7859/// answer summary in `content`; never surface the transport JSON.
7860fn ask_user_result_text(result: &rpi_agent::AgentToolResult) -> String {
7861    let text = tool_result_text(result);
7862    if text.trim().is_empty() {
7863        "Answer recorded.".to_string()
7864    } else {
7865        text
7866    }
7867}
7868
7869// ===========================================================================
7870// Selectors — editor-container swap (TS showSelector pattern)
7871// ===========================================================================
7872
7873/// Swap the `editor_container`'s child (the editor) for a `SelectList`,
7874/// hiding the editor while the selector is open. Records the selector in
7875/// `state.active_selector` so the key loop routes to it.
7876fn open_selector(
7877    state: &Arc<TuiState>,
7878    editor_container: &Arc<Container>,
7879    editor: &Arc<Editor>,
7880    tui: &Arc<TuiAltScreen>,
7881    list: Arc<SelectList>,
7882    kind: SelectorKind,
7883) {
7884    open_selector_with_view(
7885        state,
7886        editor_container,
7887        editor,
7888        tui,
7889        list.clone(),
7890        list,
7891        kind,
7892    );
7893}
7894
7895/// Open a selector with an optional framed view. Native extension selectors
7896/// wrap the list with a title and hint while built-in selectors keep the list
7897/// as the complete view.
7898fn open_selector_with_view<C: Component + 'static>(
7899    state: &Arc<TuiState>,
7900    editor_container: &Arc<Container>,
7901    editor: &Arc<Editor>,
7902    tui: &Arc<TuiAltScreen>,
7903    list: Arc<SelectList>,
7904    view: Arc<C>,
7905    kind: SelectorKind,
7906) {
7907    // Dispatch ui_prompt_start event
7908    dispatch_ui_prompt_event(state);
7909    
7910    // Unfocus the editor so its cursor marker doesn't render behind the list.
7911    editor.set_focused(false);
7912    // A selector replaces the editor slot. Drop stale slash/@file
7913    // suggestions so they cannot reappear after the selector closes.
7914    state.autocomplete_container.clear();
7915    // Swap: clear the container and add the selector view.
7916    editor_container.clear();
7917    editor_container.add_child(view.clone());
7918    *state.active_selector.lock().unwrap() = Some((list, kind));
7919    let focused: Arc<dyn Component> = view;
7920    tui.set_focus(Some(focused));
7921    tui.request_render(false);
7922}
7923
7924/// Restore the editor into the `editor_container` and clear the active
7925/// selector. Called by selector `on_cancel` and the Esc handler.
7926fn close_selector(
7927    state: &Arc<TuiState>,
7928    editor_container: &Arc<Container>,
7929    editor: &Arc<Editor>,
7930    tui: &Arc<TuiAltScreen>,
7931) {
7932    // Dispatch ui_prompt_end event
7933    dispatch_ui_prompt_event_end(state);
7934    
7935    editor_container.clear();
7936    editor_container.add_child(editor.clone());
7937    state.autocomplete_container.clear();
7938    editor.set_focused(true);
7939    *state.active_selector.lock().unwrap() = None;
7940    *state.active_extension_cancel.lock().unwrap() = None;
7941    tui.set_focus(Some(editor.clone()));
7942    tui.request_render(false);
7943}
7944
7945/// Build + open the `/model` selector. Items are the resolved catalog (display
7946/// label = model name; description = id), with the current model marked.
7947/// Selecting applies the model **live** via `lane.set_model` (takes effect on
7948/// the next user message — the in-flight run's config is already snapshotted),
7949/// updates the footer, and notes the next-prompt effect.
7950fn open_model_selector(
7951    state: &Arc<TuiState>,
7952    editor_container: &Arc<Container>,
7953    editor: &Arc<Editor>,
7954    tui: &Arc<TuiAltScreen>,
7955    catalog: &[rpi_ai::Model],
7956    lane: &Arc<dyn AgentLane>,
7957    lane_model_id: &str,
7958    chat: &Arc<Container>,
7959) {
7960    let items = model_selector_items(catalog, lane_model_id);
7961    if items.is_empty() {
7962        add_note_message(
7963            chat,
7964            "No models in the catalog. Use --model at startup to select one.",
7965        );
7966        tui.request_render(false);
7967        return;
7968    }
7969    let list = Arc::new(SelectList::new(items, 10));
7970
7971    // Capture the catalog + lane so the on_select closure can resolve the
7972    // chosen Model and apply it. `on_select` fires on the blocking key thread,
7973    // so the async `set_model` runs on a spawned task (matches Ctrl+M).
7974    let catalog_arc = catalog.to_vec();
7975    let state_sel = state.clone();
7976    let ec_sel = editor_container.clone();
7977    let editor_sel = editor.clone();
7978    let tui_sel = tui.clone();
7979    let chat_sel = chat.clone();
7980    let lane_sel = lane.clone();
7981    list.on_select(Arc::new(move |item| {
7982        let Some(model) = catalog_arc.iter().find(|m| m.id == item.value).cloned() else {
7983            add_note_message(
7984                &chat_sel,
7985                &format!("Model {} not found in catalog.", item.label),
7986            );
7987            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
7988            return;
7989        };
7990        state_sel.set_current_model(&model);
7991        let lane = lane_sel.clone();
7992        tokio::spawn(async move {
7993            let _ = lane.set_model(model).await;
7994        });
7995        add_note_message(
7996            &chat_sel,
7997            &format!(
7998                "Model set to {} — applies to the next message.",
7999                short_model_name(&item.value)
8000            ),
8001        );
8002        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8003    }));
8004    let state_cancel = state.clone();
8005    let ec_cancel = editor_container.clone();
8006    let editor_cancel = editor.clone();
8007    let tui_cancel = tui.clone();
8008    list.on_cancel(Arc::new(move || {
8009        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8010    }));
8011
8012    open_selector(
8013        state,
8014        editor_container,
8015        editor,
8016        tui,
8017        list,
8018        SelectorKind::Model,
8019    );
8020}
8021
8022/// Cycle to the next catalog entry after `current_id`, wrapping to the first.
8023/// Returns `None` only when the catalog is empty or the current id isn't
8024/// found (in which case the first entry is returned — a no-op if it IS the
8025/// current). Used by the Ctrl+M model-cycle hotkey.
8026fn cycle_next_model(catalog: &[rpi_ai::Model], current_id: &str) -> Option<rpi_ai::Model> {
8027    if catalog.is_empty() {
8028        return None;
8029    }
8030    let idx = catalog
8031        .iter()
8032        .position(|m| m.id.eq_ignore_ascii_case(current_id));
8033    match idx {
8034        Some(i) => {
8035            let next = (i + 1) % catalog.len();
8036            Some(catalog[next].clone())
8037        }
8038        None => Some(catalog[0].clone()),
8039    }
8040}
8041
8042/// Build + open the `/session` selector. Lists JSONL session files under the
8043/// default session dir (`<cwd>/.rpi/sessions`, with legacy `.pi/sessions`
8044/// fallback). Selecting reports "restore not
8045/// implemented in v1" (existing constraint) but shows the list for
8046/// discoverability.
8047fn open_session_selector(
8048    state: &Arc<TuiState>,
8049    editor_container: &Arc<Container>,
8050    editor: &Arc<Editor>,
8051    tui: &Arc<TuiAltScreen>,
8052    cwd: &std::path::Path,
8053    tx: &mpsc::UnboundedSender<TuiMessage>,
8054) {
8055    let dir = crate::session::default_session_dir(cwd);
8056    let mut items: Vec<SelectItem> = Vec::new();
8057    if let Ok(entries) = std::fs::read_dir(&dir) {
8058        for entry in entries.flatten() {
8059            let path = entry.path();
8060            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
8061                continue;
8062            }
8063            let stem = path
8064                .file_stem()
8065                .and_then(|s| s.to_str())
8066                .unwrap_or("(unnamed)")
8067                .to_string();
8068            let display = path
8069                .file_name()
8070                .and_then(|s| s.to_str())
8071                .unwrap_or(&stem)
8072                .to_string();
8073            items.push(SelectItem::new(&stem, &display));
8074        }
8075    }
8076    if items.is_empty() {
8077        add_note_message(
8078            &state.chat_container,
8079            "No saved sessions found. Sessions are created automatically in interactive mode.",
8080        );
8081        tui.request_render(false);
8082        return;
8083    }
8084    let list = Arc::new(SelectList::new(items, 10));
8085
8086    let state_sel = state.clone();
8087    let ec_sel = editor_container.clone();
8088    let editor_sel = editor.clone();
8089    let tui_sel = tui.clone();
8090    let tx_sel = tx.clone();
8091    list.on_select(Arc::new(move |item| {
8092        // Close the selector first, then ask the async loop to hot-switch:
8093        // opening the session file + swapping the harness backing is async
8094        // (repo list/open) and must not run on the blocking key thread.
8095        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8096        let _ = tx_sel.send(TuiMessage::SwitchSession(item.value.clone()));
8097    }));
8098    let state_cancel = state.clone();
8099    let ec_cancel = editor_container.clone();
8100    let editor_cancel = editor.clone();
8101    let tui_cancel = tui.clone();
8102    list.on_cancel(Arc::new(move || {
8103        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8104    }));
8105
8106    open_selector(
8107        state,
8108        editor_container,
8109        editor,
8110        tui,
8111        list,
8112        SelectorKind::Session,
8113    );
8114}
8115
8116fn custom_entry_display_text(
8117    custom_type: &str,
8118    data: Option<&serde_json::Value>,
8119) -> Option<String> {
8120    let data = data?;
8121    let text = data
8122        .get("summary")
8123        .or_else(|| data.get("text"))
8124        .or_else(|| data.get("output"))
8125        .and_then(|value| value.as_str())
8126        .filter(|value| !value.trim().is_empty())?;
8127    let label = match custom_type {
8128        "compactionSummary" => "Compaction summary",
8129        "branchSummary" => "Branch summary",
8130        "bashExecution" => "Command output",
8131        other => other,
8132    };
8133    Some(format!("{label}: {text}"))
8134}
8135
8136/// Open a selector for the current session's persisted entry tree. Selecting a
8137/// message moves the main lane leaf to that entry, then the caller reloads the
8138/// visible branch from durable storage.
8139async fn open_tree_selector(
8140    harness: &AgentHarness,
8141    state: &Arc<TuiState>,
8142    editor_container: &Arc<Container>,
8143    editor: &Arc<Editor>,
8144    tui: &Arc<TuiAltScreen>,
8145    chat: &Arc<Container>,
8146    tx: &mpsc::UnboundedSender<TuiMessage>,
8147) {
8148    let entries = match harness
8149        .session()
8150        .view("main")
8151        .find_entries(&EntryQuery {
8152            order: Some(EntryOrder::OldestFirst),
8153            ..Default::default()
8154        })
8155        .await
8156    {
8157        Ok(entries) => entries,
8158        Err(error) => {
8159            add_error_message(chat, &format!("Could not read session tree: {error}"));
8160            tui.request_render(false);
8161            return;
8162        }
8163    };
8164    let current = harness.session().get_leaf_id().await.ok().flatten();
8165    let items: Vec<SelectItem> = entries
8166        .iter()
8167        .map(|entry| {
8168            let marker = if current.as_deref() == Some(entry.id()) {
8169                " (current)"
8170            } else {
8171                ""
8172            };
8173            SelectItem::new(
8174                entry.id(),
8175                &format!("{} #{}{}", entry.entry_type(), entry.seq(), marker),
8176            )
8177            .with_description(&entry.id()[..entry.id().len().min(12)])
8178        })
8179        .collect();
8180    if items.is_empty() {
8181        add_note_message(chat, "The current session has no entries to navigate.");
8182        tui.request_render(false);
8183        return;
8184    }
8185    let list = Arc::new(SelectList::new(items, 12));
8186    let state_sel = state.clone();
8187    let ec_sel = editor_container.clone();
8188    let editor_sel = editor.clone();
8189    let tui_sel = tui.clone();
8190    let tx_sel = tx.clone();
8191    list.on_select(Arc::new(move |item| {
8192        let _ = tx_sel.send(TuiMessage::NavigateTree(item.value.clone()));
8193        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8194    }));
8195    let state_cancel = state.clone();
8196    let ec_cancel = editor_container.clone();
8197    let editor_cancel = editor.clone();
8198    let tui_cancel = tui.clone();
8199    list.on_cancel(Arc::new(move || {
8200        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8201    }));
8202    open_selector(
8203        state,
8204        editor_container,
8205        editor,
8206        tui,
8207        list,
8208        SelectorKind::Tree,
8209    );
8210}
8211
8212/// Build + open the `/theme` selector. Built-in presets and enabled package
8213/// themes are shown; selecting applies the theme live and re-renders.
8214fn open_theme_selector(
8215    state: &Arc<TuiState>,
8216    editor_container: &Arc<Container>,
8217    editor: &Arc<Editor>,
8218    tui: &Arc<TuiAltScreen>,
8219    cwd: &std::path::Path,
8220    package_resources: &Arc<crate::packages::PackageResources>,
8221) {
8222    let mut items = vec![
8223        SelectItem::new("dark", "Dark").with_description("Default dark theme"),
8224        SelectItem::new("light", "Light").with_description("Light background"),
8225        SelectItem::new("monochrome", "Monochrome").with_description("No color accents"),
8226    ];
8227    if state.themes_enabled {
8228        for path in package_resources.theme_files() {
8229            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
8230                items.push(SelectItem::new(name, name).with_description("Package theme"));
8231            }
8232        }
8233    }
8234    let list = Arc::new(SelectList::new(items, 10));
8235
8236    let state_sel = state.clone();
8237    let ec_sel = editor_container.clone();
8238    let editor_sel = editor.clone();
8239    let tui_sel = tui.clone();
8240    let chat_sel = state.chat_container.clone();
8241    let cwd_sel = cwd.to_path_buf();
8242    let package_resources_sel = package_resources.clone();
8243    list.on_select(Arc::new(move |item| {
8244        let preset = match item.value.as_str() {
8245            "light" => Some(ThemePreset::Light),
8246            "monochrome" => Some(ThemePreset::Monochrome),
8247            "dark" => Some(ThemePreset::Dark),
8248            name => {
8249                if state_sel.themes_enabled {
8250                    if let Ok(custom) = crate::packages::load_theme_with_resources(
8251                        &cwd_sel,
8252                        name,
8253                        &package_resources_sel,
8254                    ) {
8255                        rpi_tui::global_theme_manager().set(custom.clone());
8256                        state_sel.theme_manager.set(custom);
8257                    }
8258                }
8259                add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8260                close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8261                tui_sel.render_now(true);
8262                return;
8263            }
8264        };
8265        let Some(preset) = preset else { return };
8266        apply_theme_preset(preset);
8267        state_sel.theme_manager.apply_preset(preset);
8268        // A quick accent note so the user sees the change registered even if
8269        // the terminal's own colors mask the preset difference.
8270        add_note_message(&chat_sel, &format!("Theme set to {}.", item.label));
8271        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8272        tui_sel.render_now(true);
8273    }));
8274    let state_cancel = state.clone();
8275    let ec_cancel = editor_container.clone();
8276    let editor_cancel = editor.clone();
8277    let tui_cancel = tui.clone();
8278    list.on_cancel(Arc::new(move || {
8279        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8280    }));
8281
8282    open_selector(
8283        state,
8284        editor_container,
8285        editor,
8286        tui,
8287        list,
8288        SelectorKind::Theme,
8289    );
8290}
8291
8292// ===========================================================================
8293// Feasible selectors — /thinking, /tools, /images
8294// ===========================================================================
8295
8296/// One-line descriptions for each thinking level, ported from
8297/// thinking-selector.ts (the TS `getThinkingLevelDescription` table).
8298fn thinking_level_description(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8299    use rpi_ai::types::ThinkingLevel::*;
8300    match level {
8301        Off => "Off — No reasoning",
8302        Minimal => "Minimal — Brief reasoning (~1k tokens)",
8303        Low => "Low — Light reasoning (~1k tokens)",
8304        Medium => "Medium — Moderate reasoning (~80% of max)",
8305        High => "High — Extensive reasoning (~95% of max)",
8306        Xhigh => "Xhigh — Near-maximal reasoning",
8307        Max => "Max — Maximum reasoning",
8308    }
8309}
8310
8311/// The lowercase serialized name of a [`ThinkingLevel`] (matches its
8312/// `#[serde(rename_all = "lowercase")]` form): "off", "minimal", … "max".
8313fn thinking_level_name(level: rpi_ai::types::ThinkingLevel) -> &'static str {
8314    use rpi_ai::types::ThinkingLevel::*;
8315    match level {
8316        Off => "off",
8317        Minimal => "minimal",
8318        Low => "low",
8319        Medium => "medium",
8320        High => "high",
8321        Xhigh => "xhigh",
8322        Max => "max",
8323    }
8324}
8325
8326/// Parse a thinking-level name back to the enum (case-insensitive). Returns
8327/// `None` for an unknown name; used by the `/thinking` selector callback.
8328fn thinking_level_from_name(name: &str) -> Option<rpi_ai::types::ThinkingLevel> {
8329    use rpi_ai::types::ThinkingLevel::*;
8330    match name.to_ascii_lowercase().as_str() {
8331        "off" => Some(Off),
8332        "minimal" => Some(Minimal),
8333        "low" => Some(Low),
8334        "medium" => Some(Medium),
8335        "high" => Some(High),
8336        "xhigh" => Some(Xhigh),
8337        "max" => Some(Max),
8338        _ => None,
8339    }
8340}
8341
8342/// Build + open the `/thinking` selector. Items are the levels the current
8343/// model supports (`Model::supported_thinking_levels`), each with a
8344/// description; the current level (read beforehand via `lane.get_thinking_level`)
8345/// is preselected. Selecting applies it live via `lane.set_thinking_level`.
8346///
8347/// `on_select` fires on the blocking key thread, so it can't await
8348/// `lane.get_thinking_level()` to know the current level — the opener resolves
8349/// it first (best-effort) and preselects; the toggle on_select just applies
8350/// whatever was picked.
8351fn open_thinking_selector(
8352    state: &Arc<TuiState>,
8353    editor_container: &Arc<Container>,
8354    editor: &Arc<Editor>,
8355    tui: &Arc<TuiAltScreen>,
8356    lane: &Arc<dyn AgentLane>,
8357    catalog: &[rpi_ai::Model],
8358    lane_model_id: &str,
8359    chat: &Arc<Container>,
8360) {
8361    // Find the current model in the catalog to read its supported levels. If
8362    // absent, fall back to all levels so the selector still opens.
8363    let model = catalog
8364        .iter()
8365        .find(|m| m.id.eq_ignore_ascii_case(lane_model_id));
8366    let levels: Vec<rpi_ai::types::ThinkingLevel> = model
8367        .map(|m| m.supported_thinking_levels())
8368        .unwrap_or_else(|| {
8369            use rpi_ai::types::ThinkingLevel::*;
8370            vec![Off, Minimal, Low, Medium, High]
8371        });
8372    let mut items: Vec<SelectItem> = Vec::new();
8373    for lvl in &levels {
8374        let name = thinking_level_name(*lvl);
8375        items.push(SelectItem::new(name, name).with_description(thinking_level_description(*lvl)));
8376    }
8377    if items.is_empty() {
8378        add_note_message(chat, "This model has no supported thinking levels.");
8379        tui.request_render(false);
8380        return;
8381    }
8382    let list = Arc::new(SelectList::new(items, 10));
8383
8384    let state_sel = state.clone();
8385    let ec_sel = editor_container.clone();
8386    let editor_sel = editor.clone();
8387    let tui_sel = tui.clone();
8388    let chat_sel = chat.clone();
8389    let lane_sel = lane.clone();
8390    list.on_select(Arc::new(move |item| {
8391        let Some(level) = thinking_level_from_name(&item.value) else {
8392            add_note_message(
8393                &chat_sel,
8394                &format!("Unknown thinking level: {}.", item.label),
8395            );
8396            close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8397            return;
8398        };
8399        let lane = lane_sel.clone();
8400        let footer_sel = state_sel.footer.clone();
8401        tokio::spawn(async move {
8402            let _ = lane.set_thinking_level(level).await;
8403        });
8404        // Reflect the chosen level in the footer's model suffix (pi parity:
8405        // `model • thinking off` / `model • medium`). The shown text for the
8406        // Off level is "off", matching the TS `thinkingLevel === "off"` branch.
8407        footer_sel.set_thinking_level(Some(thinking_level_name(level)));
8408        add_note_message(&chat_sel, &format!("Thinking set to {}.", item.label));
8409        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8410    }));
8411    let state_cancel = state.clone();
8412    let ec_cancel = editor_container.clone();
8413    let editor_cancel = editor.clone();
8414    let tui_cancel = tui.clone();
8415    list.on_cancel(Arc::new(move || {
8416        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8417    }));
8418
8419    open_selector(
8420        state,
8421        editor_container,
8422        editor,
8423        tui,
8424        list,
8425        SelectorKind::Thinking,
8426    );
8427}
8428
8429/// Build + open the `/tools` selector. Lists the 7 builtin tool names; each
8430/// visit reads the live active set via `lane.get_active_tools()` (best-effort,
8431/// resolved synchronously by the opener using `tokio::runtime::Handle` block_on
8432/// — the blocking key thread can't await) and selecting a tool **toggles** it
8433/// on/off via `lane.set_active_tools`. Active tools are marked `(on)`.
8434fn open_tools_selector(
8435    state: &Arc<TuiState>,
8436    editor_container: &Arc<Container>,
8437    editor: &Arc<Editor>,
8438    tui: &Arc<TuiAltScreen>,
8439    lane: &Arc<dyn AgentLane>,
8440    chat: &Arc<Container>,
8441) {
8442    // Best-effort read of the current active set. The opener runs on the async
8443    // runtime (it's called from the main loop's channel dispatch or the submit
8444    // closure that lives on the blocking thread — but `handle.block_on` is safe
8445    // because `get_active_tools` is std-Mutex-backed and finishes quickly).
8446    let mut active = match tokio::runtime::Handle::try_current() {
8447        Ok(h) => h
8448            .block_on(async { lane.get_active_tools().await })
8449            .unwrap_or_default(),
8450        Err(_) => Vec::new(),
8451    };
8452    // An empty active set is the harness sentinel for "all registered tools"
8453    // (the selector only exposes built-ins). Expand it before rendering and
8454    // toggling so the first `/tools` visit does not show every tool as off or
8455    // accidentally reduce the active set to the one item selected.
8456    if active.is_empty() {
8457        active = crate::session::BUILTIN_TOOL_NAMES
8458            .iter()
8459            .map(|name| (*name).to_string())
8460            .collect();
8461    }
8462    let mut items: Vec<SelectItem> = Vec::new();
8463    for name in crate::session::BUILTIN_TOOL_NAMES {
8464        let on = active.iter().any(|a| a == name);
8465        let label = if on {
8466            format!("{name} (on)")
8467        } else {
8468            (*name).to_string()
8469        };
8470        items.push(SelectItem::new(name, &label).with_description("Toggle tool on/off"));
8471    }
8472    let list = Arc::new(SelectList::new(items, 10));
8473
8474    // Capture the active set so on_select can toggle without re-reading.
8475    let active_captured = active.clone();
8476    let state_sel = state.clone();
8477    let ec_sel = editor_container.clone();
8478    let editor_sel = editor.clone();
8479    let tui_sel = tui.clone();
8480    let chat_sel = chat.clone();
8481    let lane_sel = lane.clone();
8482    list.on_select(Arc::new(move |item| {
8483        let mut next = active_captured.clone();
8484        if let Some(pos) = next.iter().position(|a| a == &item.value) {
8485            next.remove(pos);
8486        } else {
8487            next.push(item.value.clone());
8488        }
8489        let on = next.iter().any(|a| a == &item.value);
8490        let lane = lane_sel.clone();
8491        let next_clone = next.clone();
8492        tokio::spawn(async move {
8493            let _ = lane.set_active_tools(next_clone).await;
8494        });
8495        let list_str = if next.is_empty() {
8496            "(none)".to_string()
8497        } else {
8498            next.join(", ")
8499        };
8500        add_note_message(
8501            &chat_sel,
8502            &format!(
8503                "{} {} — active tools: {}",
8504                item.value,
8505                if on { "enabled" } else { "disabled" },
8506                list_str
8507            ),
8508        );
8509        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8510    }));
8511    let state_cancel = state.clone();
8512    let ec_cancel = editor_container.clone();
8513    let editor_cancel = editor.clone();
8514    let tui_cancel = tui.clone();
8515    list.on_cancel(Arc::new(move || {
8516        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8517    }));
8518
8519    open_selector(
8520        state,
8521        editor_container,
8522        editor,
8523        tui,
8524        list,
8525        SelectorKind::Tools,
8526    );
8527}
8528
8529/// Build + open the `/images` selector (Yes/No). Stores the choice in
8530/// `state.show_images` and notes it. Image wiring is minimal this pass — the
8531/// flag is consulted where images would be shown and echoed back here.
8532fn open_images_selector(
8533    state: &Arc<TuiState>,
8534    editor_container: &Arc<Container>,
8535    editor: &Arc<Editor>,
8536    tui: &Arc<TuiAltScreen>,
8537    chat: &Arc<Container>,
8538) {
8539    let current = *state.show_images.lock().unwrap();
8540    let items = vec![
8541        SelectItem::new("yes", "Yes").with_description(if current {
8542            "Inline images (current)"
8543        } else {
8544            "Inline images"
8545        }),
8546        SelectItem::new("no", "No").with_description(if current {
8547            "Placeholder only"
8548        } else {
8549            "Placeholder only (current)"
8550        }),
8551    ];
8552    let list = Arc::new(SelectList::new(items, 5));
8553
8554    let state_sel = state.clone();
8555    let ec_sel = editor_container.clone();
8556    let editor_sel = editor.clone();
8557    let tui_sel = tui.clone();
8558    let chat_sel = chat.clone();
8559    list.on_select(Arc::new(move |item| {
8560        let on = item.value == "yes";
8561        *state_sel.show_images.lock().unwrap() = on;
8562        add_note_message(
8563            &chat_sel,
8564            &format!("Inline images {}.", if on { "enabled" } else { "disabled" }),
8565        );
8566        close_selector(&state_sel, &ec_sel, &editor_sel, &tui_sel);
8567    }));
8568    let state_cancel = state.clone();
8569    let ec_cancel = editor_container.clone();
8570    let editor_cancel = editor.clone();
8571    let tui_cancel = tui.clone();
8572    list.on_cancel(Arc::new(move || {
8573        close_selector(&state_cancel, &ec_cancel, &editor_cancel, &tui_cancel);
8574    }));
8575
8576    open_selector(
8577        state,
8578        editor_container,
8579        editor,
8580        tui,
8581        list,
8582        SelectorKind::Images,
8583    );
8584}
8585
8586// ===========================================================================
8587// Autocomplete
8588// ===========================================================================
8589
8590/// Refresh the autocomplete suggestion list from the current editor text +
8591/// cursor. Renders the suggestions into `autocomplete_container` (above the
8592/// editor) or clears it when there are none.
8593fn refresh_autocomplete(state: &Arc<TuiState>, editor: &Arc<Editor>) {
8594    let text = editor.get_text();
8595    let cursor = editor_cursor_offset(editor, &text);
8596    let suggestions = state.autocomplete.get_suggestions(&text, cursor);
8597    render_autocomplete(state, suggestions);
8598}
8599
8600/// Convert the editor's logical `(row, byte-column)` caret into the absolute
8601/// byte offset expected by autocomplete providers.
8602fn editor_cursor_offset(editor: &Editor, text: &str) -> usize {
8603    let (row, col) = editor.cursor_position();
8604    let mut offset = 0;
8605    for (index, line) in text.split('\n').enumerate() {
8606        if index == row {
8607            return (offset + col.min(line.len())).min(text.len());
8608        }
8609        offset = offset.saturating_add(line.len() + 1);
8610    }
8611    text.len()
8612}
8613
8614/// Restore an editor caret from an absolute byte offset after autocomplete
8615/// replaces a span in a multi-line draft.
8616fn set_editor_cursor_offset(editor: &Editor, text: &str, offset: usize) {
8617    let offset = offset.min(text.len());
8618    let mut consumed = 0;
8619    for (row, line) in text.split('\n').enumerate() {
8620        let end = consumed + line.len();
8621        if offset <= end {
8622            editor.set_cursor(row, offset - consumed);
8623            return;
8624        }
8625        consumed = end + 1;
8626    }
8627    let last_row = text.bytes().filter(|byte| *byte == b'\n').count();
8628    editor.set_cursor(
8629        last_row,
8630        text.rsplit('\n').next().map(str::len).unwrap_or(0),
8631    );
8632}
8633
8634/// Render (or clear) the autocomplete suggestion list into the container.
8635fn render_autocomplete(state: &Arc<TuiState>, suggestions: Option<AutocompleteSuggestions>) {
8636    state.autocomplete_container.clear();
8637    let Some(sugg) = suggestions else {
8638        return;
8639    };
8640    if sugg.items.is_empty() {
8641        return;
8642    }
8643    // Build a compact list: top item marked with `→`, rest with `  `.
8644    // Cap the list so the dock doesn't swallow the transcript.
8645    let accent = state.theme_manager.get().colors.accent;
8646    let muted = state.theme_manager.get().colors.muted;
8647    for (i, item) in sugg
8648        .items
8649        .iter()
8650        .take(state.autocomplete_max_visible)
8651        .enumerate()
8652    {
8653        let prefix = if i == 0 { "→ " } else { "  " };
8654        let label = item.display_text();
8655        let line = if i == 0 {
8656            format!(
8657                "{prefix}{} {}",
8658                accent.fg(label),
8659                muted.fg(item.description.as_deref().unwrap_or(""))
8660            )
8661        } else {
8662            format!(
8663                "{prefix}{} {}",
8664                muted.fg(label),
8665                muted.fg(item.description.as_deref().unwrap_or(""))
8666            )
8667        };
8668        state
8669            .autocomplete_container
8670            .add_child(Arc::new(Text::new(line, 1, 0)));
8671    }
8672}
8673
8674/// Accept the top autocomplete suggestion: replace `text[start..end]` with the
8675/// suggestion text, reposition the caret, and clear the suggestion list.
8676/// Returns `true` if a suggestion was accepted.
8677fn accept_top_suggestion(state: &Arc<TuiState>, editor: &Arc<Editor>) -> bool {
8678    let text = editor.get_text();
8679    let cursor = editor_cursor_offset(editor, &text);
8680    let Some(sugg) = state.autocomplete.get_suggestions(&text, cursor) else {
8681        return false;
8682    };
8683    let Some(top) = sugg.items.first() else {
8684        return false;
8685    };
8686    // Replace the [start, end) span with the suggestion text. `start`/`end`
8687    // are byte offsets emitted by the providers on char boundaries, so the
8688    // `text[..start]` / `text[end..]` slices are sound for multibyte input.
8689    let start = sugg.start.min(text.len());
8690    let end = sugg.end.min(text.len());
8691    let mut replaced = String::with_capacity(text.len() + top.text.len());
8692    replaced.push_str(&text[..start]);
8693    replaced.push_str(&top.text);
8694    // Keep the text AFTER the replaced span (mid-line completion: replacing
8695    // `[start, end)` must not drop the rest of the line).
8696    replaced.push_str(&text[end..]);
8697    if top.insert_space && !replaced.ends_with('/') {
8698        replaced.push(' ');
8699    }
8700    // New caret position: after the inserted text (byte offset; the editor
8701    // snaps `set_cursor` to a char boundary as a safety net).
8702    let new_cursor = replaced.len().min(
8703        start
8704            + top.text.len()
8705            + if top.insert_space && !top.text.ends_with('/') {
8706                1
8707            } else {
8708                0
8709            },
8710    );
8711    editor.set_text(&replaced);
8712    set_editor_cursor_offset(editor, &replaced, new_cursor);
8713    state.autocomplete_container.clear();
8714    true
8715}
8716
8717// ===========================================================================
8718// Transcript message helpers
8719// ===========================================================================
8720
8721/// Add the welcome header to the chat container.
8722fn add_welcome_message(container: &Arc<Container>) {
8723    add_welcome_message_with_capabilities(container, &[], &[]);
8724}
8725
8726/// Add the startup welcome header and a compact snapshot of active tools and
8727/// discovered skills. The snapshot reflects the harness configuration used by
8728/// the first turn, including tools contributed by extensions.
8729fn add_welcome_message_with_capabilities(
8730    container: &Arc<Container>,
8731    active_tools: &[String],
8732    skills: &[String],
8733) {
8734    let c = current_theme().colors;
8735    // Accent logotype + a dim tagline, separated from the rest by a thin
8736    // themed rule. Plain `Text("rpi interactive TUI")` was visually identical
8737    // to the body text, so the header didn't read as a header.
8738    let title = format!(
8739        "{} {}",
8740        c.accent.fg(&tui_bold("rpi")),
8741        c.muted.fg("interactive TUI")
8742    );
8743    container.add_child(Arc::new(Text::new(title, 1, 0)));
8744    container.add_child(Arc::new(Spacer::new(1)));
8745    container.add_child(Arc::new(Text::new(
8746        c.dim.fg("Type your message and press Enter to send."),
8747        1,
8748        0,
8749    )));
8750    let hint = c
8751        .dim
8752        .fg("Enter send · Shift+Enter newline · Ctrl+C abort · Esc abort · /help");
8753    container.add_child(Arc::new(Text::new(hint, 1, 0)));
8754    container.add_child(Arc::new(Spacer::new(1)));
8755    container.add_child(Arc::new(Text::new(
8756        welcome_capability_line("Tools", active_tools),
8757        1,
8758        0,
8759    )));
8760    container.add_child(Arc::new(Text::new(
8761        welcome_capability_line("Skills", skills),
8762        1,
8763        0,
8764    )));
8765    container.add_child(Arc::new(DynamicBorder::new()));
8766}
8767
8768/// Append update notices inside the live transcript. Fullscreen mode clears
8769/// pre-TUI stdout/stderr, so update state must be represented by components.
8770fn add_update_notices(container: &Arc<Container>, report: &crate::updates::UpdateReport) {
8771    let colors = current_theme().colors;
8772    let group = Arc::new(Container::new());
8773
8774    for warning in &report.warnings {
8775        let body = format!(
8776            "{}\n{} {}{}",
8777            colors.error.fg(&warning.message),
8778            colors.muted.fg("Run"),
8779            colors.accent.fg(&warning.command),
8780            colors.muted.fg(" to retry.")
8781        );
8782        add_update_panel(&group, "Update Failed", &body, colors.error);
8783    }
8784
8785    if let Some(notice) = report.notices.iter().find(|notice| notice.name == "rpi") {
8786        let body = format!(
8787            "{} {}{}",
8788            colors
8789                .muted
8790                .fg(&format!("New version {} is available. Run", notice.latest)),
8791            colors.accent.fg(&notice.command),
8792            colors.muted.fg(".")
8793        );
8794        add_update_panel(&group, "Update Available", &body, colors.warning);
8795    }
8796
8797    let package_notices = report
8798        .notices
8799        .iter()
8800        .filter(|notice| notice.name != "rpi")
8801        .collect::<Vec<_>>();
8802    if !package_notices.is_empty() {
8803        let command = package_notices[0].command.as_str();
8804        let mut lines = vec![format!(
8805            "{} {}{}",
8806            colors.muted.fg("Package updates are available. Run"),
8807            colors.accent.fg(command),
8808            colors.muted.fg(".")
8809        )];
8810        lines.push(colors.muted.fg("Packages:"));
8811        lines.extend(
8812            package_notices
8813                .into_iter()
8814                .map(|notice| format!("- {} {} -> {}", notice.name, notice.current, notice.latest)),
8815        );
8816        add_update_panel(
8817            &group,
8818            "Package Updates Available",
8819            &lines.join("\n"),
8820            colors.warning,
8821        );
8822    }
8823
8824    // Other transcript producers append concurrently. Add the fully built
8825    // group in one operation so card borders and content cannot interleave
8826    // with user, assistant, tool, or extension messages.
8827    if group.child_count() > 0 {
8828        container.add_child(group);
8829    }
8830}
8831
8832fn add_update_panel(container: &Arc<Container>, title: &str, body: &str, color: rpi_tui::Color) {
8833    container.add_child(Arc::new(Spacer::new(1)));
8834    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8835    container.add_child(Arc::new(Text::new(
8836        format!("{}\n{body}", color.fg(&tui_bold(title))),
8837        1,
8838        0,
8839    )));
8840    container.add_child(Arc::new(DynamicBorder::with_color(color)));
8841}
8842
8843fn welcome_capability_line(label: &str, names: &[String]) -> String {
8844    let c = current_theme().colors;
8845    let value = if names.is_empty() {
8846        "none".to_string()
8847    } else {
8848        names.join(" · ")
8849    };
8850    format!(
8851        "{} {}",
8852        c.accent.fg(&format!("{label} ({})", names.len())),
8853        c.muted.fg(&value)
8854    )
8855}
8856
8857/// Add the `/help` command listing to the chat container.
8858fn add_help_message(container: &Arc<Container>) {
8859    let c = current_theme().colors;
8860    // Section header + a thin themed rule, then a two-column command table:
8861    // `cmd` in accent, `— desc` in muted. The old single-space layout made
8862    // the description column wander depending on command length.
8863    container.add_child(Arc::new(Text::new(
8864        c.md_heading.fg(&tui_bold("📚 Available Commands")),
8865        1,
8866        0,
8867    )));
8868    container.add_child(Arc::new(Spacer::new(1)));
8869
8870    let cmds: &[(&str, &str)] = &[
8871        ("/help, /?", "Show this help message"),
8872        ("/clear, /new", "Clear the conversation"),
8873        ("/exit, /quit, /q", "Exit the application"),
8874        ("/version, /v", "Show version information"),
8875        ("/changelog", "Show recent release changes"),
8876        ("/model, /m", "Choose a model (live switch)"),
8877        ("/thinking, /think", "Set reasoning depth (selector)"),
8878        ("/tools", "Toggle built-in tools on/off"),
8879        ("/images", "Toggle inline image rendering"),
8880        ("/session", "List saved sessions"),
8881        ("/theme", "Choose a theme (selector)"),
8882        ("/compact", "Compact the conversation"),
8883        ("/copy", "Copy last reply to clipboard"),
8884        ("/hotkeys", "Show keyboard shortcuts"),
8885        ("/armin", "🐾 Easter egg"),
8886        ("/earendil", "Earendil announcement"),
8887    ];
8888    let cmd_w = cmds.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
8889    for (cmd, desc) in cmds {
8890        let row = format!(
8891            "  {:<cmd_w$}  {}  {}",
8892            c.accent.fg(cmd),
8893            c.dim.fg("—"),
8894            c.muted.fg(desc)
8895        );
8896        container.add_child(Arc::new(Text::new(row, 1, 0)));
8897    }
8898    container.add_child(Arc::new(Spacer::new(1)));
8899}
8900
8901/// Add the `/version` block to the chat container.
8902fn add_version_message(container: &Arc<Container>) {
8903    let c = current_theme().colors;
8904    container.add_child(Arc::new(Text::new(
8905        c.md_heading.fg(&tui_bold("📦 Version Information")),
8906        1,
8907        0,
8908    )));
8909    container.add_child(Arc::new(Spacer::new(1)));
8910    // Use the crate version (kept in sync via `version.workspace = true`)
8911    // instead of the stale hardcoded "v0.1.2".
8912    container.add_child(Arc::new(Text::new(
8913        format!(
8914            "  {} {}",
8915            c.muted.fg("rpi-cli"),
8916            c.text.fg(&format!("v{}", crate::VERSION))
8917        ),
8918        1,
8919        0,
8920    )));
8921    container.add_child(Arc::new(Text::new(
8922        format!(
8923            "  {}",
8924            c.dim.fg("Rust implementation of pi coding agent TUI")
8925        ),
8926        1,
8927        0,
8928    )));
8929    container.add_child(Arc::new(Spacer::new(1)));
8930}
8931
8932/// Add a compact `/changelog` block to the chat container. Keep this local to
8933/// the binary so the command remains useful in installed builds without a
8934/// source checkout or a network request.
8935fn add_changelog_message(container: &Arc<Container>) {
8936    let c = current_theme().colors;
8937    container.add_child(Arc::new(Text::new(
8938        c.md_heading.fg(&tui_bold("Recent Changes")),
8939        1,
8940        0,
8941    )));
8942    container.add_child(Arc::new(Spacer::new(1)));
8943    let entries = [
8944        (
8945            "Native parity phase 1",
8946            "models, images, trust, export, and JSON events",
8947        ),
8948        (
8949            "TUI controls",
8950            "external editor, thinking levels, and tool output toggles",
8951        ),
8952        (
8953            "Provider auth",
8954            "OpenAI-compatible API key aliases and gateway headers",
8955        ),
8956    ];
8957    for (release, summary) in entries {
8958        let row = format!("  {}  {}", c.accent.fg(release), c.muted.fg(summary));
8959        container.add_child(Arc::new(Text::new(row, 1, 0)));
8960    }
8961    container.add_child(Arc::new(Text::new(
8962        format!("  {} {}", c.dim.fg("Version"), c.text.fg(crate::VERSION)),
8963        1,
8964        0,
8965    )));
8966    container.add_child(Arc::new(Spacer::new(1)));
8967}
8968
8969/// Add the `/hotkeys` block to the chat container.
8970fn add_hotkeys_message(container: &Arc<Container>) {
8971    let c = current_theme().colors;
8972    container.add_child(Arc::new(Text::new(
8973        c.md_heading.fg(&tui_bold("⌨️  Keyboard Shortcuts")),
8974        1,
8975        0,
8976    )));
8977    container.add_child(Arc::new(Spacer::new(1)));
8978    let keys: &[(&str, &str)] = &[
8979        ("Enter", "Send message"),
8980        ("Shift+Enter", "New line"),
8981        ("Tab", "Accept autocomplete suggestion"),
8982        ("Ctrl+A / Ctrl+E", "Line start / end"),
8983        (
8984            "Ctrl+K / Ctrl+U",
8985            "Kill to end / start of line (Ctrl+Y yanks)",
8986        ),
8987        ("Ctrl+- / Ctrl+R", "Undo / redo"),
8988        ("Ctrl+Y / Alt+Y", "Yank / yank-pop"),
8989        ("Alt+Backspace", "Kill previous word"),
8990        ("Ctrl+C", "Abort a run, or exit when idle"),
8991        ("Esc", "Abort a running prompt"),
8992        ("Ctrl+L", "Open model selector"),
8993        ("Ctrl+M", "Cycle to the next model (live)"),
8994        ("Ctrl+O", "Expand/collapse all tool output"),
8995        ("Ctrl+T", "Show/hide reasoning blocks"),
8996        ("PageUp/Down", "Scroll transcript by one page"),
8997        ("Home / End", "Jump to transcript start / latest output"),
8998    ];
8999    let key_w = keys.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
9000    for (key, desc) in keys {
9001        let row = format!(
9002            "  {:<key_w$}  {}  {}",
9003            c.accent.fg(key),
9004            c.dim.fg("—"),
9005            c.muted.fg(desc)
9006        );
9007        container.add_child(Arc::new(Text::new(row, 1, 0)));
9008    }
9009    container.add_child(Arc::new(Spacer::new(1)));
9010}
9011
9012/// Add a user message echo to the chat container — a bordered `UserMessageComponent`
9013/// (surface-colored box with OSC133 prompt-boundary markers) replacing the old
9014/// plain `> text` echo. A trailing Spacer(1) separates it from the next
9015// transcript entry (every entry contributes one trailing spacer so
9016// consecutive turns are separated by exactly one blank line).
9017fn add_user_message(container: &Arc<Container>, text: &str) {
9018    container.add_child(Arc::new(UserMessageComponent::new(text.to_string())));
9019    container.add_child(Arc::new(Spacer::new(1)));
9020}
9021
9022/// Add an error message to the chat container.
9023/// Collect all rendered lines from the chat container for search.
9024fn collect_transcript_lines(chat_container: &Arc<Container>) -> Vec<String> {
9025    let width = 80; // Default width for search; actual width varies by terminal
9026    chat_container.render(width)
9027}
9028
9029fn add_error_message(container: &Arc<Container>, text: &str) {
9030    let c = current_theme().colors;
9031    let text = sanitize_error_message(text);
9032    container.add_child(Arc::new(Text::new(
9033        format!("  {} {}", c.error.fg("✗"), c.error.fg(&text)),
9034        1,
9035        0,
9036    )));
9037    container.add_child(Arc::new(Spacer::new(1)));
9038}
9039
9040/// Keep provider diagnostics printable in the transcript. HTTP error bodies
9041/// may contain carriage returns, terminal escapes, or an unexpectedly large
9042/// JSON payload; letting those bytes reach the renderer can corrupt the input
9043/// row or make the whole frame exceed terminal limits.
9044fn sanitize_error_message(text: &str) -> String {
9045    const MAX_ERROR_CHARS: usize = 16 * 1024;
9046    let mut result = String::with_capacity(text.len().min(MAX_ERROR_CHARS));
9047    let mut count = 0;
9048    // 0 = normal, 1 = escape introducer, 2 = CSI, 3 = OSC.
9049    let mut escape_mode = 0u8;
9050    for ch in text.chars() {
9051        if escape_mode != 0 {
9052            match escape_mode {
9053                1 if ch == '[' => escape_mode = 2,
9054                1 if ch == ']' => escape_mode = 3,
9055                1 if ch == '\x07' || ('@'..='~').contains(&ch) => escape_mode = 0,
9056                2 if ('@'..='~').contains(&ch) => escape_mode = 0,
9057                3 if ch == '\x07' => escape_mode = 0,
9058                _ => {}
9059            }
9060            continue;
9061        }
9062        if ch == '\x1b' {
9063            escape_mode = 1;
9064            continue;
9065        }
9066        if count >= MAX_ERROR_CHARS {
9067            result.push_str("…");
9068            break;
9069        }
9070        match ch {
9071            '\n' | '\t' => {
9072                result.push(ch);
9073                count += 1;
9074            }
9075            '\r' => {}
9076            c if c.is_control() => {}
9077            c => {
9078                result.push(c);
9079                count += 1;
9080            }
9081        }
9082    }
9083    if result.trim().is_empty() {
9084        "Provider request failed.".to_string()
9085    } else {
9086        result
9087    }
9088}
9089
9090/// Add a neutral note (e.g. unsupported-command message) to the chat container.
9091fn add_note_message(container: &Arc<Container>, text: &str) {
9092    let c = current_theme().colors;
9093    container.add_child(Arc::new(Text::new(
9094        format!("  {} {}", c.info.fg("ℹ"), c.muted.fg(text)),
9095        1,
9096        0,
9097    )));
9098    container.add_child(Arc::new(Spacer::new(1)));
9099}
9100
9101/// Render the `/context` panel: a transcript message listing the discovered
9102/// context files, skills, and prompt templates loaded for this session
9103/// (Part A resource discovery). Reads the harness resources snapshot captured
9104/// at TUI startup (the blocking submit handler can't `await get_resources()`.
9105///
9106/// Mirrors pi's context-panel intent (pi surfaces loaded resources on startup +
9107/// via `/reload`); here it's a transcript note rather than an overlay since the
9108/// resource set is session-static between `/reload`s (deferred).
9109fn show_context_panel(
9110    chat: &Arc<Container>,
9111    resources: &Arc<rpi_harness::types::AgentHarnessResources>,
9112) {
9113    let skills = resources.skills.as_deref().unwrap_or(&[]);
9114    let templates = resources.prompt_templates.as_deref().unwrap_or(&[]);
9115    let mut lines: Vec<String> = Vec::new();
9116    lines.push("📂 Discovered resources for this session:".into());
9117
9118    if skills.is_empty() {
9119        lines.push(
9120            "  Skills: (none discovered — create .rpi/skills/ (.pi/skills also works) or ~/.rpi/agent/skills/)".into(),
9121        );
9122    } else {
9123        lines.push(format!("  Skills ({}):", skills.len()));
9124        for s in skills {
9125            let marker = if s.disable_model_invocation == Some(true) {
9126                " [hidden]"
9127            } else {
9128                ""
9129            };
9130            let desc: String = s.description.chars().take(72).collect();
9131            lines.push(format!("    • {}{marker} — {desc}", s.name));
9132        }
9133    }
9134
9135    if templates.is_empty() {
9136        lines.push(
9137            "  Prompt templates: (none — create .rpi/prompts/ (.pi/prompts also works) or ~/.rpi/agent/prompts/)".into(),
9138        );
9139    } else {
9140        lines.push(format!("  Prompt templates ({}):", templates.len()));
9141        for t in templates {
9142            let desc = t
9143                .description
9144                .as_deref()
9145                .unwrap_or("(no description)")
9146                .chars()
9147                .take(72)
9148                .collect::<String>();
9149            lines.push(format!("    • /{} — {desc}", t.name));
9150        }
9151    }
9152    lines.push("  Context files (AGENTS.md/CLAUDE.md) are injected from the ancestor walk;".into());
9153    lines.push("  SYSTEM.md / APPEND_SYSTEM.md feed the base + append prompt sections.".into());
9154    lines.push(
9155        "  Use --no-skills/-ns, --no-prompt-templates/-np, --no-context-files/-nc to suppress."
9156            .into(),
9157    );
9158    let body = lines.join("\n");
9159    container_note_block(chat, &body);
9160}
9161
9162/// Append a multi-line neutral note (header line + body) to the chat container.
9163fn container_note_block(container: &Arc<Container>, body: &str) {
9164    for line in body.lines() {
9165        container.add_child(Arc::new(Text::new(line.to_string(), 1, 0)));
9166    }
9167    container.add_child(Arc::new(Spacer::new(1)));
9168}
9169
9170// ===========================================================================
9171// TUI support + entry detection
9172// ===========================================================================
9173
9174/// Check if the terminal supports TUI mode.
9175pub fn is_tui_supported() -> bool {
9176    std::io::stdout().is_terminal()
9177}
9178
9179// Keep the `Color` import used (theme accent rendering in autocomplete).
9180#[allow(unused_imports)]
9181use rpi_tui::Color as _Color;
9182
9183#[cfg(test)]
9184mod tests {
9185    use super::*;
9186    use rpi_tui::Component;
9187
9188    #[test]
9189    fn verbose_overrides_quiet_startup_listing() {
9190        assert!(should_show_startup_listing(false, false));
9191        assert!(should_show_startup_listing(true, false));
9192        assert!(should_show_startup_listing(true, true));
9193        assert!(!should_show_startup_listing(false, true));
9194    }
9195
9196    #[test]
9197    fn invalid_npm_command_fallback_keeps_only_git_package_checks() {
9198        let tmp = tempfile::tempdir().unwrap();
9199        let git_root = tmp.path().join(".pi/git/github.com/example/repo");
9200        let local_root = tmp.path().join("local-package");
9201        std::fs::create_dir_all(git_root.join(".git")).unwrap();
9202        std::fs::create_dir_all(&local_root).unwrap();
9203        std::fs::write(
9204            git_root.join("package.json"),
9205            r#"{"name":"git-demo","version":"1.0.0"}"#,
9206        )
9207        .unwrap();
9208        std::fs::write(
9209            local_root.join("package.json"),
9210            r#"{"name":"local-demo","version":"1.0.0"}"#,
9211        )
9212        .unwrap();
9213        let resources = crate::packages::discover(
9214            tmp.path(),
9215            &[
9216                "git:github.com/example/repo".to_string(),
9217                local_root.to_string_lossy().into_owned(),
9218            ],
9219        );
9220        assert_eq!(resources.packages.len(), 2);
9221
9222        let filtered = git_only_update_resources(resources);
9223
9224        assert_eq!(filtered.packages.len(), 1);
9225        assert!(matches!(
9226            filtered.packages[0].source,
9227            crate::packages::PackageSource::Git
9228        ));
9229    }
9230
9231    #[test]
9232    fn trust_command_uses_the_session_cwd_after_process_chdir() {
9233        const CHILD_ENV: &str = "RPI_TEST_TRUST_COMMAND_CHILD";
9234        const SESSION_CWD_ENV: &str = "RPI_TEST_TRUST_COMMAND_SESSION_CWD";
9235        const TEST_NAME: &str =
9236            "interactive_tui::tests::trust_command_uses_the_session_cwd_after_process_chdir";
9237
9238        if std::env::var_os(CHILD_ENV).is_some() {
9239            let session_cwd = std::path::PathBuf::from(
9240                std::env::var_os(SESSION_CWD_ENV).expect("child session cwd should be configured"),
9241            );
9242            let changed_cwd = std::env::current_dir().unwrap();
9243
9244            set_project_trust_for_command(&session_cwd, Some(true)).unwrap();
9245
9246            assert_eq!(
9247                crate::config::project_trust_decision(&session_cwd).unwrap(),
9248                Some(true)
9249            );
9250            assert_eq!(
9251                crate::config::project_trust_decision(&changed_cwd).unwrap(),
9252                None
9253            );
9254            return;
9255        }
9256
9257        let tmp = tempfile::tempdir().unwrap();
9258        let session_cwd = tmp.path().join("session-project");
9259        let changed_cwd = tmp.path().join("extension-cwd");
9260        let agent_dir = tmp.path().join("agent");
9261        std::fs::create_dir_all(&session_cwd).unwrap();
9262        std::fs::create_dir_all(&changed_cwd).unwrap();
9263        std::fs::create_dir_all(&agent_dir).unwrap();
9264
9265        let status = std::process::Command::new(std::env::current_exe().unwrap())
9266            .arg("--exact")
9267            .arg(TEST_NAME)
9268            .arg("--nocapture")
9269            .env(CHILD_ENV, "1")
9270            .env(SESSION_CWD_ENV, &session_cwd)
9271            .env(crate::config::CONFIG_DIR_ENV, &agent_dir)
9272            .current_dir(&changed_cwd)
9273            .status()
9274            .unwrap();
9275
9276        assert!(status.success(), "child test process failed: {status}");
9277    }
9278
9279    #[test]
9280    fn tui_startup_settings_prefer_rpi_project_fields_over_pi() {
9281        let global = crate::settings::Settings {
9282            editor_padding_x: Some(3),
9283            autocomplete_max_visible: Some(6),
9284            hide_thinking_block: Some(false),
9285            quiet_startup: Some(true),
9286            show_terminal_progress: Some(true),
9287            ..Default::default()
9288        };
9289        let rpi_project = crate::settings::Settings {
9290            editor_padding_x: Some(0),
9291            hide_thinking_block: Some(true),
9292            quiet_startup: Some(false),
9293            show_terminal_progress: Some(false),
9294            ..Default::default()
9295        };
9296        let pi_project = crate::settings::Settings {
9297            editor_padding_x: Some(9),
9298            autocomplete_max_visible: Some(12),
9299            quiet_startup: Some(true),
9300            ..Default::default()
9301        };
9302
9303        assert_eq!(
9304            resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true),
9305            TuiStartupSettings {
9306                editor_padding_x: 0,
9307                autocomplete_max_visible: 12,
9308                hide_thinking: true,
9309                quiet_startup: false,
9310                show_terminal_progress: false,
9311            }
9312        );
9313    }
9314
9315    #[test]
9316    fn tui_startup_settings_fall_back_from_rpi_to_pi_per_field() {
9317        let global = crate::settings::Settings {
9318            quiet_startup: Some(false),
9319            ..Default::default()
9320        };
9321        let rpi_project = crate::settings::Settings::default();
9322        let pi_project = crate::settings::Settings {
9323            quiet_startup: Some(true),
9324            ..Default::default()
9325        };
9326
9327        let resolved = resolve_tui_startup_settings(&global, &[rpi_project, pi_project], true);
9328
9329        assert!(resolved.quiet_startup);
9330    }
9331
9332    #[test]
9333    fn tui_startup_settings_use_global_values_without_project_fields() {
9334        let global = crate::settings::Settings {
9335            editor_padding_x: Some(7),
9336            autocomplete_max_visible: Some(8),
9337            hide_thinking_block: Some(true),
9338            quiet_startup: Some(true),
9339            show_terminal_progress: Some(false),
9340            ..Default::default()
9341        };
9342
9343        assert_eq!(
9344            resolve_tui_startup_settings(&global, &[crate::settings::Settings::default()], true,),
9345            TuiStartupSettings {
9346                editor_padding_x: 7,
9347                autocomplete_max_visible: 8,
9348                hide_thinking: true,
9349                quiet_startup: true,
9350                show_terminal_progress: false,
9351            }
9352        );
9353    }
9354
9355    #[test]
9356    fn tui_startup_settings_ignore_untrusted_project_values() {
9357        let global = crate::settings::Settings {
9358            quiet_startup: Some(false),
9359            show_terminal_progress: Some(true),
9360            ..Default::default()
9361        };
9362        let project = crate::settings::Settings {
9363            quiet_startup: Some(true),
9364            show_terminal_progress: Some(false),
9365            ..Default::default()
9366        };
9367
9368        let resolved = resolve_tui_startup_settings(&global, &[project], false);
9369
9370        assert!(!resolved.quiet_startup);
9371        assert!(resolved.show_terminal_progress);
9372    }
9373
9374    #[test]
9375    fn transcript_page_uses_viewport_with_overlap() {
9376        assert_eq!(transcript_page_size(24), 20);
9377        assert_eq!(transcript_page_size(4), 1);
9378        assert_eq!(transcript_page_size(0), 1);
9379    }
9380
9381    #[test]
9382    fn key_repeat_is_dispatched_but_release_is_not() {
9383        assert!(should_dispatch_key(KeyEventKind::Press));
9384        assert!(should_dispatch_key(KeyEventKind::Repeat));
9385        assert!(!should_dispatch_key(KeyEventKind::Release));
9386    }
9387
9388    #[test]
9389    fn key_event_encoding_matches_pi_keybinding_protocol() {
9390        let key = |code, modifiers| KeyEvent::new(code, modifiers);
9391        assert_eq!(
9392            key_event_to_input(key(KeyCode::Enter, KeyModifiers::NONE)),
9393            "\r"
9394        );
9395        assert_eq!(
9396            key_event_to_input(key(KeyCode::Enter, KeyModifiers::SHIFT)),
9397            "\x1b[13;2u"
9398        );
9399        assert_eq!(
9400            key_event_to_input(key(KeyCode::Tab, KeyModifiers::SHIFT)),
9401            "\x1b[9;2u"
9402        );
9403        assert_eq!(
9404            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::SHIFT)),
9405            "\x1b[Z"
9406        );
9407        assert_eq!(
9408            key_event_to_input(key(KeyCode::BackTab, KeyModifiers::NONE)),
9409            "\x1b[Z"
9410        );
9411        assert_eq!(
9412            key_event_to_input(key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
9413            "\x03"
9414        );
9415        assert_eq!(
9416            key_event_to_input(key(KeyCode::Char('o'), KeyModifiers::CONTROL)),
9417            "\x0f"
9418        );
9419        assert_eq!(
9420            key_event_to_input(key(KeyCode::Char('!'), KeyModifiers::SHIFT)),
9421            "!"
9422        );
9423        assert_eq!(
9424            key_event_to_input(key(KeyCode::Char('1'), KeyModifiers::SHIFT)),
9425            "1"
9426        );
9427    }
9428
9429    #[test]
9430    fn dialog_cancel_before_open_is_consumed_without_stranding_request() {
9431        let bridge = JsDialogBridge::default();
9432        let (sender, receiver) = std_mpsc::channel();
9433        bridge.pending.lock().unwrap().push_back(JsDialogPending {
9434            request: JsDialogRequest {
9435                id: "dialog-1".into(),
9436                method: "input".into(),
9437                title: String::new(),
9438                message: String::new(),
9439                options: Vec::new(),
9440                placeholder: None,
9441                prefill: None,
9442            },
9443            result: sender,
9444        });
9445
9446        // Model the cancellation arriving after the queue entry has been
9447        // removed but before the TUI has installed the native widget.
9448        bridge.cancel("dialog-1");
9449        assert!(bridge.take_pending().is_none());
9450        assert_eq!(
9451            receiver.recv().unwrap(),
9452            serde_json::json!({ "cancelled": true })
9453        );
9454        assert!(bridge.cancelled_before_open.lock().unwrap().is_empty());
9455    }
9456
9457    #[test]
9458    fn test_layout_renders_welcome_message() {
9459        let chat = Arc::new(Container::new());
9460        add_welcome_message(&chat);
9461
9462        let scroll = Arc::new(ScrollView::new(
9463            chat.clone(),
9464            ScrollViewOptions {
9465                follow: FollowMode::End,
9466                primary: true,
9467                ..Default::default()
9468            },
9469        ));
9470
9471        let editor = Arc::new(Editor::new(
9472            EditorOptions {
9473                padding_x: 1,
9474                ..Default::default()
9475            },
9476            EditorStyle::default(),
9477            Arc::new(rpi_tui::Keybindings::new()),
9478        ));
9479        let dock = Arc::new(Container::new());
9480        dock.add_child(editor);
9481
9482        let footer = Arc::new(FooterComponent::new());
9483
9484        let root = VStack::from_children(vec![
9485            StackChild::Entry(StackEntry::new(scroll.clone()).grow(1).min_size(1)),
9486            StackChild::Entry(StackEntry::new(dock)),
9487            StackChild::Entry(StackEntry::new(footer)),
9488        ]);
9489
9490        let frame = rpi_tui::render_layout_frame(Arc::new(root), 80, 24);
9491
9492        let all: String = frame.lines.join("\n");
9493        assert!(
9494            all.contains("rpi"),
9495            "Welcome message not found. Rendered: {}",
9496            all
9497        );
9498        assert!(
9499            all.contains("Type your message"),
9500            "Help text not found. Rendered: {}",
9501            all
9502        );
9503    }
9504
9505    #[test]
9506    fn test_chat_container_has_welcome_content() {
9507        let chat = Arc::new(Container::new());
9508        add_welcome_message_with_capabilities(
9509            &chat,
9510            &["read".into(), "bash".into(), "web_fetch".into()],
9511            &["rust-review".into(), "release".into()],
9512        );
9513
9514        let lines = chat.render(80);
9515        let all: String = lines.join("\n");
9516        // Welcome title is "rpi" (accent bold) + "interactive TUI" (muted),
9517        // joined by an ANSI reset — strip ANSI before checking the substring.
9518        let plain = strip_ansi(&all);
9519        assert!(
9520            plain.contains("rpi"),
9521            "Welcome message not in chat container: {:?}",
9522            lines
9523        );
9524        assert!(plain.contains("Tools (3)"), "Tool count missing: {plain}");
9525        assert!(
9526            plain.contains("read · bash · web_fetch"),
9527            "Tool names missing: {plain}"
9528        );
9529        assert!(plain.contains("Skills (2)"), "Skill count missing: {plain}");
9530        assert!(
9531            plain.contains("rust-review · release"),
9532            "Skill names missing: {plain}"
9533        );
9534    }
9535
9536    #[test]
9537    fn update_notices_render_inside_the_transcript() {
9538        let chat = Arc::new(Container::new());
9539        let report = crate::updates::UpdateReport {
9540            notices: vec![
9541                crate::updates::UpdateNotice {
9542                    name: "rpi".into(),
9543                    current: "0.1.10".into(),
9544                    latest: "0.1.11".into(),
9545                    command: "rpi update".into(),
9546                },
9547                crate::updates::UpdateNotice {
9548                    name: "rpi-search".into(),
9549                    current: "0.1.0".into(),
9550                    latest: "0.1.1".into(),
9551                    command: "rpi pi-package update".into(),
9552                },
9553            ],
9554            warnings: vec![crate::updates::UpdateWarning {
9555                message: "The previously scheduled rpi update failed: access denied".into(),
9556                command: "rpi update".into(),
9557            }],
9558        };
9559
9560        add_update_notices(&chat, &report);
9561
9562        assert_eq!(chat.child_count(), 1);
9563        let plain = strip_ansi(&chat.render(80).join("\n"));
9564        assert!(plain.contains("Update Failed"), "{plain}");
9565        assert!(
9566            plain.contains("rpi update failed: access denied"),
9567            "{plain}"
9568        );
9569        assert!(plain.contains("Update Available"), "{plain}");
9570        assert!(plain.contains("New version 0.1.11 is available"), "{plain}");
9571        assert!(plain.contains("rpi update"), "{plain}");
9572        assert!(plain.contains("Package Updates Available"), "{plain}");
9573        assert!(plain.contains("rpi pi-package update"), "{plain}");
9574        assert!(plain.contains("- rpi-search 0.1.0 -> 0.1.1"), "{plain}");
9575    }
9576
9577    #[test]
9578    fn empty_update_report_does_not_add_transcript_content() {
9579        let chat = Arc::new(Container::new());
9580
9581        add_update_notices(&chat, &crate::updates::UpdateReport::default());
9582
9583        assert_eq!(chat.child_count(), 0);
9584    }
9585
9586    #[test]
9587    fn skill_reads_are_detected_by_path() {
9588        let name = skill_tool_name(
9589            "read",
9590            &serde_json::json!({"path": "C:/work/.rpi/skills/release/SKILL.md"}),
9591        );
9592        assert_eq!(name.as_deref(), Some("release"));
9593
9594        let name = skill_tool_name("read", &serde_json::json!({"path": "/docs/README.md"}));
9595        assert!(name.is_none());
9596
9597        // Only `read` (not other tools) triggers the skill box.
9598        assert!(skill_tool_name("grep", &serde_json::json!({"path": "/s/x/SKILL.md"})).is_none());
9599    }
9600
9601    #[test]
9602    fn welcome_capabilities_show_empty_state() {
9603        let plain = strip_ansi(&welcome_capability_line("Skills", &[]));
9604        assert_eq!(plain, "Skills (0) none");
9605    }
9606
9607    #[test]
9608    fn empty_tool_progress_is_deferred_until_start() {
9609        let empty = rpi_agent::AgentToolResult::default();
9610        assert!(!tool_update_has_payload("", &empty));
9611
9612        let text = rpi_agent::AgentToolResult::text("partial output");
9613        assert!(tool_update_has_payload("partial output", &text));
9614
9615        let details = rpi_agent::AgentToolResult {
9616            details: serde_json::json!({"path": "src/lib.rs"}),
9617            ..Default::default()
9618        };
9619        assert!(tool_update_has_payload("", &details));
9620    }
9621
9622    #[test]
9623    fn completed_stream_reconciles_the_final_tail_before_detaching() {
9624        let component = Arc::new(AssistantMessageComponent::new(
9625            AssistantMessageOptions::default(),
9626        ));
9627        component.set_streaming(true);
9628        component.update_blocks(&[AssistantBlock::Text("partial response".into())]);
9629
9630        let current = Mutex::new(Some(component.clone()));
9631        let cached = Mutex::new("partial response".to_string());
9632        let mut final_message = AssistantMessage::empty(rpi_ai::Api::Faux, "faux", "faux-model", 0);
9633        final_message.content = vec![Content::text(
9634            "partial response with the previously missing final tail",
9635        )];
9636        final_message.stop_reason = rpi_ai::types::StopReason::Stop;
9637
9638        reconcile_streamed_assistant_completion(&current, &cached, Some(&final_message));
9639
9640        assert!(current.lock().unwrap().is_none());
9641        assert_eq!(
9642            cached.lock().unwrap().as_str(),
9643            "partial response with the previously missing final tail"
9644        );
9645        let rendered = strip_ansi(&component.render(100).join("\n"));
9646        assert!(
9647            rendered.contains("previously missing final tail"),
9648            "{rendered}"
9649        );
9650    }
9651
9652    /// Reproduction for "Tab 补全了但显示没刷新": after `accept_top_suggestion`
9653    /// replaces the editor text, the NEXT rendered frame must show the
9654    /// completed text (" /model " with the caret after it), not the old
9655    /// prefix. Mirrors the real dock layout (autocomplete_container above the
9656    /// bordered editor) and drives the same accept path the Tab handler uses.
9657    #[test]
9658    fn tab_accept_suggestion_reflects_in_next_render() {
9659        use rpi_tui::render_layout_frame;
9660
9661        let editor = Arc::new(Editor::new(
9662            EditorOptions {
9663                padding_x: 1,
9664                ..Default::default()
9665            },
9666            EditorStyle::default(),
9667            Arc::new(rpi_tui::Keybindings::new()),
9668        ));
9669        editor.set_focused(true);
9670        let editor_container = Arc::new(Container::new());
9671        editor_container.add_child(editor.clone());
9672        let autocomplete_container = Arc::new(Container::new());
9673        let footer = Arc::new(rpi_tui::Text::new("FOOTER", 0, 0));
9674        let dock = Arc::new(VStack::from_children(vec![
9675            StackChild::Entry(StackEntry::new(autocomplete_container.clone())),
9676            StackChild::Entry(
9677                StackEntry::new(editor_container.clone())
9678                    .shrink(0)
9679                    .min_size(3),
9680            ),
9681            StackChild::Entry(StackEntry::new(footer)),
9682        ]));
9683
9684        // Simulate the user typing "/mo" (the popup shows suggestions).
9685        let mut manager = AutocompleteManager::new();
9686        let mut combined = CombinedAutocompleteProvider::new();
9687        combined.add_provider(Arc::new(
9688            SlashCommandAutocompleteProvider::with_default_commands(),
9689        ));
9690        combined.add_provider(Arc::new(FilePathAutocompleteProvider::new()));
9691        manager.set_provider(Arc::new(combined));
9692        // Simulate typing "/mo" via the real insert path (advances the caret
9693        // by char length, like `handle_key` does).
9694        editor.insert("/mo");
9695        assert_eq!(editor.cursor_position(), (0, 3));
9696
9697        let frame_before = render_layout_frame(dock.clone(), 80, 10);
9698        assert!(
9699            frame_before.lines.iter().any(|l| l.contains("/mo")),
9700            "precondition: editor shows the typed prefix. Frame rows:\n{}",
9701            frame_before
9702                .lines
9703                .iter()
9704                .map(|l| format!("  [{l}]"))
9705                .collect::<Vec<_>>()
9706                .join("\n")
9707        );
9708
9709        // Tab: accept the top suggestion (the same code path as the key loop).
9710        let text = editor.get_text();
9711        let cursor = editor_cursor_offset(&editor, &text);
9712        let sugg = manager
9713            .get_suggestions(&text, cursor)
9714            .expect("slash suggestions for /mo");
9715        let top = sugg.items.first().expect("at least one suggestion");
9716        let start = sugg.start.min(text.len());
9717        let end = sugg.end.min(text.len());
9718        let mut replaced = String::new();
9719        replaced.push_str(&text[..start]);
9720        replaced.push_str(&top.text);
9721        replaced.push_str(&text[end..]);
9722        if top.insert_space && !replaced.ends_with('/') {
9723            replaced.push(' ');
9724        }
9725        let new_cursor = start + top.text.len();
9726        editor.set_text(&replaced);
9727        set_editor_cursor_offset(&editor, &replaced, new_cursor);
9728        autocomplete_container.clear();
9729        assert_eq!(editor.get_text(), "/model");
9730
9731        // The next render MUST display the completed text.
9732        let frame_after = render_layout_frame(dock, 80, 10);
9733        let all: String = frame_after.lines.join("\n");
9734        assert!(
9735            all.contains("/model"),
9736            "completed text missing from next render. Got:\n{all}"
9737        );
9738        // The caret must sit AFTER the completed command (the snap_boundary
9739        // regression put it one char early: "/mode|l" with the final char
9740        // dangling past the caret).
9741        let editor_line = frame_after
9742            .lines
9743            .iter()
9744            .find(|l| l.contains("/model"))
9745            .expect("editor row with completed text");
9746        assert!(
9747            editor_line.contains(&format!("/model{}", rpi_tui::CURSOR_MARKER)),
9748            "caret must follow the full completed text. Got: {editor_line:?}"
9749        );
9750    }
9751
9752    #[test]
9753    fn multiline_autocomplete_preserves_row_and_column() {
9754        let editor = Arc::new(Editor::simple());
9755        editor.set_text("first\n/mo");
9756        editor.set_cursor(1, 3);
9757
9758        let text = editor.get_text();
9759        assert_eq!(editor_cursor_offset(&editor, &text), 9);
9760
9761        let replaced = "first\n/model";
9762        editor.set_text(replaced);
9763        set_editor_cursor_offset(&editor, replaced, 12);
9764        assert_eq!(editor.cursor_position(), (1, 6));
9765    }
9766
9767    #[test]
9768    fn test_slash_command_dispatch() {
9769        // The registry is the single source of truth for dispatch: `find(token)`
9770        // returns the command (by name or alias) whose `name()` is the canonical
9771        // form, or `None` for an unknown token. This replaces the old enum-based
9772        // `handle_slash_command` assertions with equivalent registry lookups.
9773        let registry = build_builtin_registry();
9774
9775        // Helper: a token resolves to the command with this canonical name.
9776        let resolves_to = |token: &str, canonical: &str| {
9777            let found = registry.find(token).expect("{token} should resolve");
9778            assert_eq!(
9779                found.name(),
9780                canonical,
9781                "{token} resolved to {} (expected {canonical})",
9782                found.name()
9783            );
9784        };
9785
9786        resolves_to("/help", "/help");
9787        resolves_to("/?", "/help"); // alias → canonical
9788        resolves_to("/clear", "/clear");
9789        resolves_to("/new", "/clear"); // alias
9790        resolves_to("/q", "/exit"); // alias
9791        resolves_to("/quit", "/exit"); // alias
9792        resolves_to("/version", "/version");
9793        resolves_to("/v", "/version"); // alias
9794        resolves_to("/changelog", "/changelog");
9795        resolves_to("/hotkeys", "/hotkeys");
9796        resolves_to("/model", "/model");
9797        resolves_to("/m", "/model"); // alias
9798        resolves_to("/theme", "/theme");
9799        resolves_to("/session", "/session");
9800        resolves_to("/resume", "/session"); // alias
9801        resolves_to("/compact", "/compact");
9802        resolves_to("/copy", "/copy");
9803        resolves_to("/thinking", "/thinking");
9804        resolves_to("/think", "/thinking"); // alias
9805        resolves_to("/tools", "/tools");
9806        resolves_to("/images", "/images");
9807        resolves_to("/armin", "/armin");
9808        resolves_to("/earendil", "/earendil");
9809        resolves_to("/context", "/context");
9810        // Out-of-v1-scope commands resolve to their own UnsupportedCommand entry.
9811        resolves_to("/settings", "/settings");
9812        resolves_to("/name", "/name");
9813        resolves_to("/export", "/export");
9814
9815        // Unknown token → not found.
9816        assert!(registry.find("/nope").is_none(), "/nope should be unknown");
9817    }
9818
9819    #[test]
9820
9821    fn test_registry_visible_entries_cover_dispatch() {
9822        // The autocomplete list is derived from the registry, so every visible
9823        // command the dispatcher recognizes must appear in it — by construction,
9824        // but this guards against a future command being registered with
9825        // `visible()` / a non-empty description that the builder drops.
9826        let registry = build_builtin_registry();
9827        let names: Vec<String> = registry
9828            .visible_entries()
9829            .iter()
9830            .map(|c| c.name.clone())
9831            .collect();
9832        for recognized in [
9833            "/help",
9834            "/clear",
9835            "/new",
9836            "/exit",
9837            "/quit",
9838            "/version",
9839            "/changelog",
9840            "/model",
9841            "/session",
9842            "/theme",
9843            "/compact",
9844            "/copy",
9845            "/hotkeys",
9846            "/tools",
9847            "/images",
9848            "/thinking",
9849            "/usage",
9850            "/armin",
9851            "/earendil",
9852        ] {
9853            assert!(
9854                names.contains(&recognized.to_string()),
9855                "{recognized} missing from autocomplete list"
9856            );
9857        }
9858        // Hidden commands stay off the list.
9859        for hidden in ["/context", "/q", "/m", "/v", "/think", "/resume", "/?"] {
9860            assert!(
9861                !names.contains(&hidden.to_string()),
9862                "{hidden} should be hidden from autocomplete"
9863            );
9864        }
9865    }
9866
9867    #[test]
9868    fn test_agent_event_mapping_creates_assistant_and_tool() {
9869        // Synthetic AgentEvent sequence → UI mutations, exercised against the
9870        // real drain handler with a no-op TUI stand-in.
9871        use rpi_ai::types::{
9872            StopReason, TextContent, TextContentType, ThinkingContent, ThinkingContentType,
9873            ToolCall, ToolCallType, Usage,
9874        };
9875
9876        let state = Arc::new(TuiState {
9877            current_assistant: std::sync::Mutex::new(None),
9878            tool_components: std::sync::Mutex::new(HashMap::new()),
9879            bash_components: std::sync::Mutex::new(HashMap::new()),
9880            themes_enabled: true,
9881            hide_thinking: std::sync::Mutex::new(false),
9882            tool_outputs_expanded: std::sync::Mutex::new(false),
9883            show_terminal_progress: true,
9884            status: std::sync::Mutex::new(RunStatus::Idle),
9885            js_preparation_cancel: std::sync::Mutex::new(None),
9886            footer: Arc::new(FooterComponent::new()),
9887            status_container: Arc::new(Container::new()),
9888            chat_container: Arc::new(Container::new()),
9889            loader: Arc::new(Loader::new()),
9890            last_assistant_text: std::sync::Mutex::new(String::new()),
9891            active_selector: std::sync::Mutex::new(None),
9892            active_extension_editor: std::sync::Mutex::new(None),
9893            active_extension_input: std::sync::Mutex::new(None),
9894            active_extension_cancel: std::sync::Mutex::new(None),
9895            autocomplete: AutocompleteManager::new(),
9896            autocomplete_container: Arc::new(Container::new()),
9897            autocomplete_max_visible: 5,
9898            pending_images: std::sync::Mutex::new(Vec::new()),
9899            theme_manager: Arc::new(ThemeManager::new()),
9900            tui: None,
9901            current_model_id: std::sync::Mutex::new(String::new()),
9902            show_images: std::sync::Mutex::new(true),
9903            history: std::sync::Mutex::new(Vec::new()),
9904            history_index: std::sync::Mutex::new(-1),
9905            history_draft: std::sync::Mutex::new(None),
9906            last_input_tokens: std::sync::Mutex::new(0),
9907            scoped_edit: std::sync::Mutex::new(None),
9908            markdown_transformer: std::sync::Mutex::new(None),
9909            extension_session: Arc::new(std::sync::Mutex::new(
9910                rpi_extensions::ExtensionSession::none(),
9911            )),
9912            search: Arc::new(AltScreenSearch::new()),
9913            search_bar: Arc::new(SearchBar::new()),
9914            selection_start: std::sync::Mutex::new(None),
9915            selection_end: std::sync::Mutex::new(None),
9916        });
9917
9918        // The drain handler takes `Arc<TuiAltScreen>`, which needs a real
9919        // terminal; instead, exercise the *mutation* half directly against a
9920        // captured chat container via a synthetic message-start event's data.
9921        let assistant = AssistantMessage {
9922            role: rpi_ai::types::AssistantRole,
9923            content: vec![
9924                Content::Thinking(ThinkingContent {
9925                    kind: ThinkingContentType,
9926                    thinking: "Reasoning about the reply.".into(),
9927                    thinking_signature: None,
9928                    redacted: false,
9929                }),
9930                Content::Text(TextContent {
9931                    kind: TextContentType,
9932                    text: "Hello.".into(),
9933                    text_signature: None,
9934                }),
9935                Content::ToolCall(ToolCall {
9936                    kind: ToolCallType,
9937                    id: "tc1".into(),
9938                    name: "bash".into(),
9939                    arguments: serde_json::json!({"command": "echo hi"}),
9940                    thought_signature: None,
9941                    namespace: None,
9942                }),
9943            ],
9944            api: rpi_ai::Api::AnthropicMessages,
9945            provider: "anthropic".into(),
9946            model: "claude-sonnet-5".into(),
9947            response_model: None,
9948            response_id: None,
9949            usage: Usage::zero(),
9950            stop_reason: StopReason::Stop,
9951            deferred: None,
9952            error_message: None,
9953            raw_stop_reason: None,
9954            end_turn: None,
9955            timestamp: 0,
9956        };
9957
9958        // Manually apply the MessageStart assistant branch logic (mirrors the
9959        // drain handler, without needing a TuiAltScreen).
9960        let comp = Arc::new(AssistantMessageComponent::new(
9961            AssistantMessageOptions::default(),
9962        ));
9963        comp.set_streaming(true);
9964        comp.update_blocks(&assistant_blocks(&assistant));
9965        let chat = Arc::new(Container::new());
9966        chat.add_child(comp.clone());
9967        *state.current_assistant.lock().unwrap() = Some(comp);
9968
9969        // Manually apply the MessageUpdate tool-call scan (mirrors drain).
9970        for c in &assistant.content {
9971            if let Content::ToolCall(tc) = c {
9972                let mut tools = state.tool_components.lock().unwrap();
9973                if !tools.contains_key(&tc.id) {
9974                    let tc_comp = Arc::new(ToolExecutionComponent::new(
9975                        &tc.name,
9976                        &tc.arguments.to_string(),
9977                    ));
9978                    tc_comp.set_running();
9979                    chat.add_child(tc_comp.clone());
9980                    tools.insert(tc.id.clone(), tc_comp);
9981                }
9982            }
9983        }
9984
9985        // Assert: the assistant component rendered the text + the thinking
9986        // block (the update_blocks path keeps thinking visible), and a tool
9987        // component was registered.
9988        let rendered = chat.render(80);
9989        let joined: String = rendered.join("\n");
9990        assert!(
9991            joined.contains("Hello."),
9992            "assistant text not rendered: {joined}"
9993        );
9994        assert!(
9995            joined.contains("Reasoning about the reply."),
9996            "thinking block not rendered: {joined}"
9997        );
9998        assert_eq!(state.tool_components.lock().unwrap().len(), 1);
9999        assert!(state.current_assistant.lock().unwrap().is_some());
10000
10001        // Manually apply ToolExecutionEnd (mirrors drain).
10002        let ended = state.tool_components.lock().unwrap().remove("tc1").unwrap();
10003        ended.set_result("hi", false);
10004        assert!(state.tool_components.lock().unwrap().is_empty());
10005
10006        // A running bash panel owns the visible spinner. The global loader is
10007        // hidden until the last concurrent bash tool completes, then restored
10008        // while the agent remains in the Working state.
10009        assert!(state.try_start_working());
10010        assert!(
10011            !state.try_start_working(),
10012            "a second submit must be rejected"
10013        );
10014        state.set_status(RunStatus::Idle);
10015        state.set_status(RunStatus::Working);
10016        assert_eq!(state.status_container.child_count(), 1);
10017        assert!(state.footer.get_status().is_empty());
10018        state.show_retry(3, 10, 8_000);
10019        let retry_status = strip_ansi(&state.status_container.render(80).join("\n"));
10020        assert!(retry_status.contains("Retrying (3/10)"));
10021        state.set_status(RunStatus::Working);
10022        {
10023            let mut bash = state.bash_components.lock().unwrap();
10024            bash.insert(
10025                "bash-1".into(),
10026                Arc::new(BashExecutionComponent::new("one")),
10027            );
10028            bash.insert(
10029                "bash-2".into(),
10030                Arc::new(BashExecutionComponent::new("two")),
10031            );
10032        }
10033        state.sync_working_loader_with_bash();
10034        assert_eq!(state.status_container.child_count(), 0);
10035        state.bash_components.lock().unwrap().remove("bash-1");
10036        state.sync_working_loader_with_bash();
10037        assert_eq!(state.status_container.child_count(), 0);
10038        state.bash_components.lock().unwrap().remove("bash-2");
10039        state.sync_working_loader_with_bash();
10040        assert_eq!(state.status_container.child_count(), 1);
10041
10042        state.set_status(RunStatus::Aborting);
10043        assert_eq!(state.status_container.child_count(), 0);
10044        assert!(!state.loader.is_running());
10045    }
10046
10047    #[test]
10048    fn fresh_launch_does_not_restore_old_history() {
10049        let fresh = Args::default();
10050        assert!(!launch_restores_history(&fresh));
10051
10052        let continued = Args {
10053            continue_session: true,
10054            ..Args::default()
10055        };
10056        assert!(launch_restores_history(&continued));
10057
10058        let selected = Args {
10059            session: Some("session-id".into()),
10060            ..Args::default()
10061        };
10062        assert!(launch_restores_history(&selected));
10063    }
10064
10065    #[test]
10066    fn test_short_model_name() {
10067        assert_eq!(
10068            short_model_name("anthropic:claude-sonnet-5"),
10069            "claude-sonnet-5"
10070        );
10071        assert_eq!(short_model_name("claude-sonnet-5"), "claude-sonnet-5");
10072    }
10073
10074    #[test]
10075    fn model_selector_items_are_deduplicated_and_provider_qualified() {
10076        use rpi_ai::{Api, Model};
10077
10078        let mut gateway = Model::new(
10079            "gpt-5.6-sol",
10080            "GPT 5.6 Sol",
10081            Api::OpenaiCompletions,
10082            "routeryo-copy",
10083            "https://gateway.example.com",
10084        );
10085        let duplicate = gateway.clone();
10086        let anthropic = Model::new(
10087            "claude-sonnet-5",
10088            "Claude Sonnet 5",
10089            Api::AnthropicMessages,
10090            "anthropic",
10091            "https://api.anthropic.com",
10092        );
10093        gateway.headers = Some(std::collections::BTreeMap::from([(
10094            "authorization".into(),
10095            "Bearer test".into(),
10096        )]));
10097
10098        let items = model_selector_items(&[gateway, duplicate, anthropic], "gpt-5.6-sol");
10099        assert_eq!(items.len(), 2);
10100        assert_eq!(items[0].value, "gpt-5.6-sol");
10101        assert_eq!(items[0].label, "GPT 5.6 Sol");
10102        assert_eq!(
10103            items[0].description.as_deref(),
10104            Some("routeryo-copy/gpt-5.6-sol (current)")
10105        );
10106        assert_eq!(items[1].description.as_deref(), Some("claude-sonnet-5"));
10107    }
10108
10109    #[test]
10110    fn model_selector_match_accepts_bare_and_qualified_ids() {
10111        use rpi_ai::{Api, Model};
10112
10113        let gateway = Model::new(
10114            "gpt-5.6-sol",
10115            "GPT 5.6 Sol",
10116            Api::OpenaiCompletions,
10117            "routeryo-copy",
10118            "https://gateway.example.com",
10119        );
10120        let anthropic = Model::new(
10121            "claude-sonnet-5",
10122            "Claude Sonnet 5",
10123            Api::AnthropicMessages,
10124            "anthropic",
10125            "https://api.anthropic.com",
10126        );
10127        let catalog = [gateway, anthropic];
10128        assert_eq!(
10129            find_model_selector_match(&catalog, "gpt-5.6-sol")
10130                .unwrap()
10131                .provider,
10132            "routeryo-copy"
10133        );
10134        assert_eq!(
10135            find_model_selector_match(&catalog, "routeryo-copy/gpt-5.6-sol")
10136                .unwrap()
10137                .id,
10138            "gpt-5.6-sol"
10139        );
10140        assert_eq!(
10141            find_model_selector_match(&catalog, "anthropic/claude-sonnet-5")
10142                .unwrap()
10143                .id,
10144            "claude-sonnet-5"
10145        );
10146        assert!(find_model_selector_match(&catalog, "other/gpt-5.6-sol").is_none());
10147    }
10148
10149    #[test]
10150    fn assistant_error_text_keeps_terminal_provider_diagnostic_visible() {
10151        use rpi_ai::types::{AssistantMessage, AssistantRole, StopReason, Usage};
10152
10153        let failed = AssistantMessage {
10154            role: AssistantRole,
10155            content: Vec::new(),
10156            api: rpi_ai::Api::AnthropicMessages,
10157            provider: "anthropic".into(),
10158            model: "claude-sonnet-5".into(),
10159            response_model: None,
10160            response_id: None,
10161            usage: Usage::zero(),
10162            stop_reason: StopReason::Error,
10163            deferred: None,
10164            error_message: Some("upstream returned 401".into()),
10165            raw_stop_reason: None,
10166            end_turn: None,
10167            timestamp: 0,
10168        };
10169        assert_eq!(
10170            assistant_error_text(&failed).as_deref(),
10171            Some("upstream returned 401")
10172        );
10173
10174        let mut no_detail = failed;
10175        no_detail.error_message = Some("  ".into());
10176        assert_eq!(
10177            assistant_error_text(&no_detail).as_deref(),
10178            Some("Provider request failed.")
10179        );
10180
10181        let mut aborted = no_detail;
10182        aborted.stop_reason = StopReason::Aborted;
10183        aborted.error_message = Some("abort error: Request aborted".into());
10184        assert_eq!(
10185            assistant_error_text(&aborted).as_deref(),
10186            Some("abort error: Request aborted")
10187        );
10188
10189        aborted.error_message = None;
10190        assert_eq!(
10191            assistant_error_text(&aborted).as_deref(),
10192            Some("Request aborted.")
10193        );
10194    }
10195
10196    #[test]
10197    fn sanitize_error_message_keeps_diagnostics_without_terminal_controls() {
10198        assert_eq!(
10199            sanitize_error_message("405\r\nMethod Not Allowed\x1b[2J"),
10200            "405\nMethod Not Allowed"
10201        );
10202        assert_eq!(sanitize_error_message("\0\tmessage"), "\tmessage");
10203        assert_eq!(sanitize_error_message("   "), "Provider request failed.");
10204        let long = "x".repeat(20_000);
10205        let cleaned = sanitize_error_message(&long);
10206        assert!(cleaned.chars().count() <= 16 * 1024 + 1);
10207        assert!(cleaned.ends_with('…'));
10208    }
10209
10210    #[test]
10211    fn test_cycle_next_model_wraps_around() {
10212        use rpi_ai::{Api, Model};
10213        let mk = |id: &str| {
10214            Model::new(
10215                id,
10216                id,
10217                Api::AnthropicMessages,
10218                "anthropic",
10219                "https://api.anthropic.com",
10220            )
10221        };
10222        let catalog = [mk("a"), mk("b"), mk("c")];
10223        // Next after "a" is "b"; after "c" wraps to "a".
10224        assert_eq!(cycle_next_model(&catalog, "a").unwrap().id, "b");
10225        assert_eq!(cycle_next_model(&catalog, "c").unwrap().id, "a");
10226        // An unknown current id falls back to the first model.
10227        assert_eq!(cycle_next_model(&catalog, "zzz").unwrap().id, "a");
10228        // Empty catalog yields None.
10229        let empty: Vec<Model> = vec![];
10230        assert!(cycle_next_model(&empty, "a").is_none());
10231    }
10232
10233    #[test]
10234    fn test_autocomplete_slash_suggestions_render() {
10235        // The autocomplete container should render at least one suggestion
10236        // line when the editor holds a `/` prefix, and clear when it doesn't.
10237        let state = Arc::new(TuiState {
10238            current_assistant: std::sync::Mutex::new(None),
10239            tool_components: std::sync::Mutex::new(HashMap::new()),
10240            bash_components: std::sync::Mutex::new(HashMap::new()),
10241            themes_enabled: true,
10242            hide_thinking: std::sync::Mutex::new(false),
10243            tool_outputs_expanded: std::sync::Mutex::new(false),
10244            show_terminal_progress: true,
10245            status: std::sync::Mutex::new(RunStatus::Idle),
10246            js_preparation_cancel: std::sync::Mutex::new(None),
10247            footer: Arc::new(FooterComponent::new()),
10248            status_container: Arc::new(Container::new()),
10249            chat_container: Arc::new(Container::new()),
10250            loader: Arc::new(Loader::new()),
10251            last_assistant_text: std::sync::Mutex::new(String::new()),
10252            active_selector: std::sync::Mutex::new(None),
10253            active_extension_editor: std::sync::Mutex::new(None),
10254            active_extension_input: std::sync::Mutex::new(None),
10255            active_extension_cancel: std::sync::Mutex::new(None),
10256            autocomplete: AutocompleteManager::new(),
10257            autocomplete_container: Arc::new(Container::new()),
10258            autocomplete_max_visible: 5,
10259            pending_images: std::sync::Mutex::new(Vec::new()),
10260            theme_manager: Arc::new(ThemeManager::new()),
10261            tui: None,
10262            current_model_id: std::sync::Mutex::new(String::new()),
10263            show_images: std::sync::Mutex::new(true),
10264            history: std::sync::Mutex::new(Vec::new()),
10265            history_index: std::sync::Mutex::new(-1),
10266            history_draft: std::sync::Mutex::new(None),
10267            last_input_tokens: std::sync::Mutex::new(0),
10268            scoped_edit: std::sync::Mutex::new(None),
10269            markdown_transformer: std::sync::Mutex::new(None),
10270            extension_session: Arc::new(std::sync::Mutex::new(
10271                rpi_extensions::ExtensionSession::none(),
10272            )),
10273            search: Arc::new(AltScreenSearch::new()),
10274            search_bar: Arc::new(SearchBar::new()),
10275            selection_start: std::sync::Mutex::new(None),
10276            selection_end: std::sync::Mutex::new(None),
10277        });
10278        {
10279            let mut combined = CombinedAutocompleteProvider::new();
10280            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10281                build_builtin_registry().visible_entries(),
10282            )));
10283            state.autocomplete.set_provider(Arc::new(combined));
10284        }
10285
10286        let editor = Arc::new(Editor::simple());
10287        editor.set_text("/he");
10288        editor.set_cursor(0, 3);
10289        refresh_autocomplete(&state, &editor);
10290        let lines = state.autocomplete_container.render(80);
10291        let joined: String = lines.join("\n");
10292        assert!(
10293            joined.contains("/help"),
10294            "slash suggestions not rendered: {joined}"
10295        );
10296
10297        // Clear: no suggestions for plain text.
10298        editor.set_text("hello");
10299        editor.set_cursor(0, 5);
10300        refresh_autocomplete(&state, &editor);
10301        assert!(state.autocomplete_container.render(80).is_empty());
10302    }
10303
10304    #[test]
10305    fn test_select_list_swap_restores_editor() {
10306        // The editor-container swap: opening a selector replaces the editor
10307        // child; closing restores it. Verify the container child count + the
10308        // active_selector flag round-trip.
10309        let state = Arc::new(TuiState {
10310            current_assistant: std::sync::Mutex::new(None),
10311            tool_components: std::sync::Mutex::new(HashMap::new()),
10312            bash_components: std::sync::Mutex::new(HashMap::new()),
10313            themes_enabled: true,
10314            hide_thinking: std::sync::Mutex::new(false),
10315            tool_outputs_expanded: std::sync::Mutex::new(false),
10316            show_terminal_progress: true,
10317            status: std::sync::Mutex::new(RunStatus::Idle),
10318            js_preparation_cancel: std::sync::Mutex::new(None),
10319            footer: Arc::new(FooterComponent::new()),
10320            status_container: Arc::new(Container::new()),
10321            chat_container: Arc::new(Container::new()),
10322            loader: Arc::new(Loader::new()),
10323            last_assistant_text: std::sync::Mutex::new(String::new()),
10324            active_selector: std::sync::Mutex::new(None),
10325            active_extension_editor: std::sync::Mutex::new(None),
10326            active_extension_input: std::sync::Mutex::new(None),
10327            active_extension_cancel: std::sync::Mutex::new(None),
10328            autocomplete: AutocompleteManager::new(),
10329            autocomplete_container: Arc::new(Container::new()),
10330            autocomplete_max_visible: 5,
10331            pending_images: std::sync::Mutex::new(Vec::new()),
10332            theme_manager: Arc::new(ThemeManager::new()),
10333            tui: None,
10334            current_model_id: std::sync::Mutex::new(String::new()),
10335            show_images: std::sync::Mutex::new(true),
10336            history: std::sync::Mutex::new(Vec::new()),
10337            history_index: std::sync::Mutex::new(-1),
10338            history_draft: std::sync::Mutex::new(None),
10339            last_input_tokens: std::sync::Mutex::new(0),
10340            scoped_edit: std::sync::Mutex::new(None),
10341            markdown_transformer: std::sync::Mutex::new(None),
10342            extension_session: Arc::new(std::sync::Mutex::new(
10343                rpi_extensions::ExtensionSession::none(),
10344            )),
10345            search: Arc::new(AltScreenSearch::new()),
10346            search_bar: Arc::new(SearchBar::new()),
10347            selection_start: std::sync::Mutex::new(None),
10348            selection_end: std::sync::Mutex::new(None),
10349        });
10350        let editor_container = Arc::new(Container::new());
10351        let editor = Arc::new(Editor::simple());
10352        editor_container.add_child(editor.clone());
10353        assert!(!state.selector_open());
10354
10355        let tui_terminal = Box::new(ProcessTerminal::new());
10356        let tui = Arc::new(TuiAltScreen::new(tui_terminal, true, None));
10357        let list = Arc::new(SelectList::new(
10358            vec![SelectItem::new("a", "A"), SelectItem::new("b", "B")],
10359            5,
10360        ));
10361        open_selector(
10362            &state,
10363            &editor_container,
10364            &editor,
10365            &tui,
10366            list,
10367            SelectorKind::Theme,
10368        );
10369        assert!(state.selector_open());
10370        // list only (editor swapped out).
10371        assert_eq!(editor_container.child_count(), 1);
10372
10373        close_selector(&state, &editor_container, &editor, &tui);
10374        assert!(!state.selector_open());
10375        // editor restored.
10376        assert_eq!(editor_container.child_count(), 1);
10377    }
10378
10379    #[test]
10380    fn test_message_history_browse_restores_draft() {
10381        // ↑/↓ recall semantics (mirrors TS navigateHistory): push two
10382        // messages, browse older → newer → back past the newest restores the
10383        // draft the user was typing.
10384        let state = Arc::new(TuiState {
10385            current_assistant: std::sync::Mutex::new(None),
10386            tool_components: std::sync::Mutex::new(HashMap::new()),
10387            bash_components: std::sync::Mutex::new(HashMap::new()),
10388            themes_enabled: true,
10389            hide_thinking: std::sync::Mutex::new(false),
10390            tool_outputs_expanded: std::sync::Mutex::new(false),
10391            show_terminal_progress: true,
10392            status: std::sync::Mutex::new(RunStatus::Idle),
10393            js_preparation_cancel: std::sync::Mutex::new(None),
10394            footer: Arc::new(FooterComponent::new()),
10395            status_container: Arc::new(Container::new()),
10396            chat_container: Arc::new(Container::new()),
10397            loader: Arc::new(Loader::new()),
10398            last_assistant_text: std::sync::Mutex::new(String::new()),
10399            active_selector: std::sync::Mutex::new(None),
10400            active_extension_editor: std::sync::Mutex::new(None),
10401            active_extension_input: std::sync::Mutex::new(None),
10402            active_extension_cancel: std::sync::Mutex::new(None),
10403            autocomplete: AutocompleteManager::new(),
10404            autocomplete_container: Arc::new(Container::new()),
10405            autocomplete_max_visible: 5,
10406            pending_images: std::sync::Mutex::new(Vec::new()),
10407            theme_manager: Arc::new(ThemeManager::new()),
10408            tui: None,
10409            current_model_id: std::sync::Mutex::new(String::new()),
10410            show_images: std::sync::Mutex::new(true),
10411            history: std::sync::Mutex::new(Vec::new()),
10412            history_index: std::sync::Mutex::new(-1),
10413            history_draft: std::sync::Mutex::new(None),
10414            last_input_tokens: std::sync::Mutex::new(0),
10415            scoped_edit: std::sync::Mutex::new(None),
10416            markdown_transformer: std::sync::Mutex::new(None),
10417            extension_session: Arc::new(std::sync::Mutex::new(
10418                rpi_extensions::ExtensionSession::none(),
10419            )),
10420            search: Arc::new(AltScreenSearch::new()),
10421            search_bar: Arc::new(SearchBar::new()),
10422            selection_start: std::sync::Mutex::new(None),
10423            selection_end: std::sync::Mutex::new(None),
10424        });
10425        let editor = Arc::new(Editor::simple());
10426
10427        push_history(&state, "first message");
10428        push_history(&state, "second message");
10429        // Consecutive duplicate is skipped.
10430        push_history(&state, "second message");
10431        push_history(&state, "   "); // empty → skipped
10432        assert_eq!(state.history.lock().unwrap().len(), 2);
10433        assert_eq!(state.history.lock().unwrap()[0], "second message");
10434
10435        // User starts typing a fresh prompt.
10436        editor.set_text("half-typed");
10437        editor.set_cursor(0, 11);
10438
10439        // ↑ → most recent.
10440        navigate_history(&state, &editor, -1);
10441        assert_eq!(editor.get_text(), "second message");
10442        assert_eq!(*state.history_index.lock().unwrap(), 0);
10443        // ↑ → older.
10444        navigate_history(&state, &editor, -1);
10445        assert_eq!(editor.get_text(), "first message");
10446        assert_eq!(*state.history_index.lock().unwrap(), 1);
10447        // ↑ past the oldest → stays (no wrap).
10448        navigate_history(&state, &editor, -1);
10449        assert_eq!(editor.get_text(), "first message");
10450        // ↓ → newer.
10451        navigate_history(&state, &editor, 1);
10452        assert_eq!(editor.get_text(), "second message");
10453        // ↓ past the newest → restores the draft.
10454        navigate_history(&state, &editor, 1);
10455        assert_eq!(editor.get_text(), "half-typed");
10456        assert_eq!(*state.history_index.lock().unwrap(), -1);
10457    }
10458
10459    #[test]
10460    fn test_accept_top_suggestion_replaces_prefix() {
10461        // `/he` + Tab → `/help ` (slash command provider inserts a space).
10462        let state = Arc::new(TuiState {
10463            current_assistant: std::sync::Mutex::new(None),
10464            tool_components: std::sync::Mutex::new(HashMap::new()),
10465            bash_components: std::sync::Mutex::new(HashMap::new()),
10466            themes_enabled: true,
10467            hide_thinking: std::sync::Mutex::new(false),
10468            tool_outputs_expanded: std::sync::Mutex::new(false),
10469            show_terminal_progress: true,
10470            status: std::sync::Mutex::new(RunStatus::Idle),
10471            js_preparation_cancel: std::sync::Mutex::new(None),
10472            footer: Arc::new(FooterComponent::new()),
10473            status_container: Arc::new(Container::new()),
10474            chat_container: Arc::new(Container::new()),
10475            loader: Arc::new(Loader::new()),
10476            last_assistant_text: std::sync::Mutex::new(String::new()),
10477            active_selector: std::sync::Mutex::new(None),
10478            active_extension_editor: std::sync::Mutex::new(None),
10479            active_extension_input: std::sync::Mutex::new(None),
10480            active_extension_cancel: std::sync::Mutex::new(None),
10481            autocomplete: AutocompleteManager::new(),
10482            autocomplete_container: Arc::new(Container::new()),
10483            autocomplete_max_visible: 5,
10484            pending_images: std::sync::Mutex::new(Vec::new()),
10485            theme_manager: Arc::new(ThemeManager::new()),
10486            tui: None,
10487            current_model_id: std::sync::Mutex::new(String::new()),
10488            show_images: std::sync::Mutex::new(true),
10489            history: std::sync::Mutex::new(Vec::new()),
10490            history_index: std::sync::Mutex::new(-1),
10491            history_draft: std::sync::Mutex::new(None),
10492            last_input_tokens: std::sync::Mutex::new(0),
10493            scoped_edit: std::sync::Mutex::new(None),
10494            markdown_transformer: std::sync::Mutex::new(None),
10495            extension_session: Arc::new(std::sync::Mutex::new(
10496                rpi_extensions::ExtensionSession::none(),
10497            )),
10498            search: Arc::new(AltScreenSearch::new()),
10499            search_bar: Arc::new(SearchBar::new()),
10500            selection_start: std::sync::Mutex::new(None),
10501            selection_end: std::sync::Mutex::new(None),
10502        });
10503        {
10504            let mut combined = CombinedAutocompleteProvider::new();
10505            combined.add_provider(Arc::new(SlashCommandAutocompleteProvider::new(
10506                build_builtin_registry().visible_entries(),
10507            )));
10508            state.autocomplete.set_provider(Arc::new(combined));
10509        }
10510        let editor = Arc::new(Editor::simple());
10511        editor.set_text("/he");
10512        editor.set_cursor(0, 3);
10513        refresh_autocomplete(&state, &editor);
10514        let accepted = accept_top_suggestion(&state, &editor);
10515        assert!(accepted, "should accept the top suggestion");
10516        let text = editor.get_text();
10517        assert!(
10518            text.starts_with("/help"),
10519            "editor text should start with /help, got {text}"
10520        );
10521    }
10522
10523    #[test]
10524    fn configured_key_parser_supports_native_notation() {
10525        let combo = parse_configured_key("Ctrl+G").expect("ctrl+g should parse");
10526        assert_eq!(combo.code, KeyCode::Char('g'));
10527        assert!(combo.modifiers.contains(KeyModifiers::CONTROL));
10528        let combo = parse_configured_key("shift+tab").expect("shift+tab should parse");
10529        assert_eq!(combo.code, KeyCode::BackTab);
10530    }
10531
10532    #[test]
10533    fn double_escape_trigger_has_half_second_window() {
10534        let now = std::time::Instant::now();
10535        assert!(!double_escape_trigger(None, now));
10536        assert!(double_escape_trigger(
10537            Some(now - std::time::Duration::from_millis(500)),
10538            now
10539        ));
10540        assert!(!double_escape_trigger(
10541            Some(now - std::time::Duration::from_millis(501)),
10542            now
10543        ));
10544    }
10545
10546    #[test]
10547    fn pasted_multi_line_block_becomes_one_prompt_not_many() {
10548        // End-to-end shape of the fix: replay the key stream a Windows paste
10549        // produces ("l1\nl2\nl3" + a trailing Enter) through the same pieces
10550        // the key loop uses — the burst discriminator for each Enter and the
10551        // editor for the text — and assert that *no* submit fired while the
10552        // block arrived, then exactly one submit on the user's own Enter.
10553        use std::sync::atomic::{AtomicUsize, Ordering};
10554
10555        let editor = rpi_tui::Editor::simple();
10556        let submits = Arc::new(AtomicUsize::new(0));
10557        let submits_for_cb = submits.clone();
10558        editor.on_submit(Arc::new(move |_text: &str| {
10559            submits_for_cb.fetch_add(1, Ordering::SeqCst);
10560        }));
10561
10562        // Every pasted key is delivered back-to-back, so `now` is unchanged and
10563        // the console queue is never empty until the paste is drained.
10564        let now = std::time::Instant::now();
10565        let mut last_text_key_at: Option<std::time::Instant> = None;
10566        let block = ["l1", "l2", "l3"];
10567        for (index, line) in block.iter().enumerate() {
10568            for ch in line.chars() {
10569                editor.insert(&ch.to_string());
10570                last_text_key_at = Some(now);
10571            }
10572            if index + 1 < block.len() {
10573                // The pasted newline: more input is still queued behind it.
10574                let more_queued = true;
10575                assert!(
10576                    enter_is_paste_burst(last_text_key_at, now, more_queued),
10577                    "pasted newline {index} must not submit"
10578                );
10579                editor.insert("\n");
10580                last_text_key_at = Some(now);
10581            }
10582        }
10583        assert_eq!(editor.get_text(), "l1\nl2\nl3");
10584        assert_eq!(submits.load(Ordering::SeqCst), 0, "paste must not submit");
10585
10586        // The user's own Enter: a real gap, nothing queued.
10587        let typed_at = now + PASTE_BURST_GAP + std::time::Duration::from_millis(80);
10588        assert!(!enter_is_paste_burst(last_text_key_at, typed_at, false));
10589        editor.handle_key(crossterm::event::KeyEvent::new(
10590            KeyCode::Enter,
10591            KeyModifiers::NONE,
10592        ));
10593        assert_eq!(submits.load(Ordering::SeqCst), 1, "typed Enter submits once");
10594    }
10595
10596    #[test]
10597    fn pasted_win_crlf_text_has_no_stray_carriage_returns() {
10598        // A Windows paste delivers CRLF; the editor must fold it to LF so the
10599        // rendered block is clean and the cursor arithmetic stays sound.
10600        let editor = rpi_tui::Editor::simple();
10601        editor.insert("alpha\r\nbeta\r\n");
10602        assert_eq!(editor.get_text(), "alpha\nbeta\n");
10603        assert!(!editor.get_text().contains('\r'));
10604    }
10605
10606    #[test]
10607    fn pasted_enter_is_not_a_submit() {
10608        let now = std::time::Instant::now();
10609
10610        // Queued input behind the Enter => paste, regardless of history.
10611        assert!(enter_is_paste_burst(None, now, true));
10612
10613        // Enter immediately after pasted characters => paste (also covers a
10614        // paste whose final line ends with a newline, where nothing is queued).
10615        assert!(enter_is_paste_burst(
10616            Some(now - std::time::Duration::from_millis(1)),
10617            now,
10618            false
10619        ));
10620        assert!(enter_is_paste_burst(
10621            Some(now - (PASTE_BURST_GAP - std::time::Duration::from_millis(1))),
10622            now,
10623            false
10624        ));
10625
10626        // A typed Enter: nothing queued and a real gap since the last key.
10627        assert!(!enter_is_paste_burst(None, now, false));
10628        assert!(!enter_is_paste_burst(
10629            Some(now - PASTE_BURST_GAP),
10630            now,
10631            false
10632        ));
10633        assert!(!enter_is_paste_burst(
10634            Some(now - std::time::Duration::from_millis(120)),
10635            now,
10636            false
10637        ));
10638    }
10639
10640    // ---- ask_user TUI bridge ----
10641
10642    #[test]
10643    fn ask_user_tool_names_are_recognized() {
10644        assert!(is_ask_user_tool("ask_user"));
10645        assert!(is_ask_user_tool("ask_user_question"));
10646        assert!(!is_ask_user_tool("bash"));
10647    }
10648
10649    #[test]
10650    fn ask_user_args_display_never_leaks_transport_json() {
10651        let text = ask_user_args_display(&serde_json::json!({
10652            "question": "你的代理端口是多少?",
10653            "context": "本机",
10654            "options": ["7890", {"title": "7897", "description": "clash"}],
10655            "suggest": "1080"
10656        }));
10657        assert!(text.contains("你的代理端口是多少?"));
10658        assert!(text.contains("本机"));
10659        assert!(text.contains("Choices: 7890, 7897"));
10660        assert!(text.contains("Suggestions: 1080"));
10661        assert!(!text.trim_start().starts_with('{'));
10662
10663        let multi = ask_user_args_display(&serde_json::json!({
10664            "questions": [
10665                {"id": "a", "question": "Q1"},
10666                {"id": "b", "header": "H", "question": "Q2"}
10667            ]
10668        }));
10669        assert!(multi.contains("Q1"));
10670        assert!(multi.contains("H: Q2"));
10671
10672        assert_eq!(
10673            ask_user_args_display(&serde_json::json!({"type": "confirm", "summary": "Deploy?"})),
10674            "Confirmation requested: Deploy?"
10675        );
10676        assert!(ask_user_args_display(&serde_json::Value::Null).is_empty());
10677    }
10678
10679    #[test]
10680    fn ask_user_result_and_progress_prefer_readable_content() {
10681        let waiting = rpi_agent::AgentToolResult::text("Waiting for your answer\n端口?");
10682        assert!(ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &waiting)
10683            .contains("端口?"));
10684        let empty = rpi_agent::AgentToolResult::default();
10685        assert!(
10686            ask_user_progress_text(&serde_json::json!({"question": "端口?"}), &empty)
10687                .contains("端口?")
10688        );
10689        let answered = rpi_agent::AgentToolResult::text("端口?: 7897");
10690        assert_eq!(ask_user_result_text(&answered), "端口?: 7897");
10691        assert_eq!(ask_user_result_text(&empty), "Answer recorded.");
10692    }
10693
10694    #[test]
10695    fn ask_user_prompt_parser_supports_flat_and_questions_shapes() {
10696        let flat = rpi_extensions::UiDialogRequest {
10697            request_id: "r1".into(),
10698            tool_call_id: Some("c1".into()),
10699            ui: serde_json::json!({
10700                "kind": "selector",
10701                "id": "proxy_port",
10702                "header": "Proxy",
10703                "question": "端口?",
10704                "options": [{"title": "7890", "description": "text"}, {"title": "7897"}],
10705                "allowMultiple": true,
10706                "allowFreeform": false,
10707                "suggest": "1080"
10708            }),
10709            raw: serde_json::json!({}),
10710        };
10711        let prompt = parse_ask_user_prompt(&flat);
10712        assert_eq!(prompt.question_id, "proxy_port");
10713        assert_eq!(prompt.header.as_deref(), Some("Proxy"));
10714        assert_eq!(prompt.options.len(), 2);
10715        assert_eq!(prompt.options[0].1.as_deref(), Some("text"));
10716        assert!(prompt.allow_multiple);
10717        assert!(!prompt.allow_freeform);
10718        assert_eq!(prompt.suggest.as_deref(), Some("1080"));
10719
10720        let alias = rpi_extensions::UiDialogRequest {
10721            ui: serde_json::json!({
10722                "questions": [{"id": "q", "question": "Q?", "options": []}]
10723            }),
10724            ..flat.clone()
10725        };
10726        let prompt = parse_ask_user_prompt(&alias);
10727        assert_eq!(prompt.question_id, "q");
10728        assert!(prompt.allow_freeform, "no options defaults to freeform");
10729
10730        let confirm = rpi_extensions::UiDialogRequest {
10731            ui: serde_json::json!({"kind": "confirm", "question": "Go?"}),
10732            ..flat.clone()
10733        };
10734        let prompt = parse_ask_user_prompt(&confirm);
10735        assert_eq!(prompt.kind, "confirm");
10736        assert_eq!(prompt.options.len(), 2);
10737        assert_eq!(prompt.options[0].0, "Yes");
10738    }
10739
10740    #[test]
10741    fn ask_user_bridge_answers_and_cancels_by_request_id() {
10742        let mailbox = rpi_extensions::UiDialogMailbox::new();
10743        mailbox.attach();
10744        let bridge = AskUserBridge::new(mailbox.clone());
10745
10746        // Two concurrent requests must not cross answers.
10747        mailbox
10748            .handle(serde_json::json!({
10749                "op": "open", "requestId": "r1", "toolCallId": "call-r1",
10750                "ui": {"kind": "input", "question": "Port?"}
10751            }))
10752            .unwrap();
10753        mailbox
10754            .handle(serde_json::json!({
10755                "op": "open", "requestId": "r2", "toolCallId": "call-r2",
10756                "ui": {"kind": "input", "question": "Level?"}
10757            }))
10758            .unwrap();
10759
10760        let first = bridge.take_pending().expect("r1 visible");
10761        assert_eq!(first.request_id, "r1");
10762        assert!(
10763            bridge.take_pending().is_none(),
10764            "only one request occupies the input slot"
10765        );
10766        bridge.respond("r1", serde_json::json!({"text": "7897"}));
10767        assert_eq!(mailbox.poll("r1").unwrap()["answer"]["text"], "7897");
10768
10769        let second = bridge.take_pending().expect("r2 visible");
10770        assert_eq!(second.request_id, "r2");
10771        bridge.cancel("r2");
10772        assert_eq!(mailbox.poll("r2").unwrap()["status"], "cancelled");
10773
10774        // cancel_all drops anything still queued.
10775        mailbox
10776            .handle(serde_json::json!({
10777                "op": "open", "requestId": "r3",
10778                "ui": {"kind": "input", "question": "X?"}
10779            }))
10780            .unwrap();
10781        bridge.cancel_all();
10782        assert_eq!(mailbox.poll("r3").unwrap()["status"], "cancelled");
10783
10784        // A detached mailbox rejects new prompts (headless contract).
10785        bridge.shutdown();
10786        assert!(mailbox
10787            .handle(serde_json::json!({
10788                "op": "open", "requestId": "r4", "ui": {"kind": "input"}
10789            }))
10790            .is_err());
10791    }
10792}